Как быстро посчитать биты в отдельные бины в ряду целых чисел на Sandy Bridge?

Обновление: пожалуйста, прочтите код, он НЕ касается подсчета битов в одном int

Можно ли улучшить производительность следующего кода с помощью какого-нибудь умного ассемблера?

uint bit_counter[64];

void Count(uint64 bits) {
  bit_counter[0] += (bits >> 0) & 1;
  bit_counter[1] += (bits >> 1) & 1;
  // ..
  bit_counter[63] += (bits >> 63) & 1;
}

Count находится в самом внутреннем цикле моего алгоритма.

Обновление: Архитектура: x86-64, Sandy Bridge, поэтому можно использовать SSE4.2, AVX1 и более ранние технологии, но не AVX2 или BMI1/2.

bits переменная имеет почти случайные биты (близкие к полунулям и полуединицам)


person Łukasz Lew    schedule 17.10.2011    source источник
comment
Кхм, сборка для какой архитектуры?   -  person NPE    schedule 17.10.2011
comment
Вы можете, по крайней мере, сделать эту функцию Count красивее: for(int i=0; i < 64; ++i) bit_counter[i] += (bits >> i) & 1;.   -  person Xeo    schedule 17.10.2011
comment
Насколько широкими должны быть битовые счетчики? Должны ли они быть uints или более узкими?   -  person harold    schedule 17.10.2011
comment
64 бит минимум. Я бы предпочел использовать 128-битные регистры SSE.   -  person Łukasz Lew    schedule 17.10.2011
comment
@Xeo: Prettier намного медленнее.   -  person Łukasz Lew    schedule 17.10.2011
comment
@Lukasz: ты уверен? Я бы оставил цикл разворачиваться компилятору. Поскольку границы цикла for являются константами времени компиляции, я почти уверен, что хороший компилятор может и будет оптимизировать его.   -  person Xeo    schedule 17.10.2011
comment
Я не думаю, что использование 128-битных регистров SSE быстрее, чем классические 64-битные регистры. Проверьте мою вторую версию.   -  person GJ.    schedule 17.10.2011
comment
@Xeo: Время. Prettier (в данном случае) значительно медленнее. Ручное развертывание цикла Лукаша Лью работает быстрее — по крайней мере, с gcc 4.2 и LLVM   -  person the wolf    schedule 18.10.2011


Ответы (9)


Может быть, вы можете сделать 8 сразу, взяв 8 бит, разнесенных на 8, и сохранив 8 uint64 для подсчета. Это всего лишь 1 байт на один счетчик, поэтому вы можете накопить только 255 вызовов count, прежде чем вам придется распаковывать эти uint64.

person harold    schedule 17.10.2011
comment
Это ОЧЕНЬ интересно. Лучший ответ на данный момент. - person Łukasz Lew; 17.10.2011
comment
Теоретически вы можете распаковывать с очень похожей логикой. Распаковывайте uint64[8] в uint64[16] каждые 256*255 итераций. Однако вы быстро заметите, что этот второй этап встречается гораздо реже и, следовательно, добавляет ‹1% улучшения. т.е. первый шаг уже настолько эффективен, что вам трудно его улучшить. Этот алгоритм также хорошо подходит для оптимизации SSE. - person MSalters; 17.10.2011

Вы можете попробовать сделать это с SSE, увеличивая 4 элемента за итерацию.

Предупреждение: за непроверенным кодом следует...

#include <stdint.h>
#include <emmintrin.h>

uint32_t bit_counter[64] __attribute__ ((aligned(16)));
                     // make sure bit_counter array is 16 byte aligned for SSE

void Count_SSE(uint64 bits)
{
    const __m128i inc_table[16] = {
        _mm_set_epi32(0, 0, 0, 0),
        _mm_set_epi32(0, 0, 0, 1),
        _mm_set_epi32(0, 0, 1, 0),
        _mm_set_epi32(0, 0, 1, 1),
        _mm_set_epi32(0, 1, 0, 0),
        _mm_set_epi32(0, 1, 0, 1),
        _mm_set_epi32(0, 1, 1, 0),
        _mm_set_epi32(0, 1, 1, 1),
        _mm_set_epi32(1, 0, 0, 0),
        _mm_set_epi32(1, 0, 0, 1),
        _mm_set_epi32(1, 0, 1, 0),
        _mm_set_epi32(1, 0, 1, 1),
        _mm_set_epi32(1, 1, 0, 0),
        _mm_set_epi32(1, 1, 0, 1),
        _mm_set_epi32(1, 1, 1, 0),
        _mm_set_epi32(1, 1, 1, 1)
    };

    for (int i = 0; i < 64; i += 4)
    {
        __m128i vbit_counter = _mm_load_si128(&bit_counter[i]);
                                          // load 4 ints from bit_counter
        int index = (bits >> i) & 15;     // get next 4 bits
        __m128i vinc = inc_table[index];  // look up 4 increments from LUT
        vbit_counter = _mm_add_epi32(vbit_counter, vinc);
                                          // increment 4 elements of bit_counter
        _mm_store_si128(&bit_counter[i], vbit_counter);
    }                                     // store 4 updated ints
}

