Why a 64-bit Counter in a Loop Breaks _mm_popcnt_u64 Performance

Online Python Trainer for Beginners

Learn Python easily without overwhelming theory. Solve practical tasks with automatic checking, get hints in Russian, and write code directly in your browser — no installation required.

Start Course

The famous bug that still haunts C++ developers

\n\n

In 2013, a question appeared on Stack Overflow that garnered hundreds of thousands of views and spawned a real cult among C++ optimizers. It went something like this: "I replaced int with uint64_t in a loop counter — and performance dropped by 10 times. Why?". The answer lay not in magic, but in the microarchitecture of Intel processors. In 2025, this problem hasn't gone away: modern chips can still "stumble" on what seems like a harmless data type change.

\n\n

Today we'll break down this paradox using the _mm_popcnt_u64 instruction (counting set bits in a 64-bit word) as an example, show real code that suffers from this anomaly, and give clear recommendations on how to write fast and predictable C++ code in 2025.

\n\n

The core of the problem: 32-bit vs 64-bit counter

\n\n

Imagine a simple loop that sums the number of set bits (popcount) in an array of 64-bit integers. We use the _mm_popcnt_u64 intrinsic from the SSE4.2 set. Here are two code variants — the first with a 32-bit counter, the second with a 64-bit one:

\n\n
#include <cstdint>\n#include <vector>\n#include <nmmintrin.h> // for _mm_popcnt_u64\n\n// Variant 1: 32-bit counter (fast)\nuint64_t popcnt_32bit(const std::vector<uint64_t>& data) {\n    uint64_t result = 0;\n    for (int i = 0; i < data.size(); ++i) {   // int — 32 bits\n        result += _mm_popcnt_u64(data[i]);\n    }\n    return result;\n}\n\n// Variant 2: 64-bit counter (slow)\nuint64_t popcnt_64bit(const std::vector<uint64_t>& data) {\n    uint64_t result = 0;\n    for (uint64_t i = 0; i < data.size(); ++i) { // uint64_t — 64 bits\n        result += _mm_popcnt_u64(data[i]);\n    }\n    return result;\n}\n
\n\n

If you measure the speed on an Intel processor (e.g., Core i7-13700K or even an older Haswell), the difference will be shocking: the second variant can be 3–10 times slower. At the same time, on AMD Ryzen there is no such anomaly — both variants run equally fast.

\n\n

Why does this happen? The root of evil — false dependency

\n\n

The POPCNT instruction on Intel processors (up to the most modern ones) has a false dependency on the destination register. Formally, POPCNT completely overwrites the output register, but microarchitecturally the processor behaves as if it reads the old result before writing. This is a bug (or feature) in Intel's implementation.

\n\n

Now look at the loop with the 64-bit counter i. The compiler is forced to use the same register (e.g., rax) for storing both the counter and the intermediate results of POPCNT. Each iteration creates a dependency chain: the next POPCNT cannot start until the previous one finishes, because the processor "thinks" it needs the old result. This kills the pipeline.

\n\n

With a 32-bit counter, the compiler uses different registers (e.g., ecx for the counter and rax for accumulating the result), and the false dependency does not occur — POPCNT instructions execute in parallel.

\n\n

Detailed analysis in assembly

\n\n

To be sure, let's look at what each variant compiles to (GCC 13, flags -O3 -march=native):

\n\n

32-bit counter (fast):

\n
.L3:\n    popcnt rcx, QWORD PTR [rdx]   ; popcnt into rcx (independent register)\n    add    rax, rcx                ; add to accumulator\n    add    rdx, 8                  ; advance pointer\n    cmp    rdi, rdx                ; compare\n    jne    .L3\n
\n\n

64-bit counter (slow):

\n
.L7:\n    popcnt rax, QWORD PTR [rdx]   ; popcnt into rax — the same as the                

Blogs

Book Recommendations