MPS Simulator¶
DenseSVSimulator keeps every one of a circuit's 2**n amplitudes in memory — that
works for a couple dozen qubits, but beyond that it runs out of RAM.
MPSSimulator runs the same circuits at hundreds of qubits by keeping a
compact tensor-network representation instead of the full state, at the cost of
accuracy on highly-entangled circuits.
When to use MPS: low-entanglement circuits — GHZ chains, shallow local circuits, product-state preparations. For highly-entangled circuits the bond dimension grows exponentially and MPS degrades back toward the same cost as the dense engine. It is a complement to
DenseSVSimulator, not a replacement.
Step 1. Build a circuit¶
A GHZ chain: qubit 0 goes into superposition, then every qubit is entangled with the next one. This is exactly the kind of circuit MPS is built for — the entanglement stays low no matter how many qubits the chain grows to.
import dense_evolution as de
n = 50
lines = ["OPENQASM 2.0;", 'include "qelib1.inc";',
f"qreg q[{n}];", f"creg c[{n}];", "h q[0];"]
lines += [f"cx q[{i}],q[{i+1}];" for i in range(n - 1)]
lines.append("measure q -> c;")
circuit = de.QASMParser().parse("\n".join(lines))
50 qubits is written with a loop instead of by hand, but the result is the same real OpenQASM 2.0 text the parser always takes.
Step 2. Run it with MPS instead of the full state¶
A dense statevector at 50 qubits would require 2**50 complex numbers — roughly
16 PB — far more memory than any machine has. MPSSimulator keeps a bounded
bond dimension (max_bond) instead, which is enough for a circuit this simple.
run_circuit_jit compiles the whole circuit into a single jax.lax.scan-fused,
@jax.jit-compiled kernel. Use the eager per-gate methods (apply_gate_1q,
apply_gate_2q) instead when memory is the priority over speed — the JIT path
pads every gamma/lambda tensor to max_bond for the lifetime of the instance.
Step 3. Read out the result¶
get_top_k_probable_states finds the most likely outcomes via greedy beam search,
without ever materialising a 2**50-sized array.
idx, probs = mps.get_top_k_probable_states(k=2)
for i, p in zip(idx, probs):
print(f"{i:0{n}b}: {p:.4f}")
00000000000000000000000000000000000000000000000000: 0.5000
11111111111111111111111111111111111111111111111111: 0.5000
Only two outcomes are populated — all-zeros and all-ones, each at probability 0.5. That is the GHZ signature; everything else is numerically zero.
Note: beam search recall is not guaranteed for any fixed
k. If a known state is missing from the output, increasek(e.g.k=128).
Step 4. Check accuracy¶
MPSSimulator | n=50 | chi_max=16 | chi_used=16 | mem=0.198MB | trunc_err=1.61e-06 | avg_JSD=0.0000 | EE_max=1.000b | budget_violations=0
| Field | Meaning |
|---|---|
chi_max |
Hard cap on bond dimension (max_bond argument) |
chi_used |
Largest bond dimension actually reached during the run |
trunc_err |
Cumulative singular-value truncation error |
avg_JSD |
Mean Jensen-Shannon distance between full and truncated singular-value distributions across all SVD steps |
EE_max |
Peak entanglement entropy across all bonds (bits) |
budget_violations |
Times max_bond was hit before jsd_budget could be satisfied — if > 0, raise max_bond |
Both trunc_err and avg_JSD are near zero here. budget_violations=0 means
the accuracy budget was never hit — the result is reliable at this max_bond.
Tip: enable
jax_enable_x64before importing the package for fullfloat64/complex128precision. On this circuit it dropstrunc_errfrom1.61e-06to3.75e-15and reduceschi_usedfrom 16 to 2.
Step 5. Check that a result has actually converged with bond dimension¶
chi_used/avg_JSD/budget_violations above describe a single run at a single
max_bond — they can't tell you whether a different max_bond would have given
a different answer. bond_convergence runs the same circuit at several bond
dimensions and checks whether the observable settles down as max_bond grows.
from dense_evolution.backends.mps import bond_convergence
ops = circuit.to_tuples()
result = bond_convergence(ops, n, observables=["Z" + "I" * (n - 1)], bonds=(2, 4, 8))
print(result.verdicts[0])
print(result.diffs[0])
For this GHZ chain the entanglement never grows past bond dimension 2, so <Z0>
is exactly identical at max_bond=2, 4, and 8 — both successive differences
are exactly zero, and the verdict is converged.
A highly-entangled circuit tells a different story: on a 40-qubit, 4-layer
brickwall circuit, bonds=(4, 8, 32) gives successive <Z0> differences of
about 4.7e-2 then 1.2e-2 — shrinking, but nowhere near a reasonable tol,
so the verdict is not_converged. bond_convergence needs at least 3 bond
values to make that call at all: two values alone give a single difference,
with no way to tell whether it is closing in on tol or has already stalled
(see the function's own docstring for the exact numbers). If even the largest
bond dimension in bonds is still hitting its own cap, the verdict is
undecidable instead of converged or not_converged — no tolerance can be
certified from data where the truncation never had room to breathe.
Details¶
Troubleshooting¶
| Problem | Likely cause | Fix |
|---|---|---|
budget_violations > 0 or avg_JSD is high |
max_bond is too small: the bond-dimension search hits the cap before jsd_budget is satisfied |
Increase max_bond (e.g. 16 → 64). Raising jsd_budget only loosens the tolerance — it does not improve accuracy. |
contract_to_statevector raises MemoryError |
n > 24 is a hard cutoff, not a guideline |
Use get_probabilities_sampled or get_top_k_probable_states instead — neither materialises a (2**n,) array. |
get_top_k_probable_states misses a known state |
Greedy beam search recall is not guaranteed for a fixed k |
Increase k (e.g. 32 → 128). |
Simulation is slow or memory is high after run_circuit_jit |
Tensors are padded to max_bond for the instance lifetime |
Reduce max_bond, or use the eager per-gate path for short, low-entanglement circuits. |
Internal: bucketed SVD dispatch¶
run_circuit_jit's 2-qubit SVD step no longer always runs at a fixed
max_bond-padded size -- it dispatches to the smallest provably-sufficient
bucket size for the real bond dimension at that cut, via jax.lax.switch,
inside the same single compiled kernel. No API or behavior change; measured
68.80x-73.96x faster on CPU and ~1.41x faster on GPU (measured correctly
through this same API, not a standalone reimplementation -- an earlier
2.74x GPU claim here was wrong, see the correction below) on a real N=50
TFIM Trotter circuit. Validated first in
Dense-Evolution-Discovery's bucketed-SVD experiment
and its GPU timing correction,
including a real bug (an under-sized bucket could silently drop genuine
Schmidt weight already on the bond between the two gated qubits) found and
fixed before promotion here.
Optional: gate blocking (fuse_gates=True)¶
run_circuit_jit(ops, fuse_gates=True) fuses consecutive gates acting on
the same (or a growing) qubit pair into one matrix on the host before
compiling -- exact, no approximation -- cutting the number of scan steps.
Measured ~2x faster than the bucketed dispatch alone on GPU (~2.77x-2.87x
total over the original fixed-size SVD), at the cost of coarser
truncation_errors/entanglement_entropy/bond-history bookkeeping (one
entry per fused step instead of per original gate) -- default is False,
unchanged behavior. Validated in
Dense-Evolution-Discovery's gate-blocking redesign,
including against non-adjacent-gate (SWAP-chain) and CCX circuits.
Performance¶
DenseSVSimulator and Chunk both require 2**n × 16 bytes for the
statevector alone — around 17 GB at 30 qubits, before any computation.
MPSSimulator scales as O(n × max_bond²) instead, so a low-entanglement
circuit like the GHZ chain above runs at 50+ qubits on an ordinary machine where
the dense engines would raise MemoryError.
Exact wall-clock numbers depend heavily on the machine, circuit depth, and
entanglement structure, so none are quoted here as a general result. Run
mps.summary() to see the numbers for your own machine and circuit, the same
way Step 4 above does.
mps ¶
MPSSimulator - Matrix Product State statevector simulator, JAX-backed.
Ported from the "TurboQuant TUREQ MPSSimulator v8.2 MatryoshkaFlash" prototype (private research notebook, never published as part of the dense-evolution package). Two real bugs were found and fixed by independent verification against DenseSVSimulator before this module existed in its current form:
-
The original applied Lloyd-Max quantization to the SVD singular values on every truncation ("PolarQuantizer"). Measured a real ~0.5% Total Variation Distance error against DenseSVSimulator on an 8-qubit entangling test circuit, with ZERO bond-dimension savings to show for it. Dropped entirely -- this module keeps only the plain adaptive SVD truncation (JSD-budget-driven bond dimension, the author's own stopping criterion -- standard SVD truncation, non-standard stopping metric).
-
get_top_k_probable_states (originally "_extract_top_k_paths") picked a single "best" bond index via argmax at each step instead of correctly summing over the bond dimension. Measured 0/8 correct states against the exact contraction on the same test circuit, values off by ~30x. Fixed by propagating the true partial-contraction vector through each bond (matches exactly, to machine precision, on every state it finds) -- but note it's a genuine greedy beam search, not an exact top-k finder: recall of the true top states grows with beam width k but isn't guaranteed complete for any fixed k.
Originally ported in plain numpy (matching the prototype), then converted to jax.numpy so the core tensor contractions (einsum, SVD) run on the same backend as the rest of dense_evolution instead of a second, inconsistent numerics stack. Re-verified against DenseSVSimulator after the conversion -- see test_mps.py.
Uses whatever jax_enable_x64 precision is currently active in the process (does not toggle it itself) -- same convention as DenseSVSimulator/Chunk, which rely on the caller (dashboard_core.py's run_simulation) to set precision, since jax_enable_x64 is a process-wide flag and toggling it locally would leak to unrelated code running later in the same process.
For circuits with LOW entanglement (product states, GHZ/Bell-like chains, shallow local circuits), the bond dimension stays small regardless of qubit count, so this scales to hundreds of qubits where DenseSVSimulator (or Chunk) cannot -- see get_probabilities_sampled and get_top_k_probable_states, neither of which ever materializes a (2**n,)-shaped array. For HIGHLY entangled circuits the bond dimension grows and this degrades back toward the same exponential cost DenseSVSimulator has -- it is not a universal replacement, it is complementary.
MPSSimulator ¶
MPSSimulator(
n_qubits: int,
max_bond: int = 64,
svd_cutoff: Optional[float] = None,
jsd_budget: float = 1e-05,
use_float32: Optional[bool] = None,
)
Matrix Product State simulator with adaptive SVD-truncated bond dimension (JSD-budget driven), no lossy post-truncation quantization. JAX-backed core (einsum, SVD).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n_qubits
|
int
|
|
required |
max_bond
|
int
|
|
64
|
svd_cutoff
|
Optional[float]
|
|
None
|
jsd_budget
|
float
|
|
1e-05
|
use_float32
|
bool or None -- None (default) follows the process-wide
|
|
None
|
Source code in dense_evolution/backends/mps.py
apply_gate_1q ¶
O(chi^2) -- updates only Gamma[qubit].
apply_gate_2q ¶
2-qubit gate with adaptive SVD truncation. O(chi^3).
Vidal's full two-site update (prog.txt P0 fix): theta is built from BOTH outer Lambdas (Lambda[q1], Lambda[q2+1]) as well as the middle one, not just the middle one -- so its singular values are the true global Schmidt coefficients at this cut, not an artifact of the local 2-site reduced state. New Gamma tensors are recovered by dividing the outer Lambdas back out (regularized: entries at or below svd_cutoff map to a zero inverse instead of blowing up -- exactly the padded/zero entries in the JIT path's fixed-size arrays, and the trivial size-1 boundary Lambda everywhere else).
Source code in dense_evolution/backends/mps.py
apply_ccx ¶
Toffoli via standard T-gate decomposition (all 1q/2q gates).
Source code in dense_evolution/backends/mps.py
get_probabilities_sampled ¶
Returns a {bitstring: empirical_probability} dict from n_samples sequential draws -- the only entry point safe for n_qubits > 24.
Source code in dense_evolution/backends/mps.py
get_top_k_probable_states ¶
Greedy beam search (beam width k) for approximately-most-probable basis states, without ever contracting to a full statevector.
Returns (indices, probabilities): indices are computational-basis integers, probabilities are exact for the states found (not approximated), sorted descending. Recall of the TRUE top states improves with k but is not guaranteed for any fixed k -- see the module docstring.
Source code in dense_evolution/backends/mps.py
run_circuit_jit ¶
Runs an entire circuit through a single jax.lax.scan-fused, @jax.jit-compiled kernel instead of one eager Python call per gate -- the eager path (apply_gate_1q/apply_gate_2q/_apply_nonlocal_2q, all still available and unchanged) has zero @jax.jit anywhere and pays a host-device sync on every 2-qubit gate's bond-dimension search; measured 88.9s vs Qiskit Aer's 0.64s on a 60-qubit stress circuit -- see README changelog for the real before/after number this method produces on that same circuit.
Trade-off, explicit and intentional (not hidden): every gamma/ lambda is kept at a fixed max_bond-padded size for the rest of this instance's lifetime after this call. Structurally correct either way (zero-padding is mathematically transparent to every other method here -- contract_to_statevector, get_top_k_probable_ states, etc. all still work correctly on the padded arrays, verified), just not memory-minimal for genuinely low-entanglement circuits, which is this module's whole point for very large qubit counts. Use the eager methods directly instead when memory, not speed, is the priority -- this is an addition, not a replacement.
ops: same convention as DenseSVSimulator.run_circuit_jit_beast_mode -- list of (name, *args) tuples/lists. Unlike that method, SWAP is never decomposed into 3xCX (kept as one real gate, see _compile_mps_ops's docstring for why that matters here).
fuse_gates: opt-in, default False. When True, consecutive gates acting on the same (or a growing) qubit pair are fused into one matrix on the host before compiling (exact -- matrix multiplication, no approximation), cutting the number of scan steps and measurably faster on GPU (~2x on top of the bucketed SVD dispatch alone, see Dense-Evolution-Discovery's mps_gate_blocking_redesign_v2 experiment for the full validation, including verification against non-adjacent-gate and CCX circuits). The trade-off: self._bond_history/jsd_per_bond/ truncation_errors/entanglement_entropy get one entry per FUSED step instead of per original gate -- real diagnostics, just coarser-grained. Defaults to False so existing behavior and per-gate bookkeeping granularity are unchanged unless requested.
Source code in dense_evolution/backends/mps.py
1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 | |
mps_pauli_expectation ¶
pauli_terms accepts the same three forms as
physics.observables.pauli_expectation (a string, e.g. 'XIZ'; a dict
{qubit: 'X'|'Y'|'Z'}; or an iterable of (qubit, pauli) pairs) -- reuses
that module's own _normalize_terms so both functions agree on
parsing by construction, not by parallel reimplementation. See
_mps_transfer_sweep for why the division is needed.
Source code in dense_evolution/backends/mps.py
mps_pauli_sum_expectation ¶
sum_i coeff_i *
Source code in dense_evolution/backends/mps.py
bond_convergence ¶
bond_convergence(
ops: List,
n_qubits: int,
observables: list,
bonds: List[int],
tol: float = 0.001,
**mps_kwargs,
) -> BondConvergenceResult
Runs the same circuit at every value in bonds (increasing) and
checks whether the reported observables have actually converged with
respect to bond dimension, instead of trusting a single run's own
internal diagnostics.
Requires len(bonds) >= 3. Two bonds give exactly one discrepancy,
which is a single number with no way to tell whether it is still
shrinking toward tol or has already stalled -- measured on a
40-qubit, 4-layer brickwall circuit, chi=4->8->32 gave |tol.
A verdict of "converged" additionally requires the successive
discrepancies to be monotonically non-increasing, not just that the
last one is below tol -- a single small discrepancy proves nothing
about the trend on its own, which is the same failure mode as the
two-bond case above, one level up. (Ties count as non-increasing: an
exactly-converged observable, e.g. a GHZ chain whose bond dimension
never needs to grow, produces identical values -- and therefore
zero discrepancies -- at every bond, which must count as converged.)
avg_jsd and budget_violations (from the underlying MPSSimulator runs)
are reported per bond for context only, never used to decide the
verdict -- a low average JSD is computed per truncation step and says
nothing about whether the specific observable being tracked has
settled down as max_bond grows.
If max_bond_used() at the highest bond still equals that bond's cap, the truncation never had headroom below max_bond at any cut, so no tolerance can be certified from this data: every observable's verdict becomes "undecidable" regardless of its own discrepancies.
Source code in dense_evolution/backends/mps.py
1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 | |
See also: DenseSVSimulator for exact statevector simulation
when entanglement is too high for a bounded bond dimension, and Chunk
for anti-OOM dense simulation at large qubit counts without bond-dimension
truncation.