Как это работает: по сути, все, что мы здесь делаем, это векторизация исходного цикла, так что мы обрабатываем 4 бита на итерацию цикла вместо 1. Таким образом, теперь у нас есть 16 итераций цикла вместо 64. Для каждой итерации мы загружаем 4 бита из bits, затем использовать их в качестве индекса в LUT, который содержит все возможные комбинации 4 приращений для текущих 4 бит. Затем мы добавляем эти 4 приращения к текущим 4 элементам bit_counter.

Количество загрузок, хранилищ и добавлений уменьшено в 4 раза, но это будет несколько компенсировано загрузкой LUT и прочими служебными операциями. Тем не менее, вы все еще можете увидеть 2-кратное ускорение. Мне было бы интересно узнать результат, если вы решите попробовать.

person Paul R    schedule 17.10.2011
comment
ух ты. Я действительно не вижу, что это делает. Но выглядит многообещающе. - person sehe; 17.10.2011
comment
Можете ли вы объяснить, как работает код? - person Łukasz Lew; 17.10.2011
comment
Конечно, я только что добавил некоторые комментарии к коду, которые вы, возможно, еще не видели, и вскоре я добавлю дополнительные пояснения. - person Paul R; 17.10.2011
comment
Не уверен в этом LUT - PSHUFB предполагает, что вы можете использовать регистр XMM для параллельного Вместо этого LUT, который должен быть намного быстрее, чем последовательный поиск в памяти. (В связанной статье используется PSHUFB для обычного счетчика всплывающих окон, но это, очевидно, не единственное возможное использование LUT). - person MSalters; 17.10.2011
comment
@MSalters: я не думаю, что в этом случае вы можете создать таблицу поиска PSHUB, поскольку таблица поиска должна быть разной для каждого элемента. - person Paul R; 17.10.2011
comment
я так не думаю; LUT останется прежним: значения 0–15 (16x4 бита = 64), но теперь они упакованы в 128-битный вектор. Однако я понял, что параллельный поиск может быть немного менее параллельным, чем я предполагал. Однако нужно больше думать. - person MSalters; 17.10.2011
comment
Я не думаю, что это работает, я боюсь... - person ; 18.10.2011
comment
@ Colt 45: что не работает? Ответ выше или идея ПШУФБ ЛУТ? - person Paul R; 18.10.2011
comment
@Paul R: извините за двусмысленность. ;-) Приведенный выше ответ дает другой результат для bit counter, чем код OP, и, несмотря на это, он медленнее исходного кода - по крайней мере, когда я его скомпилировал. - person ; 18.10.2011
comment
@ Colt 45: Хорошо, я сказал, что это непроверенный код. ;-) Возможно, есть баги, т.е. значения в записях таблицы поиска, возможно, потребуется поменять местами. Также производительность будет зависеть от того, какой процессор вы используете — в идеале вы хотели бы запустить его на процессорах Intel x86-64 текущего поколения, например. Ядро i7. - person Paul R; 18.10.2011

Ознакомьтесь с лайфхаками.

Редактировать Что касается «накопления ведра битовой позиции» (bit_counter[]), у меня есть ощущение, что это может быть хорошим случаем для valarrays + маскирование. Хотя это было бы неплохо для кодирования + тестирования + профилирования. Дайте мне знать, если вы действительно заинтересованы.

В наши дни вы можете очень близко подойти к поведению valarray, используя связанные кортежи (TR1, boost или C++11); У меня такое чувство, что его будет проще читать и медленнее компилировать.

