Замена 32-битного счетчика цикла на 64-битный вызывает странные просадки производительности с _mm_popcnt_u64 на Intel CPU
Причина: зависимость данных по регистру 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