Улучшение производительности 7-стороннего моделирования ролика из шестисторонней штамповки

В другом StackExchange был предложен следующий алгоритм (основанный на арифметическом кодировании) как эффективный метод для получения результатов 7-сторонней матрицы, когда все, что дано, - это 6-сторонняя матрица:

int rand7()
{
  static double a=0, width=7;  // persistent state

  while ((int)(a+width) != (int)a)
  {
    width /= 6;
    a += (rand6()-1)*width;
  }

  int n = (int)a;
  a -= n; 
  a *= 7; width *= 7;
  return (n+1);
}

Не будучи истинным математиком, я сделаю все возможное, чтобы объяснить, как работает этот алгоритм:

В начале каждого вызова rand7(), width есть отношение 7 s/6 t а a - неотрицательное значение с тем свойством, что a + width лежит в интервале [0, 7) после базового случая. При вводе цикла while width - максимальное значение, которое можно добавить в a. Если floor(a + width) отличается от floor(a), то случайный выбор {0, width * 1/6, width * 1/3, width * 1/2, width * 2/3, width * 5/6} добавляется к a, а показатель t увеличивается на единицу (уменьшая значение width степенью 6). Заметим, что после итерации свойство, что a + width лежит в интервале [0, 7), остается инвариантным. Когда width становится меньше разницы ceil(a) - a, итерации останавливаются. Цикл добавляет больше энтропии в a, пока это может фактически повлиять на результат броска кубика, и интуитивно это создает случайное действительное число в диапазоне [0, 7] с использованием базы 6. После выхода из петля, рулон матрицы считается floor(a) + 1, а a сводится к его дробной части. В этой точке a + width лежит в интервале [0, 1). Чтобы подготовиться к следующему вызову и сохранить свойство инварианта, как a, так и width масштабируются в 7 раз (для width это увеличивает показатель s на 1).

Приведенное выше объясняет работу индуктивного шага. Анализ базового случая оставлен в качестве упражнения для заинтересованного читателя.

Конечно, с точки зрения эффективности использование арифметики с плавающей запятой сразу же появляется в виде перетаскивания производительности (при условии, что производительность rand6() уже достаточна и сама не может быть улучшена). Поддерживая этот алгоритм арифметического кодирования, каков наилучший подход к удалению использования плавающей запятой?

Ответы

Ответ 1

Следуя за комментарием, я сделал здесь версию с фиксированной точкой алгоритма. Он использует unsigned 4.60 (т.е. В дробной части числа есть 60 бит), что составляет несколько бит, чем вы можете выйти из double:

int rand7fixed() {
    static uint64_t a = 0;
    static uint64_t width = 7UL<<60;
    static const uint64_t intmask = 0xfUL<<60;

    while (((a+width)&intmask) != (a&intmask)) {
      width /= 6;
      a += (rand6()-1)*width;
    }

    int n = a >> 60;
    a &=~intmask;
    a *= 7;
    width *= 7;
    return n+1;
}

Вышеизложенное оказывается примерно на треть быстрее, чем двойная версия в OP (см. результаты тестов и примечания ниже). Я подозреваю, что время-waster - это не арифметика с плавающей запятой, а конверсии от double до int.

Как указывает R.., это не устраняет смещения; он просто уменьшает его. Простым и разумно быстрым несмещенным алгоритмом было бы многократно генерировать два значения rand6() h и l, пока хотя бы одно из них не равно нулю, а затем верните h*6+l % 7:

int rand7_2() {
  int hi, lo;
  do {
    hi = rand6() - 1;
    lo = rand6() - 1;
  } while (hi + lo);
  return (hi * 6 + lo) % 7 + 1;
}

Если вам нужно уменьшить количество вызовов до rand6, вы можете использовать тот факт, что 6 12 составляет чуть больше 7 11 для генерации 11 7-образных роликов за раз. По-прежнему необходимо отказаться от некоторых наборов из 12 6-рулонов для устранения смещения; частота отбрасывающих наборов будет (612−711)/612), или приблизительно 1 в 11, поэтому в среднем вам понадобится около 1,19 6-рулонов на 7-рулон. Вы могли бы добиться большего, используя 25 6-рулонов для создания 23 7-рулонов (1,13 6-рулонов на 7-рулон), но это не совсем подходит для 64-разрядной арифметики, поэтому предельное преимущество при вызовах rand6 было бы разжевываться, выполняя вычисления в 128-битном режиме.

