Считайте каждую битовую позицию отдельно для множества 64-битных битовых масок, используя AVX, но не AVX2

(Связано: Как быстро посчитать биты в отдельные ячейки в серии целых чисел на Sandy Bridge? является более ранним дубликатом этого, с некоторыми другими ответами. Примечание редактора: ответы здесь, вероятно, лучше.

Кроме того, AVX2-версия аналогичной проблемы, с множеством бинов для целого ряда битов, намного шире, чем один uint64_t: Улучшите алгоритм подсчета количества столбцов)


Я работаю над проектом на C, где мне нужно пройти через десятки миллионов масок (типа ulong (64-бит)) и обновить массив (называемый target) из 64 коротких целых чисел (uint16) на основе простого правило:

// for any given mask, do the following loop
for (i = 0; i < 64; i++) {
    if (mask & (1ull << i)) {
        target[i]++
    }
}

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

В настоящее время я использую gcc 4.8.4 в Ubuntu 14.04 (i7-2670QM, поддерживающий AVX, а не AVX2) для компиляции и запуска следующего кода, и это заняло около 2 секунд. Хотелось бы, чтобы он работал под 200 мс.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#include <sys/stat.h>

double getTS() {
    struct timeval tv;
    gettimeofday(&tv, NULL);
    return tv.tv_sec + tv.tv_usec / 1000000.0;
}
unsigned int target[64];

int main(int argc, char *argv[]) {
    int i, j;
    unsigned long x = 123;
    unsigned long m = 1;
    char *p = malloc(8 * 10000000);
    if (!p) {
        printf("failed to allocate\n");
        exit(0);
    }
    memset(p, 0xff, 80000000);
    printf("p=%p\n", p);
    unsigned long *pLong = (unsigned long*)p;
    double start = getTS();
    for (j = 0; j < 10000000; j++) {
        m = 1;
        for (i = 0; i < 64; i++) {
            if ((pLong[j] & m) == m) {
                target[i]++;
            }
            m = (m << 1);
        }
    }
    printf("took %f secs\n", getTS() - start);
    return 0;
}

Заранее спасибо!

Ответ 1

В моей системе, 4-летнем MacBook (Intel Core i5 с частотой 2,7 ГГц) с clang-900.0.39.2 -O3, ваш код работает за 500 мс.

Просто изменив внутренний тест на if ((pLong[j] & m) != 0) сэкономите 30%, работая за 350 мс.

Дальнейшее упрощение внутренней части до target[i] += (pLong[j] >> i) & 1; без теста доводит его до 280мс.

Дальнейшие усовершенствования, по-видимому, требуют более продвинутых методов, таких как распаковка битов в блоки по 8 улонгов и добавление их параллельно, обработка 255 улонгов за раз.

Вот улучшенная версия с использованием этого метода. это работает в 45 мс в моей системе.

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#include <sys/stat.h>

double getTS() {
    struct timeval tv;
    gettimeofday(&tv, NULL);
    return tv.tv_sec + tv.tv_usec / 1000000.0;
}

int main(int argc, char *argv[]) {
    unsigned int target[64] = { 0 };
    unsigned long *pLong = malloc(sizeof(*pLong) * 10000000);
    int i, j;

    if (!pLong) {
        printf("failed to allocate\n");
        exit(1);
    }
    memset(pLong, 0xff, sizeof(*pLong) * 10000000);
    printf("p=%p\n", (void*)pLong);
    double start = getTS();
    uint64_t inflate[256];
    for (i = 0; i < 256; i++) {
        uint64_t x = i;
        x = (x | (x << 28));
        x = (x | (x << 14));
        inflate[i] = (x | (x <<  7)) & 0x0101010101010101ULL;
    }
    for (j = 0; j < 10000000 / 255 * 255; j += 255) {
        uint64_t b[8] = { 0 };
        for (int k = 0; k < 255; k++) {
            uint64_t u = pLong[j + k];
            for (int kk = 0; kk < 8; kk++, u >>= 8)
                b[kk] += inflate[u & 255];
        }
        for (i = 0; i < 64; i++)
            target[i] += (b[i / 8] >> ((i % 8) * 8)) & 255;
    }
    for (; j < 10000000; j++) {
        uint64_t m = 1;
        for (i = 0; i < 64; i++) {
            target[i] += (pLong[j] >> i) & 1;
            m <<= 1;
        }
    }
    printf("target = {");
    for (i = 0; i < 64; i++)
        printf(" %d", target[i]);
    printf(" }\n");
    printf("took %f secs\n", getTS() - start);
    return 0;
}