person sehe    schedule 17.10.2011
comment
+1, я как раз собирался предложить ту же веб-страницу. Используя таблицу поиска, вы можете сделать это в O(1). - person Gaston; 17.10.2011
comment
@PaulR: я не согласен. Он обрамляет тему. Я делюсь своими мыслями о том, как применить его к этому вопросу (вы можете использовать поиск по байтам для хранения масок valarray. Или имитировать то же самое с помощью кортежей. Векторизующий компилятор (Intel, gcc), вероятно, оптимизирует результат в правильные инструкции SIMD. К сожалению, у меня нет времени, чтобы решить это сразу :) - person sehe; 17.10.2011
comment
Это никоим образом не отвечает на мой вопрос. Также я не вижу здесь помощи valarrays. - person Łukasz Lew; 17.10.2011
comment
Хотя хаки с битами действительно очень полезны, я не думаю, что они действительно помогают в этом конкретном вопросе. - person Paul R; 17.10.2011
comment
@Gastón: O (1) идеализирован и игнорирует влияние размера таблицы поиска (местность кеша: если таблица станет слишком большой, масштабирование перестанет - алгоритмическая сложность может быть O (1), но это не означает, что ЦП способен выполнить это эффективно, потому что ЦП не является идеальной моделью) - person sehe; 17.10.2011
comment
Не отвечает на вопрос... - person ; 18.10.2011

Видимо это можно сделать быстро с "вертикальными счетчиками". С ныне несуществующей страницы о битовых трюках (архив) от @steike:

Рассмотрим обычный массив целых чисел, где мы читаем биты по горизонтали:

       msb<-->lsb
  x[0]  00000010  = 2
  x[1]  00000001  = 1
  x[2]  00000101  = 5

Вертикальный счетчик хранит числа, как следует из названия, вертикально; то есть k-битный счетчик хранится в k словах, по одному биту в каждом слове.

  x[0]  00000110   lsb ↑
  x[1]  00000001       |
  x[2]  00000100       |
  x[3]  00000000       |
  x[4]  00000000   msb ↓
             512

С числами, хранящимися таким образом, мы можем использовать побитовые операции для одновременного увеличения любого их подмножества.

Мы создаем битовую карту с 1 битом в позициях, соответствующих счетчикам, которые мы хотим увеличить, и проходим по массиву, начиная с LSB, обновляя биты по мере продвижения. «Переносы» из одного сложения становятся входными данными для следующего элемента массива.

  input  sum

--------------------------------------------------------------------------------
   A B   C S
   0 0   0 0
   0 1   0 1      sum    = a ^ b
   1 0   0 1      carry  = a & b
   1 1   1 1

  carry = input;
  long *p = buffer;
  while (carry) {
    a = *p; b = carry;
    *p++ = a ^ b;
    carry = a & b;
  }

Для 64-битных слов цикл будет выполняться в среднем 6-7 раз — количество итераций определяется самой длинной цепочкой переносов.

person Igor Skochinsky    schedule 20.10.2011
comment
Это транспонирование 64-элементного x 64-битного блока или что-то в этом роде? Или транспонировать 1 x 64-битный элемент в 64 x 1-битных элемента? Но buffer[] содержит longs, а не биты. Что вы делаете с результатом? - person Peter Cordes; 30.04.2018
comment
@PeterCordes вам придется извлечь последний счетчик из вертикального битового столбца. должно быть выполнимо с помощью короткого цикла битовых сдвигов, маскирования и операций ИЛИ. - person Igor Skochinsky; 30.04.2018
comment
Прошло некоторое время с тех пор, как я в последний раз изучал эту проблему, но преимущество IIRC здесь в том, что шаг добавления выполняется намного быстрее, чем традиционный массив счетчиков, особенно если некоторые биты равны нулю. - person Igor Skochinsky; 30.04.2018
comment
О, я вижу, так что uint64_t buffer[64] — это структура данных для накопления отсчетов в каждой битовой позиции отдельно, выполняя полный вертикальный сумматор вручную. В конце вы прокручиваете его один раз, чтобы получить фактические значения для каждой битовой позиции. Для этого вы можете использовать SIMD, параллельно запуская 2x или 4x 64-битных аккумулятора (на Sandybridge, вероятно, лучше всего подойдет 128-битный AVX). Вы хотели бы очистить первые 4-6 или около того итераций и сделать их исключительно в регистрах без проверки досрочного завершения. Может быть, достаточно очистить, чтобы вы обычно делали без неправильного предсказания ветвления или цикла. - person Peter Cordes; 30.04.2018

