Skip to content

mibz2 · Research blog

32 lanes, 8.6 chains: where the inverse BWT walk spends its cycles

The decoder's 32-lane walk runs 8.6 times faster than one chain. That figure may not mean what it seems: the walk may already hide memory latency, and spend too many instructions on each byte.

This post asks how much parallel work the CPU actually does inside mibz2's inverse-BWT decoder, and whether hardware capacity is lost to the way Rust compiles the loop.

The interesting part is that the suspected problem may not be too little parallelism at all. The figures could instead mean that the CPU already hides memory latency well, but spends too many instructions on each decoded byte.

1. Why does the BWT decoder have 32 lanes?

The inverse BWT repeatedly looks up the next position in a table. Simplifying the actual code:

let q = t[row];
row = q >> 8;
output.push(q as u8);

The next lookup depends on the result of the previous one: ri+1 = Ψ(ri).

The CPU cannot know where to look next until the current lookup completes. This is called pointer chasing, and along one chain it is inherently serial.

mibz2's answer is to divide the inverse-BWT traversal into 512 segments and walk up to 32 independent segments at once. Each lane has its own row position, destination and segment state.

Independent traversal chains

in flight togetherLane 0lookup 1lookup 2lookup 3lookup 4lookup 5Lane 1lookup 1lookup 2lookup 3lookup 4lookup 5Lane 2lookup 1lookup 2lookup 3lookup 4lookup 5⋮⋮ 28 more lanes ⋮Lane 31lookup 1lookup 2lookup 3lookup 4lookup 5timeAlong a lane, each lookup waits for the previous one. Across lanes, nothing waits.
Four of the 32 lanes. Within a lane, each lookup waits for the one before it; across lanes, the CPU can overlap the lookups.

The source calls this walk room_segments::<32, PF_L1>. The PF_L1 part enables a prefetch hint for the next T-vector entry. The "room" algorithm batches output writes to avoid a buffer-capacity check on every byte, but it still performs one dependent lookup for every decoded byte.

2. Where does "8.6 effective chains" come from?

Two measured figures:

Measurement Cost
One dependent chain 38.55 cycles/byte
The 32-lane room walk 4.49 cycles/byte

Their ratio is 38.55 ÷ 4.49 = 8.59.

So the walk achieves about 8.6 times the byte rate of the serial chain, provided the two measurements are comparable. It does not mean that only 8.6 of the 32 lanes are working.

That distinction decides whether the next optimisation should look for more parallelism, or for less work per byte.

3. Why 32 lanes might yield only an 8.6× speed-up

Imagine a processor that takes 39 cycles to complete a dependent table lookup. If one lookup starts every 4.49 cycles, the average number in progress is about 39 ÷ 4.49 ≈ 8.7.

That is Little's law: concurrency equals request rate multiplied by latency. Computer architecture distinguishes this achieved memory-level parallelism from the number of independent chains a program makes available.

But consider what that means for mibz2. A full round of the walk, one step for each of the 32 lanes, takes 32 × 4.49 ≈ 144 cycles.

One round of the room walk, against one lookup's latency

One round: the walker steps each of its 32 lanes once, one step every 4.49 cycleslane 0lane 0 again32 × 4.49 ≈ 144 cyclesEach step starts a lookup that takes about 38.55 cycles to come backat any moment ≈ 38.55 ÷ 4.49 ≈ 8.6 lookups in progresslane 0’s lookup is back long before its next turn: all 32 lanes keep moving without 32 loads in flight
Illustrative, from the two measured figures above. About 8.6 lookups overlap at any moment, yet every lane's lookup returns long before that lane's next turn.

If a lookup takes roughly 39 cycles, the CPU has ample time to finish a lane's lookup before it comes back to that lane in the next round. So all 32 lanes can be making progress without 32 loads ever being in flight at once.

That would place the bottleneck somewhere else.

There is another caveat. The room walk explicitly prefetches future entries, so its actual demand-load latency may be much lower than the 38.55 cycles of the single chain. The two figures also need the same CPU, revision and cache conditions. So 8.6 is best read as an illustrative latency-hiding factor, not a measured count of outstanding memory requests.

4. What to look for in the machine code

Here is one iteration of the hot loop, simplified:

