Знаменитый баг, который до сих пор преследует C++ разработчиков
В 2013 году на Stack Overflow появился вопрос, который собрал сотни тысяч просмотров и породил настоящий культ среди оптимизаторов C++. Звучал он примерно так: «Я заменил int на uint64_t в счётчике цикла — и производительность упала в 10 раз. Почему?». Ответ крылся не в магии, а в микроархитектуре процессоров Intel. В 2025 году эта проблема никуда не делась: современные чипы всё ещё могут «спотыкаться» на, казалось бы, безобидной замене типа данных.
Сегодня мы разберём этот парадокс на примере инструкции _mm_popcnt_u64 (подсчёт единичных битов в 64-битном слове), покажем реальный код, который страдает от этой аномалии, и дадим чёткие рекомендации, как писать быстрый и предсказуемый C++ код в 2025 году.
Суть проблемы: 32-битный vs 64-битный счётчик
Представьте себе простой цикл, который суммирует количество единичных битов (popcount) в массиве 64-битных целых чисел. Используем интринсик _mm_popcnt_u64 из набора SSE4.2. Вот два варианта кода — первый с 32-битным счётчиком, второй с 64-битным:
#include <cstdint>
#include <vector>
#include <nmmintrin.h> // для _mm_popcnt_u64
// Вариант 1: 32-битный счётчик (быстрый)
uint64_t popcnt_32bit(const std::vector<uint64_t>& data) {
uint64_t result = 0;
for (int i = 0; i < data.size(); ++i) { // int — 32 бита
result += _mm_popcnt_u64(data[i]);
}
return result;
}
// Вариант 2: 64-битный счётчик (медленный)
uint64_t popcnt_64bit(const std::vector<uint64_t>& data) {
uint64_t result = 0;
for (uint64_t i = 0; i < data.size(); ++i) { // uint64_t — 64 бита
result += _mm_popcnt_u64(data[i]);
}
return result;
}
Если замерить скорость на процессоре Intel (например, Core i7-13700K или даже более старом Haswell), разница будет шокирующей: второй вариант может быть медленнее в 3–10 раз. При этом на AMD Ryzen такой аномалии нет — там оба варианта работают одинаково быстро.
Почему это происходит? Корень зла — ложная зависимость
Инструкция POPCNT на процессорах Intel (вплоть до самых современных) имеет ложную зависимость (false dependency) на регистр назначения. Формально POPCNT перезаписывает выходной регистр целиком, но микроархитектурно процессор ведёт себя так, будто он читает старый результат перед записью. Это баг (или особенность) реализации в Intel.
Теперь смотрим на цикл с 64-битным счётчиком i. Компилятор вынужден использовать один и тот же регистр (например, rax) для хранения и счётчика, и промежуточных результатов POPCNT. Каждая итерация создаёт цепочку зависимостей: следующая POPCNT не может начаться, пока предыдущая не завершится, потому что процессор «думает», что ей нужен старый результат. Это убивает конвейер.
С 32-битным счётчиком компилятор использует разные регистры (например, ecx для счётчика и rax для накопления результата), и ложная зависимость не возникает — POPCNT выполняются параллельно.
Детальный разбор на ассемблере
Чтобы убедиться, давайте посмотрим, во что компилируется каждый вариант (GCC 13, флаги -O3 -march=native):
32-битный счётчик (быстрый):
.L3:
popcnt rcx, QWORD PTR [rdx] ; popcnt в rcx (независимый регистр)
add rax, rcx ; складываем с накопителем
add rdx, 8 ; сдвиг указателя
cmp rdi, rdx ; сравнение
jne .L3
64-битный счётчик (медленный):
.L7:
popcnt rax, QWORD PTR [rdx] ; popcnt в rax — тот же, что и накопитель!
add rdx, 8
cmp rdi, rdx
jne .L7
Во втором случае компилятор «закоротил»: результат POPCNT пишется прямо в rax, который потом используется как аккумулятор. Каждая следующая POPCNT ждёт завершения предыдущей — отсюда и драматическое падение скорости.
Лучшие практики C++ в 2025: как избежать ловушки
Проблема жива и на современных Intel Alder Lake, Raptor Lake и даже Meteor Lake. Вот проверенные способы обойти её:
1. Используйте правильные типы для счётчиков
Не делайте все счётчики 64-битными «на всякий случай». Для количества элементов в контейнерах используйте size_t или int, если размер гарантированно меньше 2 миллиардов. size_t на 64-битных системах — это 64 бита, но компилятор умнее: он часто генерирует 32-битные регистры для индексов, если это безопасно.
2. Принудительно разрывайте зависимость
Если вы всё же используете 64-битный счётчик, можно явно «обмануть» процессор, вставив фиктивную инструкцию, которая разрывает зависимость:
uint64_t popcnt_fixed(const std::vector<uint64_t>& data) {
uint64_t result = 0;
for (uint64_t i = 0; i < data.size(); ++i) {
result += _mm_popcnt_u64(data[i]);
__asm__ volatile("" : "+r"(result)); // барьер для компилятора
}
return result;
}
На практике лучше использовать std::accumulate с явным типом аккумулятора, который не совпадает с типом счётчика.
3. Используйте библиотеки-обёртки
В 2025 году существуют отличные кроссплатформенные обёртки. Например, std::popcount из C++20 (или std::has_single_bit). Компиляторы обычно генерируют оптимальный код для них, зная об особенностях микроархитектуры:
#include <bit