Методика увеличения байта до 64-битной длины исследуется и объясняется в ответе: fooobar.com/questions/1457227/.... Я сделал target массив локальной переменной, а также массивом inflate и распечатал результаты, чтобы компилятор не оптимизировал вычисления. В производственной версии вы будете вычислять массив inflate отдельно.

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

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

Ответ 2

связанные: у более раннего дубликата есть несколько альтернативных идей: Как быстро подсчитать биты в отдельные ячейки в серии целых чисел на Сэнди Бридж?. Также Гарольд ответит на алгоритм подсчета количества столбцов AVX2 для каждого битового столбца отдельно

Также: https://github.com/mklarqvist/positional-popcount имеет SSE blend, различные AVX2, различные AVX512, включая Harley-Seal, который отлично подходит для больших массивов, и различные другие алгоритмы для позиционного попкорна. Возможно, только для uint16_t, но большинство может быть адаптировано для других ширина слова. Я думаю, что алгоритм, который я предлагаю ниже, это то, что они называют adder_forest.


Лучше всего ставить SIMD, используя AVX1 на вашем процессоре Sandybridge. Компиляторы не настолько умны, чтобы автоматически векторизовать ваши зацикленные биты, даже если вы пишете их без ответвлений, чтобы дать им больше шансов.

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


Смотрите есть ли обратная инструкция к команде movemask в intel avx2? для краткого изложения методов растрового изображения → вектор для разных размеров. Предложение Ext3h в другом ответе хорошо: распаковка битов в нечто более узкое, чем массив окончательного числа, дает вам больше элементов на инструкцию. Байты эффективны с SIMD, и затем вы можете сделать до 255 вертикальных paddb без переполнения, прежде чем распаковать их для накопления в массив 32-битных счетчиков.

Требуется только 4x 16-байтовых вектора __m128i для хранения всех 64 элементов uint8_t, поэтому эти аккумуляторы могут оставаться в регистрах, добавляя их в память только при расширении до 32-разрядных счетчиков во внешнем цикле.

Распаковка не обязательно должна быть в порядке: вы всегда можете перемешать target[] один раз в самом конце, собрав все результаты.

Внутренний цикл можно развернуть, чтобы начать с 64- или 128-битной векторной загрузки, и распаковать 4 или 8 различными способами, используя pshufb (_mm_shuffle_epi8).


Еще лучшая стратегия - постепенно расширяться.

Начиная с 2-битных аккумуляторов, затем маскируйте/сдвигайте, чтобы расширить их до 4-битных. Таким образом, в самом внутреннем цикле большинство операций работают с "плотными" данными, а не "разводят" их слишком много сразу. Более высокая плотность информации/энтропии означает, что каждая инструкция выполняет больше полезной работы.

Использование методов SWAR для 32-кратного 2-битного добавления внутри скалярных или SIMD-регистров легко/дешево, поскольку в любом случае нам необходимо избегать возможности выполнения вершины элемента. При правильном SIMD мы потеряем эти показатели, а при использовании SWAR мы испортим следующий элемент.

uint64_t x = *(input++);        // load a new bitmask
const uint64_t even_1bits = 0x5555555555555555;  // 0b...01010101;

uint64_t lo = x & even_1bits;
uint64_t hi = (x>>1) & even_1bits;            // or use ANDN before shifting to avoid a MOV copy

accum2_lo += lo;   // can do up to 3 iterations of this without overflow
accum2_hi += hi;   // because a 2-bit integer overflows at 4

Затем вы повторяете до 4 векторов 4-битных элементов, затем 8 векторов 8-битных элементов, затем вам нужно расширить до 32 и накапливать в массиве в памяти, потому что вы все равно исчерпаете регистры и эта работа с внешним внешним циклом достаточно редка, поэтому нам не нужно беспокоиться о переходе на 16-битный режим. (Особенно если мы вручную векторизируем).

Самый большой недостаток: это не автоматическая векторизация, в отличие от версии @njuffa. Но с gcc -O3 -march=sandybridge для AVX1 (затем выполняется код на Skylake) этот 64-битный скаляр на самом деле все еще немного быстрее, чем 128-битный AVX автоматическая векторизация asm из кода @njuffa.

