Volker Schwaberow

Cycle Counting Habits That Still Apply in C and C++

3 min read
Cycle Counting Habits That Still Apply in C and C++

On the 6502 you sit there and count cycles. Absolute load costs more than zero-page. I still look at C and C++ the same way. Where does the value live, and how often do I touch it?

Zero-page on the 6502

Zero-page is just the first 256 bytes ($0000 to $00FF). Modes that address it are usually shorter and cheaper than the absolute ones. LDA loads the accumulator, STA stores it. LDA zp is 2 bytes and 3 cycles. LDA abs is 3 bytes and 4 cycles. Same story for STA.

LDA $1234    ; absolute, 4 cycles
STA $2000    ; absolute, 4 cycles

LDA $34      ; zero-page, 3 cycles
STA $20      ; zero-page, 3 cycles

If something sits in a hot loop, you put it in zero-page and leave it there. Reloading it from a far address every trip around the loop is waste. The 6502 barely has registers, so people treated zero-page like an overflow register file.

The same idea in C++

Today a register is usually the cheap place. Writing the value as a local is how I say that in source. Whether it actually stays in a register is up to the compiler. Spill it, and you are back to a memory load.

What I run into more often is chasing pointers through a loop, especially when one load waits on the last one or the data thrashes the cache.

for (const auto& entry : entries) {
  process(context.config->scale * entry.value);
}

Here context.config->scale does not depend on entry. If nothing in the loop changes it, pull it out.

const auto scale = context.config->scale;
for (const auto& entry : entries) {
  process(scale * entry.value);
}

Under -O2, GCC may already move the load out of the loop. Sometimes both versions compile to exactly the same thing.

The interesting case is when it cannot prove the invariant. Maybe process() writes through some alias that touches context.config->scale. Then the compiler has to keep the load inside the loop. Pull scale out yourself and the cost can drop, but only if that invariant is real. Guess wrong, and you changed what the program means.

How to check

I look at the assembly. Compiler Explorer or objdump -drC under -O2 or -O3 is enough to see how many loads sit in the hot loop. Demangled names help when C++ gets noisy. With perf stat I compare cycles, instructions, and the cache events that matter on that CPU. Branch misses rarely tell me much here, because the control flow did not change.

I only bother with tiny rewrites on code I have actually timed. Spot the invariant, spot the access cost, then check whether the compiler already cleaned it up.

The 6502 made you think about memory because you had no choice. C++ makes it easier to forget. I still find the old habit useful: know what gets loaded, know how often, and check what the compiler actually emitted.