Avatar ·

Замена 32-битного счетчика цикла на 64-битный вызывает странные просадки производительности с _mm_popcnt_u64 на Intel CPU

Всем привет! Пишу на C++ программу для подсчёта единичных битов в большом массиве данных. Использую встроенную интринсику _mm_popcnt_u64 из набора SSE4.2. Код простой: прохожу по массиву uint64_t и накапливаю результат. Всё работает отлично, но я заметил странную вещь: если я меняю тип счётчика цикла с 32-битного (unsigned int) на 64-битный (size_t), производительность падает в 2-3 раза! При этом данные те же, размер массива тот же, компилятор тот же (MSVC 2019, флаги /O2 /arch:AVX2). Вот упрощённый код: #include #include #include #include int main() { const size_t N = 1'000'000; std::vector data(N, 0xFFFFFFFFFFFFFFFF); // все биты = 1 // Версия 1: 32-битный счётчик unsigned int sum32 = 0; auto t1 = std::chrono::high_resolution_clock::now(); for (unsigned int i = 0; i < data.size(); ++i) { sum32 += _mm_popcnt_u64(data[i]); } auto t2 = std::chrono::high_resolution_clock::now(); std::cout << "32-bit: " << std::chrono::duration_cast(t2 - t1).count() << " ms\n"; // Версия 2: 64-битный счётчик (size_t) unsigned int sum64 = 0; t1 = std::chrono::high_resolution_clock::now(); for (size_t i = 0; i < data.size(); ++i) { sum64 += _mm_popcnt_u64(data[i]); } t2 = std::chrono::high_resolution_clock::now(); std::cout << "64-bit: " << std::chrono::duration_cast(t2 - t1).count() << " ms\n"; std::cout << sum32 << " " << sum64 << std::endl; return 0; } На моём Intel i7-8700 результаты: 32-bit — ~45 мс, 64-bit — ~120 мс. Разница просто огромная! Почему так происходит? Ведь size_t — это просто беззнаковый тип, и цикл должен выполняться одинаково. Это баг компилятора или есть какое-то фундаментальное объяснение? <<>>

Причина: зависимость данных по регистру EDX и ложная зависимость от старших битов

Проблема не в баге компилятора, а в тонкостях микроархитектуры Intel при работе с инструкцией POPCNT в паре с 32-битными и 64-битными регистрами. Ключевой момент — как процессор обрабатывает частичную запись в регистры.

Инструкция _mm_popcnt_u64 (или POPCNT r64, r/m64) записывает 64-битный результат в регистр-назначение. Если вы используете 32-битный счётчик цикла (unsigned int i), компилятор обычно выбирает регистр ECX или EAX для счётчика. При этом POPCNT может использовать другой регистр (например, RDX) для результата. Регистры независимы, и конвейер процессора может выполнять инкремент счётчика и POPCNT параллельно.

Когда вы переходите на 64-битный счётчик (size_t i), компилятору приходится использовать 64-битный регистр (например, RCX или RAX). Теперь представьте, что компилятор решает использовать RDX для результата POPCNT, а RCX — для счётчика. Казалось бы, всё равно независимо. Но есть нюанс: на микроархитектурах Intel (Sandy Bridge, Haswell, Skylake) инструкция POPCNT имеет ложную зависимость по старшим 32 битам регистра-назначения. Это известный эрратум.

Фактически, POPCNT r64, r/m64 ведёт себя так, как будто он читает старшие 32 бита регистра-назначения (даже если они не нужны для результата) и ждёт, пока они станут доступны. Если перед циклом регистр RDX был изменён инструкцией с 32-битным операндом (например, XOR EDX, EDX), то старшие 32 бита RDX обнуляются. Но если компилятор использует RDX как счётчик цикла и инкрементирует его 64-битной инструкцией (INC RDX), то старшие биты остаются "грязными" с точки зрения механизма переименования регистров. Это создаёт цепочку зависимостей, которая замедляет конвейер.

Вот пример ассемблерного кода, который генерирует компилятор для 64-битной версии (упрощённо):

; 64-битный счётчик в RCX
    XOR     ECX, ECX        ; обнуляем RCX (32-битная запись обнуляет старшие биты)
loop_start:
    POPCNT  RDX, [data + RCX*8]  ; RDX = popcnt(data[i])
    ADD     sum, RDX        ; накапливаем сумму
    INC     RCX             ; i++ (64-битный инкремент)
    CMP     RCX, N
    JB      loop_start

Проблема в том, что POPCNT RDX, ... имеет ложную зависимость от предыдущего значения RDX. В этом коде RDX перезаписывается каждой итерацией, но процессор не может выполнить следующую POPCNT, пока не завершится предыдущая, потому что он "думает", что RDX нужен как источник. Это создаёт узкое место.

Для 32-битной версии компилятор генерирует другой код:

; 32-битный счётчик в ECX
    XOR     ECX, ECX
loop_start:
    POPCNT  EAX, [data + ECX*8]  ; EAX = popcnt (32-битная запись!)
    ADD     sum, EAX
    INC     ECX             ; 32-битный инкремент
    CMP     ECX, N
    JB      loop_start

Здесь POPCNT записывает результат в 32-битный регистр EAX. Это автоматически обнуляет старшие 32 бита RAX, что разрывает ложную зависимость. Процессор может выполнять INC ECX и следующую POPCNT параллельно, конвейер не блокируется.

Решение: используйте 32-битный счётчик или явно обнуляйте регистр

Самый простой способ — всегда использовать unsigned int или int для счётчиков циклов, если размер массива не превышает 2^31 элементов. Для больших массивов можно применить трюк: обнулять регистр-назначение POPCNT перед каждой итерацией:

unsigned int sum = 0;
for (size_t i = 0; i < data.size(); ++i) {
    unsigned long long tmp;
    tmp = _mm_popcnt_u64(data[i]);
    sum += (unsigned int)tmp;
}

Но компилятор может оптимизировать это обратно. Лучше использовать volatile или inline-ассемблер. На современных компиляторах (GCC 9+, Clang 10+) эта проблема частично исправлена: они генерируют POPCNT с 32-битным назначением, когда это возможно. Для MSVC рекомендуется явно указывать _mm_popcnt_u32 или использовать unsigned int для счётчика.

<<>>

Альтернативный подход: ручное развёртывание цикла и использование SIMD

Проблема с 64-битным счётчиком — это частный случай более общей проблемы: микроархитектурные особенности Intel могут убивать производительность в неожиданных местах. Вместо того чтобы бороться с компилятором, можно полностью переписать алгоритм, чтобы минимизировать количество инструкций POPCNT.

Используйте _mm256_popcnt_epi64 из AVX2 (или _mm512_popcnt_epi64 из AVX-512, если доступно). Это позволит обрабатывать 4 (или 8) 64-битных слова за одну инструкцию. Кроме того, разверните цикл вручную, чтобы уменьшить накладные расходы на инкремент счётчика:

#include 

unsigned int fast_popcnt(const uint64_t* data, size_t size) {
    __m256i sum = _mm256_setzero_si256