Но это время на Skylake, который имеет 4 скалярных порта ALU (и mov-elmination), в то время как Sandybridge не имеет mov-elmination и имеет только 3 порта ALU, так что скалярный код, вероятно, будет устранять узкие места внутреннего порта выполнения. (Но SIMD-код может быть почти таким же быстрым, потому что множество сдвигов AND/ADD смешано со сдвигами, и у SnB есть исполнительные блоки SIMD на всех 3 его портах, на которых есть какие-либо ALU. Haswell только что добавил порт 6 для скалярного -только включая смены и ветки.)

При хорошей ручной векторизации это должно быть почти в 2 или 4 раза быстрее.

Но если вам придется выбирать между этим скаляром или @njuffa с автовекторизацией AVX2, @njuffa быстрее на Skylake с -march=native

Если построение на 32-битной цели возможно/необходимо, это сильно страдает (без векторизации из-за использования uint64_t в 32-битных регистрах), в то время как векторизованный код почти не страдает (потому что вся работа происходит в векторных регистрах того же ширина).

// TODO: put the target[] re-ordering somewhere
// TODO: cleanup for N not a multiple of 3*4*21 = 252
// TODO: manual vectorize with __m128i, __m256i, and/or __m512i

void sum_gradual_widen (const uint64_t *restrict input, unsigned int *restrict target, size_t length)
{
    const uint64_t *endp = input + length - 3*4*21;     // 252 masks per outer iteration
    while(input <= endp) {
        uint64_t accum8[8] = {0};     // 8-bit accumulators
        for (int k=0 ; k<21 ; k++) {
            uint64_t accum4[4] = {0};  // 4-bit accumulators can hold counts up to 15.  We use 4*3=12
            for(int j=0 ; j<4 ; j++){
                uint64_t accum2_lo=0, accum2_hi=0;
                for(int i=0 ; i<3 ; i++) {  // the compiler should fully unroll this
                    uint64_t x = *input++;    // load a new bitmask
                    const uint64_t even_1bits = 0x5555555555555555;
                    uint64_t lo = x & even_1bits; // 0b...01010101;
                    uint64_t hi = (x>>1) & even_1bits;  // or use ANDN before shifting to avoid a MOV copy
                    accum2_lo += lo;
                    accum2_hi += hi;   // can do up to 3 iterations of this without overflow
                }

                const uint64_t even_2bits = 0x3333333333333333;
                accum4[0] +=  accum2_lo       & even_2bits;  // 0b...001100110011;   // same constant 4 times, because we shift *first*
                accum4[1] += (accum2_lo >> 2) & even_2bits;
                accum4[2] +=  accum2_hi       & even_2bits;
                accum4[3] += (accum2_hi >> 2) & even_2bits;
            }
            for (int i = 0 ; i<4 ; i++) {
                accum8[i*2 + 0] +=   accum4[i] & 0x0f0f0f0f0f0f0f0f;
                accum8[i*2 + 1] +=  (accum4[i] >> 4) & 0x0f0f0f0f0f0f0f0f;
            }
        }

        // char* can safely alias anything.
        unsigned char *narrow = (uint8_t*) accum8;
        for (int i=0 ; i<64 ; i++){
            target[i] += narrow[i];
        }
    }
    /* target[0] = bit 0
     * target[1] = bit 8
     * ...
     * target[8] = bit 1
     * target[9] = bit 9
     * ...
     */
    // TODO: 8x8 transpose
}

Мы не заботимся о порядке, поэтому, например, accum4[0] имеет 4-битные аккумуляторы для каждого 4-го бита. Последним исправлением, необходимым (но еще не реализованным) в самом конце, является транспонирование 8x8 массива uint32_t target[64],, которое может быть эффективно выполнено с использованием unpck и vshufps только с AVX1. (Транспонировать поплавок 8x8 с помощью AVX/AVX2). А также цикл очистки для последних до 251 маски.

Мы можем использовать любую ширину элемента SIMD для реализации этих сдвигов; мы все равно должны маскироваться для ширины меньше 16-битных (SSE/AVX не имеет сдвигов гранулярности, только 16-битный минимум).

Результаты тестов Arch Linux i7-6700k из тестового набора @njuffa. (Godbolt) N = (10000000 / (3*4*21) * 3*4*21) = 9999864 (т.е. 10000000 округляется до кратного 252 итерационного коэффициента "разворачивания", поэтому моя упрощенная реализация выполняет тот же объем работы, не считая переупорядочения target[], который это не делает, поэтому он печатает результаты несоответствия.  Но напечатанные значения соответствуют другой позиции массива ссылок.)

