fix

The Speedup Was the Data Layout

Most graphics code is full of vec3 and vec4 types, and most of it doesn't scale past SSE. The registers are four wide, so the code is four wide, and the 8- and 16-wide units sit idle. Stream HPC's post on making SIMD scale is the best explanation I've read of why the fix is a data-layout change rather than a better instruction.

The trick is to transpose: instead of one vector per object, store one lane per object. Four vec4s become a struct of four vec4 fields — x, y, z, w — where each field holds the same component of four different objects. Then a dot product is still four multiplies and three adds, but it produces four dot products at once instead of one.

Compilers won't do this for you. They'll vectorize a loop over an array of floats, but turning an array of vec4s into four arrays of floats changes what every other piece of code sees. The layout is a program-wide decision, so it stays with the programmer.

The culler, and the trap

The worked example is a frustum culler: for each box, test it against six planes, and cull it if it's entirely in front of any one.

The naive version tests all eight corners, 13.4 ns per box. A smarter version tests only the corner nearest the plane — dot(n, c) - dot(abs(n), e) + d — which is 3.0 ns. Four times faster, and correct.

Then it gets interesting. At 100,000 boxes the same loop takes 12.7 ns per box. That's 3 MB of data read sequentially, so memory isn't the issue. The author times a 1,000-box scene and cycles through a different number of scenes between runs:

The last two touch the same memory. The difference is that identical scenes produce the same early-out order every time. Zen 3 can memorize the branch history of roughly 16,000 boxes; beyond that, every early out is a misprediction. A benchmark that loops over the same small scene is mostly measuring the branch predictor, and it made the nearest-corner optimization look 4.4× faster than it is on fresh data.

That's a trap worth internalizing: the benchmark harness became the subject of the measurement.

The lane version, and its two hidden costs

With boxes stored one per lane, the early outs become masks: six comparisons, ANDed together, and the box is visible if all six pass. AVX2 gets 1.24 ns per box — 10× faster than the nearest-corner test on fresh data. SSE gets 2.87 ns, which is only 1.3× faster than the trained-predictor number, and that's suspicious for four lanes.

Looking at the assembly explains it. The math for eight boxes against six planes is 53 instructions: six loads, six multiplies, thirty fused multiply-adds, six compares and five ANDs. The loop is about 220.

The first problem is that the frustum is rebuilt on every iteration. Six planes need 42 broadcasts, and AVX2 only has 16 vector registers, so the allocator decides recomputation is cheaper than spilling — and recomputes the abs, the permutes and the broadcasts. None of it depends on the box.

The second problem is the writeback. Each block produces eight bits, but the code extracts them one lane at a time: about thirteen instructions per lane, roughly a hundred per block — more than the math.

The fixes

Splat the planes once, before the loop, and keep them in memory so the fused multiply-adds can use them as memory operands. The Box3Lanes type gets an overload that takes the pre-splatted planes. AVX2 drops to 0.98 ns, SSE to 1.85.

Then write the block at once: convert the eight lanes to booleans, bit-cast to a byte, store it. AVX2 uses a single vmovmskps and one byte store; SSE handles two four-lane blocks sharing a byte. Handling the partial final block after the loop matters — a branch inside the loop breaks the compiler's knowledge that each lane is all ones or all zeros, and it replaces the single mask instruction with a chain of shuffles.

Final numbers: 0.67 ns per box on AVX2, 1.84 on SSE. About 20× faster than the naive version, 25× on data the branch predictor hasn't seen.

What I take from it

The layout is the optimization. Everything else is a constant factor on top. The transposition is the thing that unlocks the width, and it's a design decision you have to make before the hot loop exists.

Look for overhead that isn't the math. A 220-instruction loop around 53 instructions of arithmetic is normal, and the fix is almost always precomputation plus a vectorized writeback. This is Amdahl's law at the instruction level.

Your benchmark may be measuring the predictor. The scene-count experiment is the cleanest demonstration I've seen, and the "200 identical copies" control is what makes it convincing.

One more, from the footnote: the 0.67 ns depends on the stack being 32-byte aligned. When it isn't, half the reloads straddle a cache line and the same loop takes 0.99 ns. A force_align_arg_pointer attribute fixes it. That's a 48% swing from an ABI detail, which is a good reminder of how much of a microbenchmark is the environment.

Source: Making SIMD Code Scale to Any Vector Width — Stream HPC.

#performance#simd#cpp#benchmarking