Вы можете развернуть свою функцию следующим образом. Это, вероятно, быстрее, чем то, что может сделать ваш компилятор!

//   rax as 64 bit input
   xor  rcx, rcx                //clear addent

   add  rax, rax                //Copy 63th bit to carry flag
   adc  dword ptr [@bit_counter + 63 * 4], ecx    //Add carry bit to counter[64]

   add  rax, rax                //Copy 62th bit to carry flag
   adc  dword ptr [@bit_counter + 62 * 4], ecx    //Add carry bit to counter[63]

   add  rax, rax                //Copy 62th bit to carry flag
   adc  dword ptr [@bit_counter + 61 * 4], ecx    //Add carry bit to counter[62]
//   ...
   add  rax, rax                //Copy 1th bit to carry flag
   adc  dword ptr [@bit_counter + 1 * 4], ecx     //Add carry bit to counter[1]

   add  rax, rax                //Copy 0th bit to carry flag
   adc  dword ptr [@bit_counter], ecx             //Add carry bit to counter[0]

ИЗМЕНИТЬ:

Вы также можете попробовать с двойным приращением:

//   rax as 64 bit input
   xor  rcx, rcx                //clear addent
//
   add  rax, rax                //Copy 63th bit to carry flag
   rcl  rcx, 33                 //Mov carry to 32th bit as 0bit of second uint
   add  rax, rax                //Copy 62th bit to carry flag
   adc  qword ptr [@bit_counter + 62 * 8], rcx  //Add rcx to 63th and 62th counters

   add  rax, rax                //Copy 61th bit to carry flag
   rcl  rcx, 33                 //Mov carry to 32th bit as 0bit of second uint
   add  rax, rax                //Copy 60th bit to carry flag
   adc  qword ptr [@bit_counter + 60 * 8], rcx  //Add rcx to 61th and 60th counters
//...
person GJ.    schedule 17.10.2011
comment
Это, вероятно, не будет намного быстрее и не стоит усилий по обслуживанию... - person ; 18.10.2011
comment
@Colt 45: наверное? Вероятно, второе решение самое быстрое! Только harolds решение может быть быстрее. - person GJ.; 18.10.2011
comment
Можете ли вы подтвердить это таймингами? Код ОП, вероятно, скомпилирован во что-то почти такое же, как я. Может и быстрее, но не на порядки. - person ; 18.10.2011
comment
@Colt 45: моя вторая процедура должна быть быстрее примерно на 50%! - person GJ.; 18.10.2011
comment
Ваша первая версия выглядит хорошо, но на каком процессоре вы тестировали версию rcl? Согласно agner.org/optimize, rcl r,imm (т. е. число, отличное от 1) работает довольно медленно. Например, 8 мопов в семействе Intel Sandybridge (с пропускной способностью 1 на 6c) или 9 мооп на AMD K10 (1 на 3 цикла). Или 16 мкп на семействе Bulldozer / задержка 7 циклов. - person Peter Cordes; 29.04.2018

Вы можете использовать набор счетчиков, каждый разного размера. Сначала накапливаем 3 значения в 2-битных счетчиках, затем распаковываем их и обновляем 4-битные счетчики. Когда 15 значений будут готовы, распакуйте их в счетчики размером в байт, а после 255 значений обновите bit_counter[].

Вся эта работа может выполняться параллельно в 128-битных регистрах SSE. На современных процессорах требуется только одна инструкция, чтобы распаковать 1 бит в 2. Просто умножьте исходное четверное слово на себя с помощью инструкции PCLMULQDQ. Это будет чередовать исходные биты с нулями. Тот же прием может помочь распаковать 2 бита в 4. А распаковать 4 и 8 бит можно с помощью тасовки, распаковки и простых логических операций.

Средняя производительность вроде неплохая, но цена 120 байт за дополнительные счетчики и довольно много ассемблерного кода.

person Evgeny Kluev    schedule 23.10.2011

Нет никакого способа ответить на это в целом; все зависит от компилятора и базовой архитектуры. Единственный реальный способ узнать это — попробовать разные решения и измерить. (На некоторых машинах, например, смены могут быть очень дорогими. На других нет.) Для начала я бы использовал что-то вроде:

uint64_t mask = 1;
int index = 0;
while ( mask != 0 ) {
    if ( (bits & mask) != 0 ) {
        ++ bit_counter[index];
    }
    ++ index;
    mask <<= 1;
}