Я запускал программу 4 раза подряд (чтобы убедиться, что процессор разогрелся до максимальной скорости турбины) и сделал один из прогонов, который выглядел хорошо (ни один из трех раз не был слишком высоким).

ref: лучший битовый цикл (следующий раздел)
быстро: код @njuffa. (автоматически векторизуется с помощью 128-битных целочисленных инструкций AVX).
постепенный: моя версия (не векторизованная gcc или clang, по крайней мере, не во внутреннем цикле.) gcc и clang полностью развертывают внутренние 12 итераций.

  • gcc8.2 -O3 -march=sandybridge -fpie -no-pie
    ref: 0,331373 с, быстрое: 0,011387 с, постепенное: 0,009966 с
  • gcc8.2 -O3 -march=sandybridge -fno-pie -no-pie
    ссылка: 0,397175 с, быстрая: 0,011255 с, постепенная: 0,010018 с
  • clang7.0 -O3 -march=sandybridge -fpie -no-pie
    ref: 0,352381 с, быстрое: 0,011926 с, постепенное: 0,009269 с (очень низкое значение для порта 7 моп, clang использовал индексированную адресацию для магазинов)
  • clang7.0 -O3 -march=sandybridge -fno-pie -no-pie
    ссылка: 0,293014 с, быстрая: 0,011777 с, постепенная: 0,009235 с

-march = skylake (позволяя AVX2 для 256-битных целочисленных векторов) помогает обоим, но больше всего @njuffa, потому что большая часть его векторизуется (включая его самый внутренний цикл):

  • gcc8.2 -O3 -march=skylake -fpie -no-pie
    ссылка: 0,328725 с, быстрая: 0,007621 с, постепенная: 0,010054 с (gcc не показывает усиления для "постепенного", только для "быстрого")
  • gcc8.2 -O3 -march=skylake -fno-pie -no-pie
    ref: 0,333922 с, быстрое: 0,007620 с, постепенное: 0,009866 с

  • clang7.0 -O3 -march=skylake -fpie -no-pie
    ссылка: 0,260616 с, быстрая: 0,007521 с, постепенная: 0,008535 с (IDK, почему постепенный быстрее, чем -march = песчаный мост; он не использует BMI1 andn. Я думаю, потому что он использует 256-битный AVX2 для k = 0..20 внешний цикл с vpaddq)

  • clang7.0 -O3 -march=skylake -fno-pie -no-pie
    ссылка: 0,259159 с, быстрая: 0,007496 с, постепенная: 0,008671 с

Без AVX, только SSE4.2: (-march=nehalem), причудливый лягушка работает быстрее, чем с AVX/tune = sandybridge. "fast" лишь чуть медленнее, чем с AVX.

  • gcc8.2 -O3 -march=skylake -fno-pie -no-pie
    ссылка: 0,337178 с, быстрая: 0,011983 с, постепенная: 0,010587 с
  • clang7.0 -O3 -march=skylake -fno-pie -no-pie
    ссылка: 0,293555 с, быстрая: 0,012549 с, постепенная: 0,008697 с

-fprofile-generate/-fprofile-use помогают некоторым для GCC, особенно для версии "ref", где она вообще не разворачивается по умолчанию.

Я выделил лучшее, но часто они находятся в пределах допустимых помех друг для друга. Неудивительно, что -fno-pie -no-pie иногда был быстрее: индексирование статических массивов с помощью [disp32 + reg] не является режимом индексированной адресации, это просто base + disp32, поэтому он никогда не запускается на процессорах семейства Sandybridge.

Но с gcc иногда -fpie был быстрее; Я не проверял, но я предполагаю, что gcc просто как-то выстрелил себе в ногу, когда возможна абсолютная 32-битная адресация. Или просто невинно выглядящие различия в code-gen вызвали проблемы с выравниванием или uop-кешем; Я не проверял подробно.


Для SIMD мы можем просто сделать 2 или 4x uint64_t параллельно, накапливая только горизонтально на последнем шаге, где мы расширяем байты до 32-битных элементов. (Возможно, перетасовывая в строке и затем используя pmaddubsw с множитель _mm256_set1_epi8(1) для добавления горизонтальных пар байтов в 16-битные элементы.)

TODO: векторизованные вручную версии __m128i и __m256i__m512i). Должно быть примерно в 2, 4, или даже 8 раз быстрее, чем "постепенное" время, указанное выше. Вероятно, предварительная выборка HW все еще может идти в ногу с этим, за исключением, возможно, версии AVX512 с данными, поступающими из DRAM, особенно если есть конфликт со стороны других потоки. Мы выполняем значительную работу с каждым прочитанным словом.


