Skip to content

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 n qubits has 2n+1 Pauli rows: n destabilizers, n stabilizers, and one measurement scratch. Each row is a pair of bit-vectors (an X-part and a Z-part) plus a sign. Pack the bits 64 to a machine word and the whole state is a few 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 n=65536 the tableau is 131073 rows, and every gate and every measurement touches it. Where the bits sit decides everything.

Two layouts for one tableau

Take a single-qubit gate on qubit q. Whatever the gate, it reads and writes q's bit in every row, all 2n+1 of them. There are two ways to store those bits, and they are not equal.

Store the tableau row-major, row after row with each row's n qubits packed left to right, and q's bits land one cell per row, w = ceil(n/64) words apart. A gate is then 2n+1 strided scalar touches, each in a different cache line. Once the tableau outgrows cache, that stride costs a cache miss per row and gate throughput falls off a cliff. We call that cliff Wall 1, and it made large-n runs unusable.

Store the tableau column-major, with qubit q's bits as one contiguous bit-plane over the 2n+1 rows (rw = ceil(rows/64) words), and the same gate becomes a single sweep over rw whole words per plane. That is O(rows/64) contiguous word operations instead of O(rows) scattered ones: about a 64x drop in distinct cache lines, and the sweep vectorises. 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 q's bits (what the gate touches)
  • faint bands = destabilizer, stabilizer, and scratch rows
  • column-major / row-major = the two memory orders
Who owns which operation
  • column-major planes (xc / zc / signc)
  • row-major engine (inner)
operationauthoritative layoutcost per call
1- and 2-qubit gatescolumn-majorceil(rows/64) word ops per plane
measure / reset / expectationrow-majorproven CHP path, word_g phase sums
gate -> measure switchtransposeone blocked 64x64 transpose
The layout flag makes the transpose lazy: a run of gates stays column-major and pays nothing, and a run of measurements stays row-major. Only the boundary between the two kinds costs a transpose, so a d-round memory experiment transposes about once per round, not once per gate.

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, O(rows/64) 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 O(64log64) word operations instead of 64×64 individual bit moves. 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 n=65536, and this replaced it. One correctness subtlety remains: Hacker's Delight transposes an MSB-column matrix, and ColTableau stores LSB-column (bit b of word r is column b), so the shifted operand is flipped to get the LSB form. The unit tests verify this against a naive transpose.

Second, initialization skips the transpose entirely. ColTableau::new builds |00 directly in the column planes and starts in 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-n gates ahead of stim; no hand-written intrinsics are involved, and the section below explains why we left it that way.

transpose64: six divide-and-conquer passes
  • block size j, halving each pass
  • each pass swaps j x j block pairs across the diagonal
pass123456
block size j32168421
Six passes total, O(64 log 64) word ops, and each of the 64 output words is written once. The kernels below are what run between transposes: one whole-plane sweep each.
The column-major gate kernels (all bounds-check-free, autovectorised)
  • whole-plane word sweep over ceil(rows/64) words
  • writes the sign plane
kernelgateper-word op
k_hHsign ^= x & z
swap x, z
k_s / k_sdagS / S†sign ^= x & z (or x & !z)
z ^= x
k_x / k_y / k_zPaulisign ^= z / x^z / x
k_cxCX a->bx_b ^= x_a
z_a ^= z_b
sign update
k_czCZz_a ^= x_b
z_b ^= x_a
sign update
SWAP is three CX kernels. Each op is one pass over the plane's words, so a gate's whole cost is the ceil(rows/64) factor and nothing else. The kernels are #[inline], so the batched run() folds them straight into its loop with no per-gate call.

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:

python
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 n=1024 in the June 2026 profiling run, came to about 40% of per-shot time. np.frombuffer over one contiguous buffer erases all of it: the bytes the Rust chunks wrote shot-major are the array's memory.

Crossing the FFI boundary, per batch of S shots x M measurements
  • old: nested Python ints
  • now: one flat buffer, viewed
nested intsflat buffer + frombuffer
Python allocations~ S x M0 (a view)
bytes copied outS x M boxed0
share of per-shot time~40% (n=1024, June 2026)negligible
Numbers attributed to the June 2026 profiling run, not re-measured here. The current samplers return (bytes, num_meas) and Python reshapes the buffer. The DetectorSampler then XOR-folds detectors against their noiseless reference directly on that uint8 array.

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.

Where an LER sweep spends its time
  • serial reference, if recomputed every call
  • parallel frame_run over the shots
  • reference~85% / call
  • frame_run~15% / call
Share attributed to the sweep profiled in the June 2026 run, not re-measured here. Caching the reference (it is circuit-only) turns the red bar into a one-time cost. Running it on the column-major layout keeps that one-time cost ~20x smaller than the old row-major replay would.

Worked example

Take n=1024, one H gate, and follow the memory.

The tableau is 2n+1=2049 rows. Column-major, qubit q's plane 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 q's bit in every one of the 2049 rows, 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 n grows.

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
python
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 x

The 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.