Быстрая реализация большого целочисленного счетчика (в C / C ++)

0

Моя цель заключается в следующем,

Создавайте последовательные значения, чтобы каждый новый никогда не генерировался раньше, пока не будут созданы все возможные значения. На этом этапе счетчик снова запускает ту же последовательность. Главное здесь, что все возможные значения генерируются без повторения (пока период не исчерпан). Не имеет значения, проста ли последовательность 0, 1, 2, 3,... или в другом порядке.

Например, если диапазон может быть представлен просто unsigned, то

void increment (unsigned &n) {++n;}

достаточно. Однако целочисленный диапазон больше 64 бит. Например, в одном месте мне нужно создать 256-битную последовательность. Простая реализация выглядит следующим образом, просто чтобы проиллюстрировать то, что я пытаюсь сделать,

typedef std::array<uint64_t, 4> ctr_type;
static constexpr uint64_t max = ~((uint64_t) 0);
void increment (ctr_type &ctr)
{
    if (ctr[0] < max) {++ctr[0]; return;}
    if (ctr[1] < max) {++ctr[1]; return;}
    if (ctr[2] < max) {++ctr[2]; return;}
    if (ctr[3] < max) {++ctr[3]; return;}
    ctr[0] = ctr[1] = ctr[2] = ctr[3] = 0;
}

Поэтому, если ctr начинается со всех нулей, то сначала ctr[0] увеличивается один за другим, пока не достигнет max, а затем ctr[1] и т.д. Если все 256 бит установлены, то мы перезагружаем его на все ноль и начинаем заново.

Проблема в том, что такая реализация на удивление медленная. Моя текущая улучшенная версия похожа на следующее:

void increment (ctr_type &ctr)
{
    std::size_t k = (!(~ctr[0])) + (!(~ctr[1])) + (!(~ctr[2])) + (!(~ctr[3]))
    if (k < 4)
        ++ctr[k];
    else
        memset(ctr.data(), 0, 32);

}

Если счетчик обрабатывается только с помощью указанной выше функции increment и всегда начинается с нуля, тогда ctr[k] == 0 если ctr[k - 1] == 0. И, таким образом, значение k будет индексом первого элемента, который меньше максимального.

Я ожидал, что первое будет быстрее, так как неверное предсказание ветки произойдет только один раз за каждые 2 ^ 64 итерации. Второе, хотя неверное предсказание происходит только каждые 2 ^ 256 итераций, оно не должно иметь значения. И кроме ветвления, ему нужно четыре побитового отрицания, четыре булево отрицание и три добавления. Это может стоить гораздо больше, чем первое.

Тем не менее, как clang, gcc, так и intel icpc генерируют двоичные файлы, а второй - намного быстрее.

Мой главный вопрос: кто-нибудь знает, есть ли более быстрый способ реализовать такой счетчик? Не имеет значения, начинается ли счетчик путем увеличения первых целых чисел или если он реализован как массив целых чисел вообще, пока алгоритм генерирует все 2 ^ 256 комбинаций из 256 бит.

Что усложняет ситуацию, мне также требуется неравномерное приращение. Например, каждый раз, когда счетчик увеличивается на K где K > 1, но почти всегда остается константой. Моя текущая реализация аналогична предыдущей.

Чтобы предоставить некоторый контекст, одно место, использующее счетчики, использует их в качестве входных данных для инструкций AES-NI aesenc. Таким образом, __m128i целое число 128 бит (загруженное в __m128i), после прохождения раундов 10 (или 12 или 14, в зависимости от размера ключа) команд, генерируется 128-bits целое число 128-bits. Если я сгенерирую одно целое число __m128i сразу, тогда стоимость increment имеет мало значения. Однако, поскольку aesenc имеет довольно небольшую задержку, я генерирую целые числа по блокам. Например, у меня может быть 4 блока, ctr_type block[4], инициализированный эквивалентно следующему,

block[0]; // initialized to zero
block[1] = block[0]; increment(block[1]);
block[2] = block[1]; increment(block[2]);
block[3] = block[2]; increment(block[3]);

И каждый раз, когда мне нужен новый вывод, я increment каждый block[i] на 4 и генерирую сразу 4 __m128i. По инструкциям чередования, в общем, я смог увеличить пропускную способность и сократить циклы на байты вывода (cpB) с 6 до 0,9 при использовании 2 64-битных целых чисел в качестве счетчика и 8 блоков. Однако, если вместо этого использовать 4 32-битных целых числа в качестве счетчика, пропускная способность, измеренная как байты в секунду, уменьшается до половины. Я знаю, что в x86-64 в некоторых ситуациях 64-битные целые числа могут быть быстрее 32 бит. Но я не ожидал, что такая простая операция приращения будет иметь такое большое значение. Я тщательно тестировал приложение, и increment действительно замедляет работу программы. Поскольку загрузка в __m128i и сохранение вывода __m128i в используемые 32- __m128i или 64- __m128i целые числа выполняются с помощью выровненных указателей, единственная разница между 32-битной и 64-битной версиями заключается в том, как счетчик увеличивается. Я ожидал, что ожидаемый AES-NI после загрузки целых чисел в __m128i должен доминировать в производительности. Но при использовании 4 или 8 блоков это явно не так.

Итак, к резюме, мой главный вопрос заключается в том, что, если кто-нибудь знает способ улучшить вышеупомянутую реализацию счетчика.

  • 1
    Как быстро вы ожидаете переполнения 64-битного счетчика? По моей математике, потребовалось бы ~ 300 лет, если бы вы могли обновить его на частоте 2 ГГц. 256-битный счетчик невероятно огромен.
  • 0
    Вы пытаетесь атаковать грубой силой на AES256 ???