for lane in lanes.iter_mut() {
    let q = t[lane.row];                   // dependent load
    if q & START != 0 { /* handoff */ }    // branch
    lane.row = q >> 8;                     // next link
    prefetch(t, lane.row);                 // future memory request
    *base[lane_index].add(off) = q as u8;  // output store
}

Several explanations compete:

Suspected limit What it means
Register pressure Too few architectural registers to hold all 32 lanes' row and output state, so the generated loop keeps reloading and storing bookkeeping
Instruction-window pressure Too many instructions separate successive independent T-vector loads, which limits what the CPU can have in flight
Execution throughput Address generation, branches, prefetches and stores use enough execution resources to set a floor in cycles per byte
Memory-system saturation The core already overlaps enough lookups, and exposing more chains won't help

A Zen 3 core, for example, has a 256-entry reorder window and a finite number of load and miss-tracking resources: substantial, but not unlimited.

Reading the disassembly, not just the Rust, shows which dependencies LLVM actually creates. One promising possibility is that the CPU spends too much time maintaining lane.row, lane.seg, lane.p and lane.end relative to the useful BWT lookup.

The segment-start branch, by contrast, is rarely taken: about 512 boundaries per 900,000 bytes, or 0.057% of steps. Even a 20-cycle misprediction at every boundary costs only about 0.011 cycles per byte, so boundary mispredictions alone can't explain an opportunity of more than 1 cycle per byte. The throughput of the per-byte branch and bookkeeping could still matter.

5. What does "four groups of eight" mean?

This is scalar batching, not necessarily SIMD. The idea is to arrange the 32 logical lanes as four groups of eight, so that each group's working state is easier for the compiler to keep in registers.

But there's a trap.

Batching the 32 lanes as four groups of eight

Interleaved groupseach step touches every groupgroup 0group 1group 2group 332 chains exposedbookkeeping per group, overlap keptOne group at a timeseveral rounds of group 0, then group 1, …group 0group 1group 2group 38 chains exposedlatency floor ≥ 38.55 ÷ 8 = 4.82 c/B (today: 4.49)
Green lanes have a lookup the CPU can overlap. Batching helps only if every group still has lookups in flight within the processor's instruction window.

If an implementation runs several rounds of group 0 before touching groups 1 to 3, it may expose only eight independent chains to the CPU at once. Under the idealised 38.55-cycle latency, eight chains have a latency floor of 38.55 ÷ 8 = 4.82 cycles per byte, already slower than today's 4.49.

That doesn't prove batching fails: real latency changes with prefetching and caching. But it shows how saving register operations can lose to reduced overlap.

The better transformation reduces lane bookkeeping while still exposing independent loads from all four groups within the processor's instruction window.

6. What would count as a breakthrough?

The question is which of two fundamentally different paths applies.

Two diagnoses, two different optimisations

Path AInsufficient overlapGenerated code serialises loads thatthe algorithm keeps independent.Fix: remove those dependencies,without spilling lane state.Path B · current hypothesisToo much work per byteOverlap is already sufficient; theinstruction stream sets the pace.Fix: fewer reloads, address and outputsteps per byte, all 32 chains kept.
Path B is an inference from the lane-count curve flattening near 24 lanes and from the 32-lane walk already tolerating one chain's latency. It is not yet a verified machine-code finding.

Path A, insufficient overlap: show that the generated code serialises independent loads unnecessarily, then remove those dependencies without giving up register locality or adding costly memory traffic.

Path B, too much work per byte: show that overlap is already sufficient and that the core is limited by its instruction stream, then reduce row-state reloads, address calculations or output bookkeeping while keeping all 32 chains.

The current hypothesis leans towards Path B, given that the measured lane-count curve flattens near 24 lanes, and that the 32-lane scheme already tolerates one chain's latency. That is an inference, not a verified finding.

So the question to settle is:

Are the 32 chains insufficiently exposed, or are they already exposed well enough, while the processor wastes cycles advancing each one?

The two answers call for completely different optimisations, which is why a careful, disassembly-level investigation is worth doing.

A way to save roughly 1.2 cycles per walked byte, on the later decoder baseline of 10.78 cycles per byte, would be worth about 11% of total decoder time, before any gain from the MTF stage. That is why this direction has much greater breakthrough potential than further polishing the MTF selector.