Chunk (large-scale, anti-OOM)¶
DenseSVSimulator allocates all 2**n amplitudes the moment it's created — at 30
qubits that's already ~17 GB, before running a single gate. Chunk runs the same
kind of circuit without that up-front allocation: it reads the actual compute
device's free memory (VRAM on GPU/TPU, not just host RAM — Step 4's Details section
has the real Colab OOM bug that made this distinction necessary), decides how many
RAM-sized pieces the circuit needs right now, and runs gates across those pieces
with a compiled kernel that never builds the full array — including, when more than
one physical device is available, spreading the pieces across a real device mesh
instead of one process's RAM (Step 6) — or, past even that RAM ceiling,
spilling idle pieces to disk and streaming the circuit through in phases
(Step 7). None of that shows up in the public API: Chunk looks exactly
like DenseSVSimulator to call.
The pieces, and how they fit together¶
Five names live in this module. You only ever call one of them directly — the rest exist to make that one work.
| Name | What it actually is | Do you call it? |
|---|---|---|
Chunk |
The public class. Decides everything below automatically. | Yes — this is the only one most code needs. |
MemoryChunker |
Pure arithmetic: given n_qubits and the machine's real free memory right now, computes how many RAM-sized pieces are needed. Allocates nothing. |
Only to inspect the geometry without building a Chunk. |
SafeMemoryGuard |
Checks real RAM/VRAM before an allocation and raises MemoryPressureError if it isn't safe. |
Only to check memory status yourself, outside a Chunk. |
CircuitChunker |
Runs a circuit in gate-sized slices against one simulator, so XLA doesn't recompile a new trace shape every call. | Never directly — Chunk owns one internally. |
MemoryPressureError |
The exception SafeMemoryGuard raises. |
To catch it, if you want to react instead of crashing. |
Chunk(n_qubits) always does the same two things: ask MemoryChunker how many
pieces n_qubits needs on this machine, right now, then either (a) — fits in one
piece — hand the circuit to a CircuitChunker wrapping one ordinary
DenseSVSimulator, or (b) — needs more than one piece — hold that many simulators
at once and run gates across them with a compiled kernel that never builds the full
(2**n,) array. Step 1 below is case (a); Step 2 is case (b).
Step 1. The common case: one circuit, one piece¶
import dense_evolution as de
qasm = 'OPENQASM 2.0; include "qelib1.inc"; qreg q[4]; h q[0]; cx q[0],q[1]; cx q[1],q[2]; cx q[2],q[3];'
circuit = de.QASMParser().parse(qasm)
chunk = de.Chunk(4)
chunk.run_chunk(circuit.to_tuples())
chunk.num_chunks, chunk.get_probabilities().round(4)
A 4-qubit GHZ chain — the standard |0000>/|1111> signature.
num_chunks == 1: on a real machine, 4 qubits fit comfortably inside the safe
budget (chunk.chunk_size_bits, usually well above 20 — see Step 3), so Chunk
just wraps one ordinary DenseSVSimulator and behaves identically to it —
run_chunk/get_probabilities/get_statevector mirror
run_circuit_jit/get_probabilities/get_statevector one-for-one. This is what
almost every real circuit does; the actual splitting in Step 2 only activates once
n_qubits genuinely exceeds what fits in memory.
Step 2. Forcing the split, to see it for real¶
On a real machine, num_chunks > 1 only kicks in at a qubit count too large to
demonstrate directly in a doc (dozens of qubits, gigabytes per piece). To show the
actual multi-piece code path deterministically regardless of this machine's real
RAM, override get_dynamic_chunk — the same technique this project's own test
suite uses to test the split cheaply.
de.chunk.get_dynamic_chunk = lambda dtype_target: 2
chunk2 = de.Chunk(4)
chunk2.num_chunks, chunk2.chunk_size_bits
Capping the safe budget at 2 qubits forces the same 4-qubit request from Step 1 to
split into 4 separate 2-qubit pieces instead of 1. Running the identical circuit now
exercises the real cross-piece kernel — cx q[1],q[2] connects two different
pieces, not two qubits inside the same array:
Identical result to Step 1 — same circuit, same physics — but this time computed as
4 separate (4,)-element pieces held in RAM together, never as one (16,)-element
array. get_dynamic_chunk is only overridden here to make the split happen at a
size small enough to show; real code never touches it — Chunk calls it
automatically, sized to whatever machine it's actually running on.
Step 3. Why this matters: the geometry at a qubit count that doesn't fit¶
from dense_evolution.backends.chunk import MemoryChunker
geo = MemoryChunker(40)
geo.num_chunks, geo.chunk_dim, geo.chunk_size_bits
MemoryChunker computes this geometry without allocating anything — pure
arithmetic, safe to call at any qubit count, on any machine (expect different
numbers on a different machine; this reflects real available memory here and now).
A plain DenseSVSimulator(40) would need 2**40 complex128 amplitudes, on the
order of 17 TB, all at once. Chunk(40) would instead hold 8192 separate pieces of
2**27 amplitudes each (~1 GB apiece, geo.memory_mb()) — the same mechanism Step
2 just showed at a size you can actually run.
Step 4. The anti-OOM guard blocks unsafe allocations before they happen¶
from dense_evolution.backends.chunk import SafeMemoryGuard, MemoryPressureError
guard = SafeMemoryGuard(threshold_pct=0.99)
try:
guard.check("demo")
except MemoryPressureError:
print("blocked before any allocation was attempted")
threshold_pct=0.99 demands 99% of memory free after the check — essentially
never true on a real machine, so this always raises. Chunk.__init__ runs the
same check (a realistic threshold_pct, default 0.15) before allocating
anything, and CircuitChunker (Step 1's single-piece path) runs it again before
every gate-slice during execution — the failure mode this exists for is
jaxlib.xla_extension.XlaRuntimeError: RESOURCE_EXHAUSTED crashing the whole
process partway through a run; MemoryPressureError raised up front, with a
clear message, is the alternative.
Step 5. Diagnostics¶
"Chunk(n_qubits=4, safe_qubits=4, num_chunks=1, chunk_size_bits=27, dtype=<class 'jax.numpy.complex128'>, mem_per_chunk=0.0 MB, ram_free=38.9%, has_jax=True)"
ram_free reflects the machine's real state at the moment repr is called, not a
cached value from construction — expect a different number here too; useful to
check right before a large run, not just after a failure.
Step 6. Beyond one process: real multiple devices¶
Everything above holds all num_chunks pieces in one process's RAM. When more than
one physical JAX device is actually available, run_chunk_distributed runs the
identical computation with each piece pinned to its own device instead — real
point-to-point network exchange (jax.lax.ppermute) between devices for gates that
connect two pieces, not a bigger single-machine array.
'dispatch_distributed() needs >= 4 JAX devices (one per chunk), only 1 available. Force extra CPU devices for testing via the XLA_FLAGS environment variable: --xla_force_host_platform_device_count=N ...'
This machine has one JAX device, so chunk2's 4 pieces (Step 2) can't spread across
4 real ones — run_chunk_distributed raises immediately with a clear, specific
error rather than silently running the single-process path instead, which would
quietly give up the reason to call it at all. Force extra CPU devices to actually
exercise this path locally: set XLA_FLAGS=--xla_force_host_platform_device_count=N
before the process starts (JAX's device count is fixed at first initialization).
Step 7. Beyond RAM entirely: spilling to disk¶
Steps 2-6 all assume num_chunks pieces fit in RAM together. Past that ceiling,
allow_disk_overflow=True keeps the idle pieces on disk as plain .npy files
instead of raising MemoryPressureError — the technique IBM used to break the
49-qubit simulation barrier classically (Pednault et al. 2019,
arXiv:1910.09534, "Leveraging Secondary
Storage to Simulate Deep 54-qubit Sycamore Circuits"): only materialize, in RAM,
the one or two pieces a gate actually touches, never the whole stack.
"Chunk(n_qubits=4, safe_qubits=2, num_chunks=4, chunk_size_bits=2, storage=disk (C:\\Users\\...\\dense_evolution_chunk_x12_1zlg), dtype=<class 'jax.numpy.complex128'>, mem_per_chunk=0.0 MB, ram_free=15.5%, has_jax=True)"
memory_threshold=0.999999 (99.9999% free required after the allocation) forces
the same guard from Step 4 to fail here — on a real machine you'd never set this
yourself, it exists only to force the fallback deterministically for this demo,
the same role Step 2's get_dynamic_chunk override plays. storage=disk in the
repr confirms the fallback actually triggered, not a silent no-op.
Identical result to Steps 1 and 2, same GHZ circuit — but this time no more than
two (2,)-element pieces were ever resident in RAM as a JAX array at once,
regardless of num_chunks. Call chunk3.close() afterward to remove the temporary
directory Chunk created for the .npy files (skip it if you passed your own
disk_dir=).
Details¶
Two different reasons a circuit gets sliced¶
Chunk uses "chunking" for two genuinely different things, easy to conflate:
gate-slicing (CircuitChunker, Step 1's path — the statevector is one array,
but the circuit is split into chunk_size_gates-sized pieces purely so XLA
doesn't recompile a fresh trace shape on every call) versus statevector-splitting
(Step 2's path — the array itself is too large for one piece, so it's held as
several smaller arrays instead). A circuit only ever goes through one path, decided
once, in Chunk.__init__, by whether n_qubits fits chunk_size_bits.
Building a second Chunk with the same geometry doesn't recompile¶
num_chunks > 1's kernel (_build_multi_chunk_runner/_build_distributed_chunk_runner)
is memoized on (num_chunks, m, k) — the three plain integers that fully determine
it. Without this, every Chunk.__init__ built a brand-new Python closure and wrapped
it in a fresh jax.jit, so two Chunk instances with identical geometry could never
hit JAX's own compilation cache (that cache is keyed by the wrapped function's
identity, not by structural equality of what it captured) — each one silently repaid
the full XLA compile cost the other had already paid. Measured directly: a second
Chunk(4) built right after a first one with the same forced geometry went from
0.654s (cold compile) to 0.123s (cache hit, verified same runner object,
c1._multi_chunk_runner is c2._multi_chunk_runner) — this is what actually makes
repeated Chunk construction (a VQE loop, a parameter sweep, this test suite's own
90+ Chunk(...) calls) cheap after the first one, automatically, with nothing for a
caller to do differently.
Sizing from device memory, not just host RAM¶
The safe qubit budget (get_dynamic_chunk, behind chunk_size_bits) reads the
active compute device's own memory via jax.devices()[0].memory_stats() when
available, falling back to host RAM (psutil) only when it isn't (e.g. plain
CPU). This matters concretely on GPU: sizing chunks off host RAM while the actual
data lives in a much smaller GPU VRAM pool caused real MemoryPressureError/OOM
crashes well before VRAM was actually exhausted — verified on a real Colab T4 GPU
before this was fixed, where chunk sizing used >11 GB of host RAM headroom while
the data lived in the T4's 11-15 GB VRAM.
num_chunks > 1: no statevector is ever fully materialized¶
Above the safe budget, Chunk holds num_chunks separate chunk-sized
DenseSVSimulator instances instead of one giant array, and run_chunk dispatches
gates against them via one compiled jax.lax.scan over a (num_chunks, chunk_dim)
stacked representation — the same total element count as the separate pieces it
replaces, so the (2**n_qubits,)-shaped array this class exists to avoid is never
built. SafeMemoryGuard.check_allocation runs once up front, sized for
num_chunks + 2 pieces held at once (headroom for the cross-piece gate-mixing
math's own temporary arrays) — if that doesn't fit, construction fails immediately
with MemoryPressureError, before any of the num_chunks inner simulators are
allocated.
Disk overflow (Step 7): why it can't just be "use a memmap"¶
The obvious-looking fix — back each piece with a numpy.memmap file instead of a
plain array — doesn't actually work here: DenseSVSimulator.sv is always a live
jax.Array (self.xp = jnp), and JAX has no concept of a memmap-backed device
array — every jnp.array(...) call materializes real RAM regardless of what fed
it. Making a piece genuinely live on disk while idle means changing when a
piece becomes a jax.Array at all, not just where its bytes sit — which is the
same change as processing the circuit in phases.
dense_evolution/backends/chunk/disk_overflow.py classifies every gate into one
of three phases, reusing (not re-deriving) the exact case split
_build_multi_chunk_step/_build_distributed_chunk_step already use:
- Local — both qubits (or the only qubit) are outside the chunk-select range. Runs against one piece at a time, no partner ever needed.
- Conditional — a 2-qubit gate with a chunk-select control and a local target. Still needs only one piece: whether the control fires is decided from that piece's own absolute index, exactly the insight Step 6's distributed kernel already documents for this same case ("no communication needed").
- Mix — anything touching a chunk-select qubit as its mixing qubit. Needs exactly its XOR-stride partner piece — never the whole stack.
This is the same decomposition LaRose 2018
(arXiv:1801.01037, "Distributed Memory
Techniques for Classical Simulation of Quantum Circuits") uses for Step 6's real
multi-device path, applied here to disk instead of a network: "communication"
means a disk read/write of one partner file instead of a ppermute to another
device. This is v1, correctness-first, not fast — every gate pays real file
I/O, one piece (or pair) at a time, no batching multiple pairs into one call; it
exists to make otherwise-impossible sizes possible at all, not to compete with
Steps 2/6 on speed.
run_chunk_distributed's one-piece-per-device scope¶
Step 6's distributed path currently supports exactly one piece per physical
device (jax.device_count() >= num_chunks required) — the literal v1 reading of
the feature request behind it ("spread pieces across a device mesh"), not a
hybrid scheme with several pieces sharing one device, which remains a possible
future refinement. jax.lax.ppermute's communication topology (perm=) must be
static — known at trace time — so it can't be built from the traced q1/q2
qubit indices directly; every possible chunk-select stride is instead enumerated
as its own statically-built ppermute call ahead of time, and jax.lax.switch
picks the right one at runtime. The underlying design — a fixed number of
"chunk-select" qubits needing pairwise communication, the rest applying purely
locally — follows LaRose 2018
(arXiv:1801.01037, "Distributed Memory
Techniques for Classical Simulation of Quantum Circuits"), which demonstrated
the same scheme on a real multi-node supercomputer (MPI/OpenMP, up to 33 qubits
across 26 processors) — real, tested distributed hardware, not just this
project's own simulated multi-device CPU testing described above. This
project's own real multi-host (separate physical machines, not simulated
local devices) run of this path remains untested — see Step 7 for the sibling
gap this module does cover (disk instead of RAM), and docs/changelog.md for
open items.
.sv accepts an external statevector back¶
chunk.sv = new_statevector writes a full (2**n,) array back through to
whichever physical storage Chunk is actually using — split back into per-piece
slices automatically when num_chunks > 1. This is what makes
NoiseModel.apply_to_sv(chunk.sv, ...) (see Noise) work transparently
on a Chunk instance the same way it works on a plain DenseSVSimulator.
Backward-compatible names¶
chunk1/chunk2/Chunk2Incrociato are aliases for MemoryChunker/Chunk/Chunk
respectively, kept for code written against this module's earlier internal names —
unrelated to Step 2's chunk2 variable name, a coincidence of this page's own
narrative, not the alias.
chunk ¶
Backward-compatibility shim -- the real implementation moved to dense_evolution.backends.chunk as part of the Phase 2 subpackage split (see prog.txt). chunk.py was the one module left behind at the package root when the rest of the split happened (everything else -- simulator, compiler, gates, trotter, qec, ... -- was already moved with its own shim); this closes that gap.
Unlike trotter.py/qec.py's shims (which re-export a short, stable public
list), dense_evolution.chunk is imported directly by module path in many
places -- tests/unit/test_chunk.py, tools/dashboard/core/system_limits.py,
research/local_site/app/server.py -- including private helpers like
_compile_multi_chunk_ops, not just the public Chunk class. Re-exporting a
curated name list would silently drop one of those on the next internal
refactor, so instead this shim replaces itself in sys.modules with the
real module object: dense_evolution.chunk and
dense_evolution.backends.chunk become the exact same module, byte for
byte, not two objects kept in sync by hand.
Import from dense_evolution.backends.chunk directly in new code.
See Also¶
DenseSVSimulator— the engineChunkwraps; Step 1 mirrors its own API one-for-one.MPSSimulator— an alternative for large qubit counts that trades exactness for a bounded bond dimension instead of RAM-sized slicing.- Noise —
NoiseModel.apply_to_sv, usable directly onchunk.sv.