Устаревший код: улучшения в битовом цикле

Ваша портативная скалярная версия также может быть улучшена, ускоряя ее с ~ 1,92 секунды (с общей ошибкой прогнозирования ветвей 34%, с закомментированными быстрыми циклами!) До ~ 0,35 с (clang7.0 -O3 -march=sandybridge) с правильным случайным входом на 3,9 ГГц Skylake. Или 1,83 секунды для версии с ветвями с != 0 вместо == m, поскольку компиляторы не могут доказать, что для m всегда установлен ровно 1 бит, и/или оптимизировать соответственно.

(против 0,01 секунды для @njuffa или моей быстрой версии выше, так что в абсолютном смысле это довольно бесполезно, но стоит упомянуть в качестве общего примера оптимизации использования кода без ответвлений.)

Если вы ожидаете случайного сочетания нулей и единиц, вам нужно что-то без разветвлений, которое не будет ошибочно предсказано. Выполнение += 0 для элементов, которые были равны нулю, избегает этого, а также означает, что абстрактная машина C определенно касается этой памяти независимо от данных.

Компиляторам не разрешается изобретать записи, поэтому, если они хотят автоматически векторизовать вашу версию if() target[i]++, им придется использовать хранилище в маске, например x86 vmaskmovps, чтобы избежать неатомарного чтения/перезаписи неизмененных элементов target. Поэтому некоторым гипотетическим будущим компиляторам, которые могут автоматически векторизовать простой скалярный код, было бы легче с этим.

В любом случае, один из способов написать это - target[i] += (pLong[j] & m != 0);, используя преобразование bool-> int для получения целого числа 0/1.

Но мы получим лучший ассемблер для x86 (и, вероятно, для большинства других архитектур), если просто сдвинем данные и изолируем младший бит с помощью &1. Компиляторы довольно глупы и, похоже, не замечают этой оптимизации. Они прекрасно оптимизируют счетчик дополнительных циклов и превращают m <<= 1 в add same,same для эффективного смещения влево, но они все еще используют xor-zero/test/setne для создания целого числа 0/1.