Показать ещё 3 комментария
Теги:
performance
algorithm

4 ответа

4

Это не только медленно, но и невозможно. Полная энергия Вселенной недостаточна для 2 ^ 256-битных изменений. И для этого потребуется серый счетчик.

Следующим шагом перед оптимизацией является исправление исходной реализации

void increment (ctr_type &ctr)
{
    if (++ctr[0] != 0) return;
    if (++ctr[1] != 0) return;
    if (++ctr[2] != 0) return;
    ++ctr[3];
}

Если каждому ctr[i] не разрешено переполняться до нуля, период будет равен 4 * (2 ^ 32), как в 0-9, 19,29,39,49,...99, 199,299,... и 1999,2999,3999,..., 9999.

В ответ на комментарий - требуется 2 ^ 64 итераций для первого переполнения. Будучи щедрым, до 2 ^ 32 итераций может проходить через секунду, а это означает, что программа должна выполнить 2 ^ 32 секунды, чтобы выполнить первую операцию. Это около 136 лет.

РЕДАКТИРОВАТЬ

Если исходная реализация с 2 ^ 66 состояниями действительно нужна, я бы предложил изменить интерфейс и функциональность на что-то вроде:

  (*counter) += 1;
  while (*counter == 0)
  {
     counter++;  // Move to next word
     if (counter > tail_of_array) {
        counter = head_of_array;
        memset(counter,0, 16);
        break;
     }
  }

Дело в том, что переполнение все еще очень редко. Почти всегда есть только одно слово, которое нужно увеличить.

  • 0
    Традиционно для эффективной реализации сложения с множественной точностью используется инструкция «добавить с переносом», но, к сожалению, она не доступна в C или C ++ без встроенного ассемблера.
2

Если вы используете GCC

unsigned __int128 H = 0, L = 0;
L++;
if (L == 0) H++;

В системах, где __int128 недоступен

unsigned long long c[4] = { 0 };
c[0]++;
if (c[0] == 0)
{
    c[1]++;
    if (c[1] == 0)
    {
        c[2]++;
        if (c[2] == 0)
        {
            c[3]++;
        }
    }
}

Используя встроенную сборку, гораздо проще сделать это, используя флаг переноса. К сожалению, большинство языков высокого уровня не имеют доступа к нему.

В любом случае это довольно тратит время, так как общее количество частиц во Вселенной составляет всего около 10 80, и вы даже не можете подсчитать 64-битный счетчик в своей жизни

  • 0
    @AkiSuihkonen Я отредактировал код
  • 0
    Спасибо за выделение 128-битной реализации в GCC, доступной с ... 2003 (более 10 лет назад). GCC генерирует почти оптимальный код без ветвей из H + = (++ L == 0)
Показать ещё 1 комментарий
0

Вы указываете "Создайте последовательные значения, чтобы каждый новый никогда не генерировался раньше"

Чтобы создать набор таких значений, посмотрите на линейные конгруэнтные генераторы https://en.wikipedia.org/wiki/Linear_congruential_generator

  • последовательность x = (x * 1 + 1)% (power_of_2), вы подумали об этом, это просто последовательные числа.

  • последовательность x = (x * 13 + 137)% (мощность 2), это порождает уникальные числа с прогнозируемым периодом (power_of_2 - 1), а уникальные числа выглядят более "случайными", вроде псевдослучайными. Вам нужно прибегнуть к произвольной арифметике точности, чтобы заставить ее работать, а также все трюки умножений по константам. Это даст вам хороший способ начать.

Вы также жалуетесь, что ваш простой код является "медленным",

При частоте 4.2 ГГц, выполняющей 4 интрограммы за цикл и используя векторизацию AVX512, на 64-ядерном компьютере с многопоточной версией вашей программы, выполняющей только что приращения, вы получаете только 64x8x4 * 2 ^ 32 = 8796093022208 приращений в секунду, что составляет 2 ^ 64 приращений, достигнутых за 25 дней. Этот пост старый, вы, возможно, достигли 841632698362998292480 к настоящему времени, запустив такую программу на такой машине, и вы славно достигнете 1683265396725996584960 через 2 года.

Вы также требуете "до создания всех возможных значений".

Вы можете генерировать только конечное количество значений, в зависимости от того, сколько вы готовы платить за энергию для питания своих компьютеров. Как упоминалось в других ответах, с 128 или 256 битными числами, даже будучи самым богатым человеком в мире, вы никогда не обмотаетесь до того, как произойдет первое из этих условий:

  • выходить из денег
  • конец человечества (никто не получит результат вашего программного обеспечения)
  • сжигание энергии из последних частиц Вселенной
  • 0
    предположим, что вы можете достичь 2 ^ 64 за 1 месяц, тогда вам понадобится 2 ^ 192 месяца, чтобы достичь 2 ^ 256, что составляет ~ 5.23e56 лет
0

Ни одна из ваших версий счетчика не увеличивается правильно. Вместо подсчета до UINT256_MAX вы на самом деле просто рассчитываете до UINT64_MAX 4 раза, а затем снова возвращаетесь в 0. Это видно из того факта, что вы не потрудились очистить любой из индексов, достигших максимального значения, до тех пор, пока все они не достигнут максимального значения. Если вы измеряете производительность, исходя из того, как часто счетчик достигает всех бит 0, вот почему. Таким образом, ваши алгоритмы не генерируют все комбинации из 256 бит, что является заявленным требованием.

  • 2
    Нет не будет Первое выполнение может не произойти в течение сотен лет.

Ещё вопросы

Сообщество Overcoder
Наверх
Меню