Здесь решение 11/12:

int rand7_12() {
    static int avail = 0;
    static uint32_t state = 0;
    static const uint32_t discard = 7*7*7*7*7*7*7*7*7*7*7; // 7 ** 11
    static const int out_per_round = 11;
    static const int in_per_round = 12;

    if (!avail) {
      do {
        state = rand6() - 1;
        for (int needed = in_per_round - 1; needed; --needed)
          state = state * 6 + rand6() - 1;
      }
      while (state >= discard);
      avail = out_per_round;
    }
    int rv = state % 7;
    state /= 7;
    --avail;
    return rv + 1;
}

В теории вы должны иметь возможность уменьшить отношение к log76, что составляет около 1.086. Например, вы можете сделать это, создав 895 7-рулонов из 972 6-рулонов, отбросив примерно один из 1600 наборов в среднем на 1.087 6-roll/7-roll, но вам понадобится 2513-битная арифметика для удерживайте состояние.

Я тестировал все четыре функции с не очень точным эталоном, который вызывает rand7 700 000 000 раз, а затем печатает гистограмму результатов. Результаты:

                                                  User time with
Algorithm       User time      rand6() calls     cycling rand6()
----------      ---------      -------------     ---------------
double          32.6 secs          760223193           13.2 secs
fixed           29.4 secs          760223194            7.9 secs
2 for 1         40.2 secs         1440004276
12 for 11       23.7 secs          840670008

Ниже приведенная выше реализация rand6() представляет собой стандартную библиотеку С++ cnnn cncnnn(), использующую mt19937_64 (64-битный Mersenne Twister) в качестве PRNG. Чтобы попытаться получить лучший дескриптор времени, затраченного на стандартную библиотеку, я также провел тест с использованием простого циклического счетчика в качестве генератора псевдослучайных чисел; остальные 13,2 и 7,9 секунды представляют (примерно) время, затрачиваемое на сам алгоритм, из которого можно сказать, что алгоритм с фиксированной точкой примерно на 40% быстрее. (Трудно получить хорошее чтение по алгоритмам объединения, поскольку фиксированная последовательность упрощает предсказание ветвления и уменьшает количество вызовов rand6, но они занимают менее 5 секунд.)

Наконец, гистограммы в случае, если кто-то хочет проверить смещение (также включает в себя прогон с std::uniform_int_distribution(1,7) для справки):

Algorithm          1          2          3          4          5          6          7
---------  ---------  ---------  ---------  ---------  ---------  ---------  ---------
reference  100007522  100002456  100015800  100005923   99972185  100008908   99987206
double     100014597  100005975   99982219   99986299  100004561  100011049   99995300
fixed      100009603  100009639  100034790   99989935   99995502   99981886   99978645
2 for 1    100004476   99997766   99999521  100001382   99992802  100003868  100000185
12 for 11   99988156  100004974  100020070  100001912   99997472   99995015   99992401

Ответ 2

[изменить]

Необходимо улучшить метод ниже, но следует простой беспристрастный метод. Он неэффективен, поскольку он называет rand6() не реже двух раз. (Предположим, что rand6() несмещен)

int rand7simple(void) {
  int product;
  do {
    int a = rand6() - 1;
    int b = rand6() - 1;
    product = a*6 + b;  // produce unbiased distributed numbers 0 .. 35
  } while (product >= 35);  // Redo 1 in 36 times
  // produce unbiased distributed numbers 0 .. 34
  return product%7 + 1;
}

Для каждого 6 вызовов rand7(), rand6() следует вызывать 7 раз. Инициализируйте широкое целочисленное состояние, чтобы свести к минимуму смещение.

Необходимо проверить это позже. GTG.

int rand7(void) {
  static int count = -1;
  static unsigned long long state = 0;
  if (count < 0) {
    count = 0;
    for (int i=0; i<25; i++) {
      state *= 6;
      state += rand6();
    }
  int retval = state % 7;
  state /= 7;

  int i = (count >= 6) + 1; 
  if (++count > 6) count = 0;
  while (i-- > 0) {
    state *= 6;
    state += rand6();
  }
  return retval;
}