Внутренний цикл, подобный этому, компилируется немного эффективнее (но все еще намного хуже, чем мы можем сделать с SSE2 или AVX, или даже скалярный, используя таблицу поиска @chrqlie, которая будет оставаться горячей в L1d при повторном использовании, как это, позволяя SWAR в uint64_t ):

    for (int j = 0; j < 10000000; j++) {
#if 1  // extract low bit directly
        unsigned long long tmp = pLong[j];
        for (int i=0 ; i<64 ; i++) {   // while(tmp) could mispredict, but good for sparse data
            target[i] += tmp&1;
            tmp >>= 1;
        }
#else // bool -> int shifting a mask
        unsigned long m = 1;
        for (i = 0; i < 64; i++) {
            target[i]+= (pLong[j] & m) != 0;
            m = (m << 1);
        }
#endif

Обратите внимание, что unsigned long не гарантированно является 64-битным типом и не поддерживается в x86-64 System V x32 (ILP32 в 64-битном режиме) и Windows x64. Или в 32-битных ABI, таких как i386 System V.

Скомпилированный в проводнике компилятора Godbolt с помощью gcc, clang и ICC, он на 1 шаг меньше в цикле с gcc. Но все они просто скалярные, с раскруткой лязга и ICC на 2.

# clang7.0 -O3 -march=sandybridge
.LBB1_2:                            # =>This Loop Header: Depth=1
   # outer loop loads a uint64 from the src
    mov     rdx, qword ptr [r14 + 8*rbx]
    mov     rsi, -256
.LBB1_3:                            #   Parent Loop BB1_2 Depth=1
                                    # do {
    mov     edi, edx
    and     edi, 1                              # isolate the low bit
    add     dword ptr [rsi + target+256], edi   # and += into target

    mov     edi, edx
    shr     edi
    and     edi, 1                              # isolate the 2nd bit
    add     dword ptr [rsi + target+260], edi

    shr     rdx, 2                              # tmp >>= 2;

    add     rsi, 8
    jne     .LBB1_3                       # } while(offset += 8 != 0);

Это немного лучше, чем мы получаем из test/setnz. Без развертывания bt/setc могли бы быть равны, но компиляторы плохо используют bt для реализации bool (x & (1ULL << n)) или bts для реализации x |= 1ULL << n.

Если у многих слов самый высокий установленный бит намного ниже бита 63, зацикливание на while(tmp) может быть выигрышем. Неправильные прогнозы ветвей делают его не стоящим, если он экономит от ~ 0 до 4 итераций большую часть времени, но если он часто экономит 32 итерации, это может стоить того. Возможно, разверните исходный код, чтобы цикл проверял только tmp каждые 2 итерации (потому что компиляторы не будут выполнять это преобразование за вас), но тогда ветвь цикла может быть shr rdx, 2/jnz.

В семействе Sandybridge это 11 мопов слитых доменов для внешнего интерфейса на 2 бита ввода. (add [mem], reg с неиндексированным режимом адресации микросопрягает нагрузку + ALU и адрес хранилища + данные хранилища, все остальное - одиночные uop. Add/jcc макроплавки. См. руководство Agner Fog, и fooobar.com/tags/x86/...). Таким образом, он должен работать примерно с 3 циклами на 2 бита = один uint64_t на 96 циклов. (Sandybridge не "разворачивается" внутри своего буфера цикла, поэтому число мопов, не кратное 4, в основном округляется, в отличие от Haswell и более поздних версий).

против не развернутой версии gcc - 7 мопов на 1 бит = 2 цикла на бит. Если вы скомпилировали с помощью gcc -O3 -march=native -fprofile-generate/test-run/gcc -O3 -march=native -fprofile-use, оптимизация на основе профиля включит развертывание цикла.

Это, вероятно, медленнее, чем ветвящаяся версия с совершенно предсказуемыми данными, как вы получаете из memset с любым повторяющимся байтовым шаблоном. Я бы предложил заполнить ваш массив случайно сгенерированными данными из быстрого PRNG, например SSE2 xorshift+, или, если вы просто синхронизируете цикл подсчета, используйте все, что захотите, например rand().

Ответ 3

Один из способов значительно ускорить это, даже без AVX, состоит в том, чтобы разбить данные на блоки до 255 элементов и накапливать счетчики битов в байтах в обычных переменных uint64_t. Поскольку исходные данные имеют 64 бита, нам нужен массив из 8 байтовых аккумуляторов. Первый аккумулятор считает биты в позициях 0, 8, 16,... 56, второй аккумулятор считает биты в позициях 1, 9, 17,... 57; и так далее. После того как мы закончили обработку блока данных, мы переносим счетчики из побайтового аккумулятора в target счетчики. Функция обновления target значений для блока до 255 номеров может быть закодирована простым способом в соответствии с описанием выше, где BITS - это количество битов в исходных данных:

/* update the counts of 1-bits in each bit position for up to 255 numbers */
void sum_block (const uint64_t *pLong, unsigned int *target, int lo, int hi)
{
    int jj, k, kk;
    uint64_t byte_wise_sum [BITS/8] = {0};
    for (jj = lo; jj < hi; jj++) {
        uint64_t t = pLong[jj];
        for (k = 0; k < BITS/8; k++) {
            byte_wise_sum[k] += t & 0x0101010101010101;
            t >>= 1;
        }
    }
    /* accumulate byte sums into target */
    for (k = 0; k < BITS/8; k++) {
        for (kk = 0; kk < BITS; kk += 8) {
            target[kk + k] += (byte_wise_sum[k] >> kk) & 0xff;
        }
    }
}

Вся программа ISO-C99, которая должна работать как минимум на платформах Windows и Linux, показана ниже. Он инициализирует исходные данные с помощью PRNG, выполняет проверку на соответствие эталонной реализации asker и сравнивает эталонный код и ускоренную версию. На моей машине (Intel Xeon E3-1270 v2 @3,50 ГГц), при компиляции с MSVS 2010 при полной оптимизации (/Ox), вывод программы:

p=0000000000550040
ref took 2.020282 secs, fast took 0.027099 secs

где ref относится к исходному решению asker. Ускорение здесь примерно в 74 раза. Различные ускорения будут наблюдаться с другими (и особенно более новыми) компиляторами.

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>

#if defined(_WIN32)
#if !defined(WIN32_LEAN_AND_MEAN)
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
double second (void)
{
    LARGE_INTEGER t;
    static double oofreq;
    static int checkedForHighResTimer;
    static BOOL hasHighResTimer;

    if (!checkedForHighResTimer) {
        hasHighResTimer = QueryPerformanceFrequency (&t);
        oofreq = 1.0 / (double)t.QuadPart;
        checkedForHighResTimer = 1;
    }
    if (hasHighResTimer) {
        QueryPerformanceCounter (&t);
        return (double)t.QuadPart * oofreq;
    } else {
        return (double)GetTickCount() * 1.0e-3;
    }
}
#elif defined(__linux__) || defined(__APPLE__)
#include <stddef.h>
#include <sys/time.h>
double second (void)
{
    struct timeval tv;
    gettimeofday(&tv, NULL);
    return (double)tv.tv_sec + (double)tv.tv_usec * 1.0e-6;
}
#else
#error unsupported platform
#endif

/*
  From: geo <[email protected]>
  Newsgroups: sci.math,comp.lang.c,comp.lang.fortran
  Subject: 64-bit KISS RNGs
  Date: Sat, 28 Feb 2009 04:30:48 -0800 (PST)

  This 64-bit KISS RNG has three components, each nearly
  good enough to serve alone.    The components are:
  Multiply-With-Carry (MWC), period (2^121+2^63-1)
  Xorshift (XSH), period 2^64-1
  Congruential (CNG), period 2^64
*/
static uint64_t kiss64_x = 1234567890987654321ULL;
static uint64_t kiss64_c = 123456123456123456ULL;
static uint64_t kiss64_y = 362436362436362436ULL;
static uint64_t kiss64_z = 1066149217761810ULL;
static uint64_t kiss64_t;
#define MWC64  (kiss64_t = (kiss64_x << 58) + kiss64_c, \
                kiss64_c = (kiss64_x >> 6), kiss64_x += kiss64_t, \
                kiss64_c += (kiss64_x < kiss64_t), kiss64_x)