Полное развертывание цикла, скорее всего, улучшит производительность. В зависимости от архитектуры замена if на:

bit_counter[index] += ((bits & mask) != 0);

может быть лучше. Или того хуже... это невозможно знать заранее. Также возможно, что на некоторых машинах систематический переход к младшему разряду и маскировка, как вы делаете, были бы лучшими.

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

person James Kanze    schedule 17.10.2011
comment
Ветвление в вашем примере намного дороже, чем исходный код. - person Łukasz Lew; 17.10.2011
comment
@ ЛукашЛью Может быть. Поэтому я представил несколько разных решений с рекомендацией их измерить. Разным процессорам требуется разное время для разных операций. (Например, я использовал процессоры, в которых сдвиг нескольких битов был очень медленным.) - person James Kanze; 17.10.2011
comment
@ŁukaszLew все зависит от оптимизатора; компиляторы стали очень сложными в наши дни. Представленные реализации как минимум компактны, просты и удобны в сопровождении. Дополнительные усилия иногда уменьшают улов, если только вы точно не знаете, что делаете, и не отключаете все опции, программируя настолько низкоуровневый и специфичный для платформы, насколько это возможно. Такого рода ручные оптимизации обычно должны происходить очень поздно в производственном графике. - person Red.Wave; 30.04.2018

Если вы подсчитаете, как часто каждый откусывает (16 возможностей) при каждом смещении (16 возможностей), вы можете легко суммировать результаты. И эти 256 сумм легко сохраняются:

unsigned long nibble_count[16][16]; // E.g. 0x000700B0 corresponds to [4][7] and [2][B]
unsigned long bitcount[64];

void CountNibbles(uint64 bits) {
  // Count nibbles
  for (int i = 0; i != 16; ++i) {
     nibble_count[i][bits&0xf]++;
     bits >>= 4;
  }
}
void SumNibbles() {
  for (int i = 0; i != 16; ++i) {
    for (int nibble = 0; nibble != 16; ++nibble) {
        for(int bitpos = 0; bitpos != 3; ++bitpos) {
           if (nibble & (1<<bitpos)) {
              bitcount[i*4 + bitpos] += nibble_count[i][nibble];
           }
        }
     }
   }
}
person MSalters    schedule 17.10.2011
comment
почему бы не пройти весь путь и не хранить количество на байт? например byte_count[8][256]? Тогда ваша петля разворачивается довольно красиво. Должно иметь такое же влияние на кеш... - person Nim; 17.10.2011
comment
@Nim: Не совсем; byte_count содержит в 8 раз больше элементов. Поскольку доступ к нему осуществляется случайным образом, это очень плохо для кеша L1. Но профилирование должно быстро показать, что наиболее эффективно. И, конечно же, вы также можете профилировать quintet_count[13][32] и т. д. ;) - person MSalters; 18.10.2011
comment
Вам, вероятно, не нужны 64-битные счетчики, поэтому вы можете вдвое сократить объем кэш-памяти. Вы уже используете uint64_t, поэтому вы можете использовать uint32_t nibble_count вместо unsigned long, который является 32-разрядным в Windows x86-64 и 64-разрядным в других операционных системах x86-64. - person Peter Cordes; 29.04.2018

Это довольно быстро:

void count(uint_fast64_t bits){
    uint_fast64_t i64=ffs64(bits);
    while(i64){
        bit_counter[i64-1]++;
        bits=bits & 0xFFFFFFFFFFFFFFFF << i64;
        i64=ffs64(bits);
    }
}

Вам нужна быстрая реализация ffs для 64-битной версии. Для большинства компиляторов и процессоров это одна инструкция. Цикл выполняется один раз для каждого бита в слове, поэтому bits=0 будет очень быстрым, а 64-битные биты 1 будут медленнее.

Я протестировал это под 64-битной Ubuntu с GCC, и он выводит те же данные, что и ваши:

void Count(uint64 bits) {
  bit_counter[0] += (bits >> 0) & 1;
  bit_counter[1] += (bits >> 1) & 1;
  // ..
  bit_counter[63] += (bits >> 63) & 1;
}

Скорость зависит от количества 1 битов в 64-битном слове.

person the wolf    schedule 17.10.2011
comment
+1 за усилия, лучшая альтернатива здесь, но это будет не так быстро, как его оригинал... - person ; 18.10.2011