Optimisations: Memory Tutorial 10 of 10
The running example: a wall of bits
The Physics page made each shot cheaper. This page makes the shot's data structure cheaper. Everything below lives in one Rust file, src/tableau.rs, and one object, ColTableau.
The Aaronson-Gottesman tableau for Vec<u64>. State-vector simulation cannot reach the qubit counts a surface-code experiment needs; a tableau can, but only if the bit-shuffling stays off the critical path. At
Two layouts for one tableau
Take a single-qubit gate on qubit
Store the tableau row-major, row after row with each row's w = ceil(n/64) words apart. A gate is then
Store the tableau column-major, with qubit rw = ceil(rows/64) words), and the same gate becomes a single sweep over rw whole words per plane. That is ColTableau calls these planes xc, zc, and a signc sign plane.
But measurement wants the other layout. do_measure and rowsum walk a whole Pauli row at once, and the bit-parallel core word_g sums the phase contribution over 64 qubit-lanes packed into one row word. That is naturally row-major. So ColTableau keeps both: a proven row-major engine (inner, a RowTableau) that owns measurement, the RNG, and the record, plus the column-major planes that own gates. A one-bit layout flag says which side is authoritative. The tableau transposes lazily: only when the operation kind switches at the gate-to-measure boundary, not per gate.
Which words a gate touches
Legend:
- blue cells = qubit
's bits (what the gate touches) - faint bands = destabilizer, stabilizer, and scratch rows
column-major/row-major= the two memory orders
- column-major planes (xc / zc / signc)
- row-major engine (inner)
| operation | authoritative layout | cost per call |
|---|---|---|
| 1- and 2-qubit gates | column-major | ceil(rows/64) word ops per plane |
| measure / reset / expectation | row-major | proven CHP path, word_g phase sums |
| gate -> measure switch | transpose | one blocked 64x64 transpose |
Measured, the column layout does what it promises: gate cost per plane-word stays flat as the plane grows past cache.
Gate throughput across sizes
Legend:
- purple
ns per plane-word= time per word of a plane (the cache-cliff test) - red
ns per gate= whole-gate time,word ops - dashed line = the ~14 ns/word bandwidth floor
The transpose, made cheap
The dual layout only pays if the transpose between the two is nearly free. It is, for three reasons.
First, the transpose itself. transpose64 flips a 64x64 bit matrix held as 64 u64 rows in place, using the Hacker's Delight divide-and-conquer: six mask-and-shift passes, halving the block size each time, each output word written once. That is to_col and to_row tile the whole tableau into 64x64 blocks and transpose each once. The earlier bit-by-bit transpose took more than 8 seconds at ColTableau stores LSB-column (bit
Second, initialization skips the transpose entirely. ColTableau::new builds Layout::Col, so a pure-gate circuit is already in place and never pays a row-to-column transpose to get going.
Third, the gate kernels are written to vectorise. Each kernel (k_h, k_s, k_sdag, k_x, k_y, k_z, k_cx, k_cz) takes plain &mut [u64] slices and iterates them with zip, so LLVM proves the bounds and drops the checks, then auto-vectorises the loop and emits NEON. The two-qubit kernels get their two disjoint planes from a split_at_mut helper (planes_mut) so they stay bounds-check-free like the one-qubit ones. This is the SIMD path the source notes pulls large-
- block size j, halving each pass
- each pass swaps j x j block pairs across the diagonal
| pass | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| block size j | 32 | 16 | 8 | 4 | 2 | 1 |
- whole-plane word sweep over ceil(rows/64) words
- writes the sign plane
| kernel | gate | per-word op |
|---|---|---|
| k_h | H | sign ^= x & zswap x, z |
| k_s / k_sdag | S / S† | sign ^= x & z (or x & !z)z ^= x |
| k_x / k_y / k_z | Pauli | sign ^= z / x^z / x |
| k_cx | CX a->b | x_b ^= x_az_a ^= z_bsign update |
| k_cz | CZ | z_a ^= x_bz_b ^= x_asign update |
One transpose per round is the floor
The lazy transpose already collapses a whole syndrome round to a single flush. Going below that, coalescing several rounds into fewer transposes, is blocked by ancilla reuse: the next round's CX ladder touches the ancillas the previous round measured, which forces a flush. So one transpose per gate-to-measure boundary, about one per round, is as far as this goes.
Zero-copy output
Once a batch of shots is sampled, the results have to cross back into Python. The batched samplers (sample_batch, frame_run) return a single flat Vec<u8> of shots * num_meas record bytes plus the num_meas width. Python does not iterate it. It views it in place:
return np.frombuffer(buf, dtype=np.uint8).reshape(shots, num_meas)No copy, no per-element boxing. The earlier design returned a nested Python structure, which forced one PyO3 Python-integer allocation per measurement per shot. That is on the order of shots * num_meas small allocations, which, measured at np.frombuffer over one contiguous buffer erases all of it: the bytes the Rust chunks wrote shot-major are the array's memory.
- old: nested Python ints
- now: one flat buffer, viewed
| nested ints | flat buffer + frombuffer | |
|---|---|---|
| Python allocations | ~ S x M | 0 (a view) |
| bytes copied out | S x M boxed | 0 |
| share of per-shot time | ~40% (n=1024, June 2026) | negligible |
Reproducible multicore
The samplers are parallel, and the parallelism is built so the answer does not depend on how many cores ran it. That invariance is a deliberate constraint, not an accident.
Each sampler splits shots into fixed-size chunks and hands them to rayon: FRAME_CHUNK = 1024 for the frame engine, BATCH_CHUNK = 256 for the per-shot tableau sampler, EST_CHUNK = 256 for the importance estimator. The size is a compile-time constant, never derived from the core count. Each chunk then derives its own RNG seeds from its index through a splitmix64 mix (chunk_seed), so chunk 5 draws the same stream no matter which worker picks it up or how many workers exist. Same seed in, byte-identical bytes out, whether it runs on one core or eight. The source verifies this at 1 vs 8 threads.
Fixed chunks, index-derived seeds
Legend:
- coloured left border = which core ran a chunk
- mono value = the chunk's splitmix64 seed, from its index
output digest= a schedule-independent fold of the seeds
The reference run, computed once
The frame sampler needs one thing before it can run: the noiseless measurement record, the reference every shot's detection events are XORed against. frame_reference computes it by running the circuit with no noise and returning the deterministic measurement bits. It returns None if any measurement is a coin flip, and the code then falls back to the heavier per-shot sample_batch.
That reference depends only on the circuit, not on the seed or the shot count. So CompiledSampler computes it once, on the first sample() call, and caches it (self._ref) for every later call. Across a logical-error-rate sweep of many sample() calls on the same circuit, this is the difference between paying the serial reference pass once and paying it every time. In one sweep it had been roughly 85% of per-call cost. Caching it moves that to a one-time setup.
The reference run has its own memory lesson. It is a full circuit replay, so it must run on the fast layout. frame_reference builds a fresh ColTableau and replays the gates column-major, transposing to the row engine only for measurements. The source notes this runs about 20x faster than replaying on the old row-major path: the same cache cliff from the first section, now avoided in the one serial pass the parallel engine cannot hide behind.
- serial reference, if recomputed every call
- parallel frame_run over the shots
Worked example
Take
The tableau is ceil(2049/64) = 33 words, so the H kernel sweeps 33 words on each of the X, Z, and sign planes: 99 word operations, all contiguous. Row-major, the same H would touch w = ceil(1024/64) = 16 words apart, for 2049 strided scalar touches. The ratio is 2049 / 33 = 62, so the column layout does about a 62nd of the distinct-cache-line work here, rising toward 64 as
When this circuit next measures, to_row transposes the planes back into the row engine by walking ceil(2049/64) x ceil(1024/64) = 33 x 16 = 528 blocks of 64x64 bits, each block six passes of transpose64. That one transpose is amortised over the whole round of gates that preceded it. A pure-gate circuit that never measures never transposes at all, because ColTableau::new started column-major.
The numbers, from the library
from qliff._core import ColTableau
n = 1024
rows = 2 * n + 1
rw = (rows + 63) // 64 # words per column plane (col-major)
w = (n + 63) // 64 # words per row-major row
print("n", n, " rows", rows, " rw", rw, " w", w)
print("col-major word-ops per plane:", rw)
print("row-major scalar touches: ", rows)
print("reduction:", rows // rw, "x")
# -> n 1024 rows 2049 rw 33 w 16
# -> col-major word-ops per plane: 33
# -> row-major scalar touches: 2049
# -> reduction: 62 xThe transpose walks rw * w = 33 * 16 = 528 blocks. A gate touches rw = 33 words. ColTableau(1024) starts column-major, so a run of gates pays neither until it measures.
One tableau, laid out so gates sweep contiguous words while measurement keeps its proven row-major path. One blocked transpose cheap enough to sit at the boundary between them. One flat buffer handed back without a copy. And a chunking scheme that makes the parallel answer independent of the hardware that produced it. The physics on the previous page decided how many shots a result costs. This page decided how fast each shot's bits move. Together they are why the logical error rate comes back in seconds instead of hours.