#define XSH64  (kiss64_y ^= (kiss64_y << 13), kiss64_y ^= (kiss64_y >> 17), \
                kiss64_y ^= (kiss64_y << 43))
#define CNG64  (kiss64_z = 6906969069ULL * kiss64_z + 1234567ULL)
#define KISS64 (MWC64 + XSH64 + CNG64)

#define N          (10000000)
#define BITS       (64)
#define BLOCK_SIZE (255)

/* cupdate the count of 1-bits in each bit position for up to 255 numbers */
void sum_block (const uint64_t *pLong, unsigned int *target, int lo, int hi)
{
    int jj, k, kk;
    uint64_t byte_wise_sum [BITS/8] = {0};
    for (jj = lo; jj < hi; jj++) {
        uint64_t t = pLong[jj];
        for (k = 0; k < BITS/8; k++) {
            byte_wise_sum[k] += t & 0x0101010101010101;
            t >>= 1;
        }
    }
    /* accumulate byte sums into target */
    for (k = 0; k < BITS/8; k++) {
        for (kk = 0; kk < BITS; kk += 8) {
            target[kk + k] += (byte_wise_sum[k] >> kk) & 0xff;
        }
    }
}

int main (void) 
{
    double start_ref, stop_ref, start, stop;
    uint64_t *pLong;
    unsigned int target_ref [BITS] = {0};
    unsigned int target [BITS] = {0};
    int i, j;

    pLong = malloc (sizeof(pLong[0]) * N);
    if (!pLong) {
        printf("failed to allocate\n");
        return EXIT_FAILURE;
    }
    printf("p=%p\n", pLong);

    /* init data */
    for (j = 0; j < N; j++) {
        pLong[j] = KISS64;
    }

    /* count bits slowly */
    start_ref = second();
    for (j = 0; j < N; j++) {
        uint64_t m = 1;
        for (i = 0; i < BITS; i++) {
            if ((pLong[j] & m) == m) {
                target_ref[i]++;
            }
            m = (m << 1);
        }
    }
    stop_ref = second();

    /* count bits fast */
    start = second();
    for (j = 0; j < N / BLOCK_SIZE; j++) {
        sum_block (pLong, target, j * BLOCK_SIZE, (j+1) * BLOCK_SIZE);
    }
    sum_block (pLong, target, j * BLOCK_SIZE, N);
    stop = second();

    /* check whether result is correct */
    for (i = 0; i < BITS; i++) {
        if (target[i] != target_ref[i]) {
            printf ("error @ %d: res=%u ref=%u\n", i, target[i], target_ref[i]);
        }
    }

    /* print benchmark results */
    printf("ref took %f secs, fast took %f secs\n", stop_ref - start_ref, stop - start);
    return EXIT_SUCCESS;
}

Ответ 4

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

Поэтому просто следуйте следующей стратегии распаковки битов в байты вектора: fooobar.com/questions/970851/...

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

После каждого блока 255, распакуйте снова в 32bit и добавьте в массив. (Вам не нужно делать ровно 255, просто какое-то удобное число меньше 256, чтобы избежать переполнения байтовых аккумуляторов).

При 8 инструкциях на битовую маску (по 4 на каждую младшую и более высокую 32-битную версию с AVX2) - или вдвое меньше, если у вас есть AVX512 - вы сможете достичь пропускной способности около полумиллиарда битовых масок в секунду и ядра на недавнем ЦП,


typedef uint64_t T;
const size_t bytes = 8;
const size_t bits = bytes * 8;
const size_t block_size = 128;

static inline __m256i expand_bits_to_bytes(uint32_t x)
{
    __m256i xbcast = _mm256_set1_epi32(x);    // we only use the low 32bits of each lane, but this is fine with AVX2

    // Each byte gets the source byte containing the corresponding bit
    const __m256i shufmask = _mm256_set_epi64x(
        0x0303030303030303, 0x0202020202020202,
        0x0101010101010101, 0x0000000000000000);
    __m256i shuf = _mm256_shuffle_epi8(xbcast, shufmask);

    const __m256i andmask = _mm256_set1_epi64x(0x8040201008040201);  // every 8 bits -> 8 bytes, pattern repeats.
    __m256i isolated_inverted = _mm256_andnot_si256(shuf, andmask);

    // this is the extra step: byte == 0 ? 0 : -1
    return _mm256_cmpeq_epi8(isolated_inverted, _mm256_setzero_si256());
}

void bitcount_vectorized(const T *data, uint32_t accumulator[bits], const size_t count)
{
    for (size_t outer = 0; outer < count - (count % block_size); outer += block_size)
    {
        __m256i temp_accumulator[bits / 32] = { _mm256_setzero_si256() };
        for (size_t inner = 0; inner < block_size; ++inner) {
            for (size_t j = 0; j < bits / 32; j++)
            {
                const auto unpacked = expand_bits_to_bytes(static_cast<uint32_t>(data[outer + inner] >> (j * 32)));
                temp_accumulator[j] = _mm256_sub_epi8(temp_accumulator[j], unpacked);
            }
        }
        for (size_t j = 0; j < bits; j++)
        {
            accumulator[j] += ((uint8_t*)(&temp_accumulator))[j];
        }
    }
    for (size_t outer = count - (count % block_size); outer < count; outer++)
    {
        for (size_t j = 0; j < bits; j++)
        {
            if (data[outer] & (T(1) << j))
            {
                accumulator[j]++;
            }
        }
    }
}

void bitcount_naive(const T *data, uint32_t accumulator[bits], const size_t count)
{
    for (size_t outer = 0; outer < count; outer++)
    {
        for (size_t j = 0; j < bits; j++)
        {
            if (data[outer] & (T(1) << j))
            {
                accumulator[j]++;
            }
        }
    }
}

В зависимости от выбранного компилятора, векторизованная форма достигла ускорения примерно в 25 раз по сравнению с наивным.

На Ryzen 5 1600X векторизованная форма примерно достигла прогнозируемой пропускной способности ~ 600 000 000 элементов в секунду.

Удивительно, но на самом деле это все еще на 50% медленнее, чем решение, предложенное @njuffa.

Ответ 5

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

См. Ускоренный подсчет населения с использованием инструкций AVX2 Даниэля Лемира, Натана Курца и Войцеха Мула (23 ноября 2016 г.).

По сути, каждый полный сумматор сжимает 3 входа в 2 выхода. Таким образом, мы эффективно исключаем целое 256-битное слово по цене 5 логических инструкций. Мы повторяем это, пока у нас не кончатся регистры, затем мы вынуждены сделать шаг накопления (см. все остальные ответы).

Позиционное popcnt для 16-битных подслов реализовано здесь: https://github.com/mklarqvist/positional-popcount

// Carry-Save Full Adder (3:2 compressor)
b ^= a;
a ^= c;
c ^= b; // xor sum
b |= a;
b ^= c; // carry

Примечание. Шаг накопления для position-popcnt дороже, чем для обычного simd popcnt. Как я полагаю, в конец CSU можно добавить пару полумесяцев, может потребоваться пройти до 256 слов перед накоплением.