diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a68a12b7..a1f269b13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v4 # Toolchain for the cargo-fmt / cargo-check / cargo-clippy hooks. - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.98.0 with: components: rustfmt, clippy @@ -59,7 +59,9 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.98.0 + with: + components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 @@ -85,7 +87,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.98.0 with: targets: wasm32-unknown-unknown @@ -101,7 +103,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.98.0 - uses: Swatinem/rust-cache@v2 @@ -128,7 +130,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.98.0 - uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/license-headers.yml b/.github/workflows/license-headers.yml index 93b682613..35a8a5c3e 100644 --- a/.github/workflows/license-headers.yml +++ b/.github/workflows/license-headers.yml @@ -12,5 +12,7 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: jdx/mise-action@v2 + - name: Check SPDX headers - uses: korandoru/hawkeye@v6 + run: mise exec -- hawkeye check --config licenserc.toml diff --git a/.github/workflows/rust-docs.yml b/.github/workflows/rust-docs.yml index 52d574e9f..c319be1dd 100644 --- a/.github/workflows/rust-docs.yml +++ b/.github/workflows/rust-docs.yml @@ -16,7 +16,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.98.0 - uses: Swatinem/rust-cache@v2 diff --git a/Cargo.lock b/Cargo.lock index 55b321d4e..e72de3cb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -408,7 +408,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", - "unicode-width 0.1.14", + "unicode-width 0.2.0", ] [[package]] @@ -1488,7 +1488,7 @@ dependencies = [ "vihaco-circuit-isa", "vihaco-cpu", "vihaco-parser", - "vihaco-parser-core", + "vihaco-parser-derive", ] [[package]] @@ -1501,6 +1501,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2069,6 +2078,36 @@ dependencies = [ "serde_json", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0db3bae107c9522f86d361697dee1d7386a2ddcf659d5aea5159819a21a3c4a7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + [[package]] name = "unarray" version = "0.1.4" @@ -2136,9 +2175,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vihaco" -version = "0.1.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "690c9db1827ed6e392340d3d9c65408213c7bb4c030b5e8991b03459e5c7bb17" +checksum = "8ca5492013a046a93fbc33c86b1a780c094cc1c7f330b1a863518abf2f5d9263" dependencies = [ "byteorder", "chumsky 0.10.1", @@ -2148,9 +2187,51 @@ dependencies = [ "eyre", "log", "smallvec", - "vihaco-derive", + "vihaco-abi", + "vihaco-bytecode", + "vihaco-module", "vihaco-parser", - "vihaco-parser-core", + "vihaco-parser-derive", + "vihaco-runtime", + "vihaco-stdlib", + "vihaco-syntax", +] + +[[package]] +name = "vihaco-abi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ce60d30d7bbdd0680a3e2f03602019a8e40e18ca0fdcf862fe7ad05ef8f253c" +dependencies = [ + "byteorder", + "chumsky 0.10.1", + "eyre", + "smallvec", + "vihaco-abi-derive", +] + +[[package]] +name = "vihaco-abi-derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f9e9ea31ac0f95b81b80039d9ad0efcbf613e7821a137cddad474ce35eee63" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "vihaco-bytecode" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daab07fd1060f2d7480a83a2f0674c7047e972a25420a6896b8761a55de40c21" +dependencies = [ + "byteorder", + "chumsky 0.10.1", + "eyre", + "vihaco-abi", ] [[package]] @@ -2162,56 +2243,108 @@ dependencies = [ "smallvec", "vihaco", "vihaco-parser", - "vihaco-parser-core", ] [[package]] name = "vihaco-cpu" -version = "0.1.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be6cffffe3fca901bcddc389422dd9d2f7041038571a758a03c63e53ce1b6d5e" +checksum = "d43a9411dfe3802d58fec11952f33ab816a64ce1388742e1b2138b5768874072" dependencies = [ "chumsky 0.10.1", "codespan", "eyre", "log", "vihaco", - "vihaco-derive", "vihaco-parser", - "vihaco-parser-core", + "vihaco-parser-derive", ] [[package]] -name = "vihaco-derive" -version = "0.1.1" +name = "vihaco-module" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f11ac1123518d4bec265847a1a12e406c1b54f4ef2478db87f4e6986e26b918" +checksum = "7fb64aeec69cda6c683ed066db8466a4c3450c2380b02218ea2008ae1c26c227" dependencies = [ - "convert_case", + "colored", + "eyre", + "vihaco-abi", + "vihaco-bytecode", +] + +[[package]] +name = "vihaco-parser" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7055d520235e111a3ab1b709b12598cb85b22af917a989249854faab2239e3c5" +dependencies = [ + "byteorder", + "chumsky 0.10.1", + "eyre", +] + +[[package]] +name = "vihaco-parser-derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4da6e8db950479302542e9327e51b9457ee8b029c3d68ff8b8dec827ddae757" +dependencies = [ + "chumsky 0.10.1", + "eyre", "proc-macro2", "quote", "syn", + "vihaco-parser", ] [[package]] -name = "vihaco-parser" -version = "0.1.1" +name = "vihaco-runtime" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b0c3b54b4caeaaeeda2953fa273155887e89ebdb0ce80b65d23c435b1e70a5" +checksum = "6f27c5c78eac004e23269c32967345901ebfd44f73b0cf4cdd018695c9fad176" dependencies = [ + "chumsky 0.10.1", + "eyre", + "vihaco-abi", + "vihaco-bytecode", + "vihaco-module", + "vihaco-parser", + "vihaco-runtime-derive", +] + +[[package]] +name = "vihaco-runtime-derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec5b07b344dcad68f0d09c900056c8eea9f442b46daa1003d7420749314ee37" +dependencies = [ + "convert_case", + "proc-macro-crate", "proc-macro2", "quote", "syn", - "vihaco-parser-core", ] [[package]] -name = "vihaco-parser-core" -version = "0.1.1" +name = "vihaco-stdlib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd65a4e9145dc1a3c0ff004b146f960ce2971ed9c6ed9213e0db49627b21417e" +dependencies = [ + "eyre", + "vihaco-runtime", +] + +[[package]] +name = "vihaco-syntax" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aea97e3bc67e4b5a97e21f2a4f64e4a87749038850968021ec903cc595353d20" +checksum = "bc1b62d87ca4a2a2eb225b6d00e62adaddd8b5b0c332ab044adf0b7c96d64fe3" dependencies = [ "chumsky 0.10.1", + "eyre", + "vihaco-bytecode", + "vihaco-parser", ] [[package]] @@ -2494,6 +2627,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/benchmarks/README.md b/benchmarks/README.md index 39dc22a30..65b95b269 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -87,3 +87,19 @@ uv run --with matplotlib python benchmarks/plot_branch_coalesce.py \ - `plot_branch_coalesce.py` — left panel: time vs `m` (log-log); right panel: sort-merge speedup `t_hash / t_sortmerge` vs `m`, with the crossover line and the "hash wins" band. + +# Cross-library Pauli propagation + +`ppvm` against [PauliPropagation.jl][xpp], [PauliStrings.jl][xps], Qiskit's +[pauli-prop][xqk] and Algorithmiq's [monoprop][xmp], on TFIM Trotter and +Heisenberg autocorrelator workloads, all single-threaded and all validated to +propagate the identical operator term-for-term before anything is timed. + +Lives in [`cross-library/`](cross-library/README.md) — see that README for the +circuit spec, the shared environment contract, the per-engine caveats (notably +that monoprop is parallel unless capped), and a measured run. + +[xpp]: https://github.com/MSRudolph/PauliPropagation.jl +[xps]: https://github.com/nicolasloizeau/PauliStrings.jl +[xqk]: https://github.com/Qiskit/pauli-prop +[xmp]: https://github.com/Algorithmiq/monoprop diff --git a/benchmarks/cross-library/README.md b/benchmarks/cross-library/README.md new file mode 100644 index 000000000..4c02f6232 --- /dev/null +++ b/benchmarks/cross-library/README.md @@ -0,0 +1,382 @@ +# Cross-library Pauli-propagation benchmark + +`ppvm` against the four other single-threaded Pauli-propagation engines we know +of, on two workloads: + +| library | language | entry point used | +|---|---|---| +| **`ppvm`** (this repo) | Rust | `PauliSum>` | +| [PauliPropagation.jl][pp] | Julia | `propagate(PauliRotation(...), psum; min_abs_coeff)` | +| [PauliStrings.jl][ps] | Julia | `trotter_step!(O, gates; truncation, truncate_every)` | +| [pauli-prop][qk] (Qiskit) | Rust-accelerated Python | `propagate_through_circuit(op, qc, max_terms, atol, frame="h")` | +| [monoprop][mp] (Algorithmiq) | C++ with Python bindings | `PauliPropagator.from_circuit(circuit, op, cutoff, lower_atol)` | + +```bash +# Everything, with the term-for-term agreement check first. +uv run --no-project python3 benchmarks/cross-library/run_xbench.py \ + --qubits-tfim 8,16,24,32,40,48,56,64 \ + --qubits-heisenberg 6,8,10,12,14 \ + --steps 10 --atol 1e-6 --iters 2 --out target/xbench + +uv run --no-project --with matplotlib python3 benchmarks/cross-library/plot_xbench.py \ + --csv target/xbench/results.csv --out target/xbench/xbench.png \ + --title "Pauli propagation: ppvm vs PauliPropagation.jl, PauliStrings.jl, pauli-prop, monoprop" + +# How wrong each engine is at a given atol, rather than how fast — see +# "Known differences" below. Regenerates accuracy.csv. +uv run --no-project python3 benchmarks/cross-library/xbench_accuracy.py \ + --out benchmarks/cross-library/accuracy.csv + +# The regime where the two truncation rules actually diverge. Three sweeps make +# up accuracy_divergence.csv — depth, angle, and a scrambling circuit with small +# angles and great depth (plus a θ=π/4 control on the same family). +A=benchmarks/cross-library/xbench_accuracy.py +uv run --no-project python3 $A --models heisenberg --libs ppvm,monoprop \ + --qubits 6 --dt 0.05 --steps 10,20,40,80,160,320 --atols 1e-3,1e-5 --out d.csv +uv run --no-project python3 $A --models heisenberg --libs ppvm,monoprop \ + --qubits 8 --steps 10 --dt 0.02,0.05,0.1,0.2,0.4,0.6,0.785 --atols 1e-3 --out a.csv +uv run --no-project python3 $A --models scramble --libs ppvm,monoprop --qubits 8 \ + --steps 400 --dt 0.05 --seeds 1,2,3,4,5 --atols 1e-3,1e-4,1e-5 --out s.csv + +# Four panels: atol scaling, the depth trend, the angle reversal, the scrambler. +uv run --no-project --with matplotlib python3 benchmarks/cross-library/plot_accuracy.py \ + --csv benchmarks/cross-library/accuracy.csv \ + --divergence benchmarks/cross-library/accuracy_divergence.csv \ + --out target/xbench/accuracy.png +``` + +A subset, if you prefer: `--libs ppvm,monoprop`. + +## What is measured + +Both workloads are **Heisenberg-picture propagation of an observable through an +explicit first-order Trotter product of Pauli rotations**, with a +coefficient-magnitude truncation after every gate. Everything below is fixed +across all five engines — the gate list, its order, the angles, the truncation +rule, and the readout. + +### `tfim` — transverse-field Ising, magnetization + +`H = J Σᵢ ZᵢZᵢ₊₁ + h Σᵢ Xᵢ` on an open chain. One step, in this order: + +1. `RX(2h·dt)` on site `0, 1, …, n−1` +2. `RZZ(2J·dt)` on bond `(0,1), (1,2), …, (n−2,n−1)` + +Observable `O = Σᵢ Zᵢ`; readout `⟨0…0|O(t)|0…0⟩`, i.e. the sum of the +coefficients of the X-free terms. + +### `heisenberg` — isotropic Heisenberg + field, autocorrelator + +`H = J Σᵢ (XᵢXᵢ₊₁ + YᵢYᵢ₊₁ + ZᵢZᵢ₊₁) + h Σᵢ Zᵢ`. One step, in this order: + +1. `RXX`, `RYY`, `RZZ` at `2J·dt` on bond `(0,1)`, then `(1,2)`, … +2. `RZ(2h·dt)` on site `0, 1, …, n−1` + +Observable `O = Z₀`; readout the autocorrelator `S(t) = tr[Z₀·O(t)]/2ⁿ`, which +is just the coefficient of `Z₀` (the Paulis are orthonormal under that pairing). + +### `scramble` — random all-to-all rotations, autocorrelator + +`steps·n` two-qubit Pauli rotations `exp(−iθ/2·Pₐ⊗P_b)`, each drawing a uniformly +random **all-to-all** pair `a≠b`, random axes `Pₐ, P_b ∈ {X,Y,Z}` and a random +angle `θ ∈ (0, 2J·dt]`. Observable and readout as for `heisenberg`. + +Only `ppvm` and `monoprop` implement it (`--libs ppvm,monoprop`); it exists +because the two Trotter models are a weak test bed for *truncation* questions. +They are nearest-neighbour, uniform-angle and conserve enough structure that +their support saturates a symmetry sector — at `n=8` Heisenberg reaches exactly +16 384 of 65 536 words, and the other 49 152 are zero by symmetry rather than by +dynamics. `scramble` has no lattice, no conserved quantity and no repeated angle: +at `n=8` it fills all 65 535 non-identity words, and since unitary conjugation +preserves the Frobenius norm the whole coefficient vector has `‖c‖₂ = 1` spread +across them. That makes it the right instance for asking whether a truncation +rule can resolve one coefficient against a scrambled background. + +Both runners generate the circuit from a **splitmix64 stream reimplemented +identically in Rust and Python**, seeded by `SEED`. Neither language's stdlib RNG +is specified tightly enough to rely on, and the draw order (pair, offset, axis, +axis, angle) has to match exactly — including the `b = (a+1+r mod n−1) mod n` +trick, which avoids a rejection loop that would consume a variable number of +draws and desynchronise the two streams. The term-for-term dump diff is what +enforces this; it catches any divergence immediately. + +`θ = 2·c·dt` for a Hamiltonian term `c·G` is the convention four of the five +engines use directly for `exp(iθ/2·G)·P·exp(−iθ/2·G)`, so the propagated +operators are identical, not merely similar. monoprop is the exception and needs +a conversion; see below. + +## The parameter contract + +Every runner reads the same environment variables and writes the same CSV, so +they can be run directly as well as through the driver: + +| variable | meaning | +|---|---| +| `MODEL` | `tfim`, `heisenberg`, or `scramble` (`ppvm` and `monoprop` only) | +| `QUBITS` | comma-separated widths | +| `STEPS` | Trotter steps | +| `DT`, `JCOUP`, `HFIELD` | `dt`, `J`, `h` | +| `ATOL` | truncation threshold on `|c|` | +| `ITERS` | timed repeats; the **minimum** is reported | +| `SEED` | `scramble` only — the circuit seed | +| `DUMP` | print the propagated support instead of timing it | +| `MAX_TERMS` | `pauli-prop` only — its mandatory cap (see below) | +| `monoprop_NUM_THREADS`, `monoprop_PARTITIONS` | `monoprop` only — the thread cap (see below) | + +CSV columns: `model,library,qubits,steps,dt,atol,time_s,terms,observable`. + +```bash +MODEL=heisenberg QUBITS=8,10,12 STEPS=10 ATOL=1e-6 ITERS=2 \ + cargo run --release -p ppvm-pauli-sum --example xbench +``` + +## Validation — why the driver refuses to time first + +`run_xbench.py` runs every engine with `DUMP=1` at `n=4, steps=3, atol=1e-14` +and diffs the whole propagated support against `ppvm`'s, term for term. It +aborts on any missing term, extra term, or coefficient difference above +`--validate-tol` (1e-10 by default). + +This is not ceremony. Three real bugs in this harness produced numbers that +looked completely reasonable and were wrong: + +* **The `pauli-prop` circuit was reversed.** In the Heisenberg frame it + conjugates from the *end* of the instruction list backwards, so the spec's + gate order needs `reversed()` on append. Appending forward propagates a + different operator — 108 terms against TFIM's 124, 61 against Heisenberg's 64, + coefficients off by up to 0.1 — while the readout still matched to 9 digits. +* **Duplicate Paulis were silently collapsing.** `propagate_through_circuit` + returns a `SparsePauliOp` that may list the same Pauli more than once, so + `len(op)` is a row count, not a support size, and a `{label: coeff}` dict + comprehension keeps the last duplicate instead of summing them. +* **monoprop's angle convention is neither the spec's nor Qiskit's.** Its + `ExpGate` applies `exp(+iθH)`, so the spec's `exp(−iθ_spec/2·G)` needs + `θ = −θ_spec/2`, *and* the gate list needs reversing like `pauli-prop`'s. All + eight sign/order combinations were tried against the reference: forward order + loses terms outright (109 against TFIM's 124), and every wrong angle keeps the + right support while moving coefficients by up to 1.3. Only `θ = −θ_spec/2` on + a reversed list lands within 5e-13. + +An observable is one scalar and can agree by luck or by cancellation. A +term-for-term diff cannot. + +## monoprop is parallel unless you stop it + +This is the one caveat that changes a headline number rather than a decimal, so +it gets its own section. + +monoprop takes **one serial partition per physical core** when left alone: with a +single MPI rank, `resolve_partition_count_` reads the core count and fans out. +The PyPI wheels are built without MPI (`monoprop.has_mpi == False`), which is +easy to misread as "therefore serial" — it is not. On the 14-core machine below, +Heisenberg at `n=12` measures: + +| | wall | CPU (user+sys) | CPU/wall | +|---|---:|---:|---:| +| default | 0.66 s | 6.28 s | 9.48× | +| `monoprop_NUM_THREADS=1 monoprop_PARTITIONS=off` | 1.97 s | 1.97 s | 1.00× | + +So an uncapped monoprop reports a **3× faster** wall time than the serial one, +against engines that never had the option. Both variables are read once into a +cached C++ static, so they must be in the environment before the first +propagation — the runner sets them itself as well as receiving them from the +driver. + +Because a stale environment variable fails silently, `xbench_monoprop.py` +**measures its own CPU/wall ratio around the timed region and exits non-zero** if +it exceeds `CPU_WALL_MAX` (1.5). Every monoprop row in the run below reported +`cpu/wall=1.00`. + +## Known differences between the engines + +These are real and are the reason the plot shows the **workload size** next to +the runtime. Read them before quoting a ratio. + +* **`pauli-prop` and `monoprop` truncate by a different rule.** Both prune at + branch-creation time, where the other three accumulate first and drop the + merged coefficient. `ppvm`, PauliPropagation.jl and PauliStrings.jl then agree + on the support **exactly**, at every width, on both models; the other two do + not. On the sweep below `pauli-prop` runs 11–36 % *under* the reference support + on TFIM (the gap widening with `n`) and 4–15 % *over* it on Heisenberg; + `monoprop` is within 1 % on TFIM and 4–12 % over on Heisenberg. The driver + prints a `note:` line for any engine more than 2 % off, and the plot's third + panel is there so the ratio is never read without it. +* **Prune-at-creation costs a little accuracy, not an order of it.** Measured as + the `L2` distance of the whole coefficient vector from a converged reference + (`ppvm` at `atol=1e-16`; the Heisenberg `n=8` sector saturates at 16 384 terms, + so that reference is exact), across `atol` from `1e-3` to `1e-7` at `n=8`, + `steps=10`: `monoprop` lands within 1.00–1.12× of `ppvm`'s error and + `pauli-prop` at 1.23–2.60×. Error falls by a decade for every decade of `atol` + in all five engines, so the two policies share a convergence order and differ + only in the constant. Don't read this off the `observable` column instead — it + is one scalar and its truncation error changes sign, so `ppvm` on TFIM goes + `7.7e-4, 1.8e-3, 1.0e-4, 3.0e-7, 1.2e-6` over those five thresholds. That dip + is a cancellation, and comparing against it manufactures a 27× gap where the + vector norm shows 6 %. `xbench_accuracy.py` writes both, for that reason; + [`accuracy.csv`](accuracy.csv) is the run these figures come from. +* **`monoprop` tracks more rows than it reports.** It retains monomials whose + coefficient has cancelled to exactly zero. This is the visible end of + prune-at-creation: a branch that cleared the threshold when it was emitted is + never re-tested after later contributions cancel it. At Heisenberg `n=8`, + `atol=1e-4` it holds 2 074 terms whose converged value is below `atol`, against + `ppvm`'s 1 107 — so roughly 7 % of its support is dead weight. That is a cost + in work rather than in accuracy; the coefficients it keeps are still right. Its `size()` at Heisenberg `n=14` is + 7 355 928 rows against the 3 204 697 terms above threshold — 2.3× — while on + TFIM the two are within 1 %. The `terms` column is the above-threshold support, + which is what the other four engines mean by it; `size()` goes to stderr next + to each row. +* **`pauli-prop` also has a mandatory `max_terms` cap** with pre-allocation. The + runner defaults it to `2²²` and **fails the run** if the support ever reaches + it, rather than quietly reporting a differently-truncated number. +* **`monoprop` has a mandatory Pauli-weight `cutoff`**, which the others have no + analogue of. The runner sets it to `n` — the whole register — so it never binds + and `lower_atol` is the only truncation in play. +* **PauliStrings.jl stores `im^{#Y}` inside the coefficient** (its `Matrix` + convention). The dump goes through `op_to_strings`, which puts its + coefficients on the same real footing as everyone else's. +* **PauliStrings.jl's own front door is `evolve(H, O, tspan; method=Trotter())`, + which we do not use** — it derives the gate list from the Hamiltonian's + internal string order, which is not the spec's order. The runner builds the + `TrotterGate` vector by hand instead. +* **Julia is timed after a warm-up run** so the reported time excludes JIT. +* **Circuit construction is outside the timed region for every engine.** + monoprop's `propagate` re-expands its gate list internally on each call, which + no flag hoists out; measured at 0.0–2.2 % of its total at these widths, so it + is left in rather than worked around. +* Everything is single-threaded (`julia -t1`; `pauli-prop` is single-threaded by + design; `monoprop` is capped as above; `ppvm` here uses no Rayon). + +### Where the two truncation rules actually diverge + +Worth writing down, because the intuitive guesses about this are wrong and the +scalar `observable` will mislead you about all of them. + +A Pauli rotation sends `Q → cos θ·Q + sin θ·(iPQ)`, so a word receives +contributions from **at most two** sources: itself, scaled by `cos`, and its +partner `PQ`, scaled by `sin`. `ppvm` thresholds that sum; `monoprop` thresholds +each contribution as it is emitted. Two consequences follow, and they set the +whole shape of the difference: + +* `monoprop` discards a child whenever `|c|·sin θ < atol` even though its parent + survives, so it loses every child of a parent in the band + `atol < |c| < atol/sin θ`. The band's width is `1/sin θ` — **set by the + rotation angle alone**, with no dependence on the shape of the coefficient + distribution. +* Because a sum of two terms can exceed its larger member by at most 2×, the + mass `ppvm` rescues is `O(atol)` per word. Both engines' errors are therefore + anchored to the same threshold and cannot be decoupled: drive `atol` low enough + that `ppvm` is accurate and `monoprop` is accurate too. + +So the gap needs three things *at once*: small `sin θ` to widen the band, depth +to populate it and compound the loss, and a scrambled instance so the loss shows +up in the answer instead of averaging out. Missing any one of them, the gap +collapses to 1.0–1.2× or reverses. Measured, at `n=8`: + +| circuit | `atol` | `monoprop` error / `ppvm` error | +|---|---|---| +| `scramble`, `dt=0.05` (`θ ≤ 0.1`), 3 200 gates | `1e-4` | **2.96×** (2.89–3.07× over 10 seeds) | +| `heisenberg`, `dt=0.05`, 320 steps | `1e-3` | 2.77× | +| `tfim`/`heisenberg`, `dt=0.1`, converged `atol` | `≤1e-5` | 1.02–1.15× | +| `scramble`, `dt=π/4` (random `θ ≤ π/2`), 80 gates | `1e-3…1e-5` | 0.85–1.00× | +| `heisenberg`, `dt=0.6` | `1e-3` | **0.67×** | + +[`accuracy_divergence.csv`](accuracy_divergence.csv) holds these sweeps, and +`plot_accuracy.py` renders them as four panels — the `atol` scaling, the depth +trend, the angle reversal, and the scrambler beside its control. + +The last two rows are the ones that catch people out. **Large angles favour +`monoprop`**, by up to 1.5×, because that is where `ppvm`'s rule has its own +failure mode: `truncate()` after each gate permanently deletes a term whose +merged coefficient has transiently cancelled below `atol`, where `monoprop` keeps +the row and lets later gates revive it. Uniform amplitudes do not help either — +`θ = π/4` is where the distribution is flattest (`sd(log|c|)` 0.66 against 2.23 +at `dt=0.1`) and it is also `monoprop`'s *best* regime, since `sin θ = 0.707` +makes the rejection band 1.41× wide, the narrowest possible. Flat amplitudes and +a wide band are mutually exclusive: you get the band from small angles, and small +angles force the geometric spread that makes amplitudes non-uniform. + +None of this is visible in the `observable` column. On the `scramble` instance +above the peak-error ratio has a median of 4.2× but a range of 0.1–576×, because +a single scalar's truncation error changes sign; the 576× is `ppvm` landing +accidentally near-exact on one seed. Neither engine loses the signal there — +relative errors are 0.4 % and 2.6 % against a peak of 0.16. + +## A run + +Apple M4 Pro (10 P + 4 E cores), macOS 26.6, rustc 1.96.0, Julia 1.12.6, +monoprop 0.8.0, PauliStrings.jl 1.10.1; single-threaded, `--steps 10 --dt 0.1 +--j 1 --h 1 --atol 1e-6 --iters 2`. Runtime relative to `ppvm`; **below 1.00× +means that engine beat `ppvm`**. `terms` is the shared support the three +accumulate-then-truncate engines all reach exactly. + +`tfim`, widths 8…64: + +| n | terms | ppvm | PauliPropagation.jl | PauliStrings.jl | pauli-prop | monoprop | +|---:|---:|---:|---:|---:|---:|---:| +| 8 | 4 701 | 0.0028 s | 2.42× | 4.90× | 11.13× | 0.98× | +| 16 | 19 529 | 0.0122 s | 3.10× | 6.26× | 5.53× | 0.69× | +| 24 | 34 353 | 0.0260 s | 5.78× | 8.05× | 5.32× | 0.54× | +| 32 | 49 177 | 0.0431 s | 4.98× | 8.39× | 7.05× | 0.45× | +| 40 | 64 001 | 0.0657 s | 6.33× | 9.20× | 5.28× | 0.43× | +| 48 | 78 825 | 0.0899 s | 7.32× | 9.02× | 5.37× | 0.40× | +| 56 | 93 649 | 0.1188 s | 10.46× | 9.84× | 5.33× | 0.35× | +| 64 | 108 473 | 0.1429 s | 11.26× | 13.18× | 5.71× | 0.33× | + +`heisenberg`, widths 6…14: + +| n | terms | ppvm | PauliPropagation.jl | PauliStrings.jl | pauli-prop | monoprop | +|---:|---:|---:|---:|---:|---:|---:| +| 6 | 1 022 | 0.0028 s | 4.28× | 6.77× | 5.02× | 1.03× | +| 8 | 16 324 | 0.0505 s | 3.43× | 6.26× | 2.02× | 0.83× | +| 10 | 225 353 | 0.5290 s | 3.11× | 6.79× | 1.51× | 0.77× | +| 12 | 1 174 849 | 2.7528 s | 2.59× | 6.50× | 1.13× | 0.67× | +| 14 | 2 915 879 | 6.0906 s | 2.23× | 7.09× | 0.88× | 0.54× | + +### Reading it + +**`ppvm` beats both Julia engines everywhere**, by 2.2–11.3× against +PauliPropagation.jl and 4.9–13.2× against PauliStrings.jl, and the margin widens +with `n` on TFIM. Those three carry an identical support term-for-term, so those +are clean ratios. + +**`ppvm` loses to `monoprop`** on both models — by up to 3.0× on TFIM at `n=64` +and 1.9× on Heisenberg at `n=14`, with the gap widening in `n` on both. On TFIM +the two carry the same support to within 1 %, so that column is a clean loss. On +Heisenberg `monoprop` is doing 4–12 % *more* above-threshold work than `ppvm` and +still finishing sooner, so the gap there is if anything understated — though it +is also tracking 2.3× as many rows in total, so the two engines are not making +the same space/time trade. + +**`pauli-prop` is 5.3–11.1× slower on TFIM but competitive on Heisenberg**, +reaching 0.88× at `n=14`. Read its column against the workload note: on TFIM at +`n=64` it is propagating 36 % fewer terms than the reference three, and on +Heisenberg `n=10`–`12` about 15 % more. + +## Scaling note + +The TFIM support grows roughly linearly in `n` at fixed depth, so that sweep +reaches 64 qubits cheaply. The Heisenberg support grows much faster — three +non-commuting bond rotations per bond per step — and the `Z₀` autocorrelator +itself converges once `n` exceeds the light cone (identical from `n≈10` at +`steps=10`), so widths past that measure scaling rather than new physics. + +## Files + +- `../../crates/ppvm-pauli-sum/examples/xbench.rs` — the `ppvm` runner. +- `../../julia-benchmarks/benches/xbench_pp.jl` — PauliPropagation.jl. +- `../../julia-benchmarks/benches/xbench_ps.jl` — PauliStrings.jl. +- `xbench_qiskit.py` — `pauli-prop`. +- `xbench_monoprop.py` — `monoprop`, including the thread-cap assertion. +- `run_xbench.py` — validation, driving, CSV merge, summary table. +- `plot_xbench.py` — the figure. +- `xbench_accuracy.py` — the sweeps behind `accuracy.csv` (all five engines vs + `atol`) and `accuracy_divergence.csv` (depth, angle, and the scrambler): each + engine's coefficient vector against a converged reference, so the timings can + be read next to what each truncation rule costs. Sweeps any of `--models`, + `--steps`, `--dt`, `--seeds`, `--atols` as comma-separated lists. +- `plot_accuracy.py` — the four-panel error-scaling figure from those two CSVs. + +[pp]: https://github.com/MSRudolph/PauliPropagation.jl +[ps]: https://github.com/nicolasloizeau/PauliStrings.jl +[qk]: https://github.com/Qiskit/pauli-prop +[mp]: https://github.com/Algorithmiq/monoprop diff --git a/benchmarks/cross-library/accuracy.csv b/benchmarks/cross-library/accuracy.csv new file mode 100644 index 000000000..4b2f3cbbd --- /dev/null +++ b/benchmarks/cross-library/accuracy.csv @@ -0,0 +1,51 @@ +model,library,qubits,steps,dt,seed,atol,ref_atol,ref_terms,ref_norm,terms,l2_err,l1_err,max_coeff_err,lost_mass,kept_subthreshold,dropped_above_atol,observable,ref_observable,obs_abs_err +tfim,ppvm,8,10,0.1,12345,1e-3,1e-16,27051,2.828427e+00,834,5.730178e-02,1.956886e+00,6.556158e-03,1.180394e+00,0,388,1.889668769617,1.888902654574,7.661150e-04 +tfim,pauli-propagation-jl,8,10,0.1,12345,1e-3,1e-16,27051,2.828427e+00,834,5.730178e-02,1.956886e+00,6.556158e-03,1.180394e+00,0,388,1.88966876961681,1.888902654574,7.661150e-04 +tfim,pauli-strings-jl,8,10,0.1,12345,1e-3,1e-16,27051,2.828427e+00,834,5.730178e-02,1.956886e+00,6.556158e-03,1.180394e+00,0,388,1.88966876961681,1.888902654574,7.661150e-04 +tfim,pauli-prop,8,10,0.1,12345,1e-3,1e-16,27051,2.828427e+00,743,9.747655e-02,3.201066e+00,8.889589e-03,1.519868e+00,0,479,1.9013036238629,1.888902654574,1.240097e-02 +tfim,monoprop,8,10,0.1,12345,1e-3,1e-16,27051,2.828427e+00,804,6.429517e-02,2.126366e+00,6.563651e-03,1.286381e+00,0,418,1.895370105228,1.888902654574,6.467451e-03 +tfim,ppvm,8,10,0.1,12345,1e-4,1e-16,27051,2.828427e+00,1730,7.713204e-03,3.399904e-01,5.538029e-04,1.657662e-01,0,558,1.887148304814,1.888902654574,1.754350e-03 +tfim,pauli-propagation-jl,8,10,0.1,12345,1e-4,1e-16,27051,2.828427e+00,1730,7.713204e-03,3.399904e-01,5.538029e-04,1.657662e-01,0,558,1.88714830481379,1.888902654574,1.754350e-03 +tfim,pauli-strings-jl,8,10,0.1,12345,1e-4,1e-16,27051,2.828427e+00,1730,7.713204e-03,3.399904e-01,5.538029e-04,1.657662e-01,0,558,1.88714830481379,1.888902654574,1.754350e-03 +tfim,pauli-prop,8,10,0.1,12345,1e-4,1e-16,27051,2.828427e+00,1527,1.655369e-02,6.909180e-01,1.396127e-03,2.516797e-01,0,761,1.89026934442961,1.888902654574,1.366690e-03 +tfim,monoprop,8,10,0.1,12345,1e-4,1e-16,27051,2.828427e+00,1705,8.349062e-03,3.553170e-01,6.351504e-04,1.787774e-01,1,584,1.888182135464,1.888902654574,7.205191e-04 +tfim,ppvm,8,10,0.1,12345,1e-5,1e-16,27051,2.828427e+00,3001,9.762681e-04,5.198656e-02,5.615238e-05,2.218986e-02,0,715,1.888799248154,1.888902654574,1.034064e-04 +tfim,pauli-propagation-jl,8,10,0.1,12345,1e-5,1e-16,27051,2.828427e+00,3001,9.762681e-04,5.198656e-02,5.615238e-05,2.218986e-02,0,715,1.88879924815445,1.888902654574,1.034064e-04 +tfim,pauli-strings-jl,8,10,0.1,12345,1e-5,1e-16,27051,2.828427e+00,3001,9.762681e-04,5.198656e-02,5.615238e-05,2.218986e-02,0,715,1.88879924815445,1.888902654574,1.034064e-04 +tfim,pauli-prop,8,10,0.1,12345,1e-5,1e-16,27051,2.828427e+00,2628,2.357978e-03,1.202946e-01,1.384898e-04,3.877258e-02,0,1088,1.88885571834255,1.888902654574,4.693623e-05 +tfim,monoprop,8,10,0.1,12345,1e-5,1e-16,27051,2.828427e+00,2998,1.023503e-03,5.342297e-02,5.628422e-05,2.245608e-02,0,718,1.888873511516,1.888902654574,2.914306e-05 +tfim,ppvm,8,10,0.1,12345,1e-6,1e-16,27051,2.828427e+00,4701,1.118273e-04,6.914840e-03,7.337651e-06,2.638321e-03,0,875,1.888902953057,1.888902654574,2.984830e-07 +tfim,pauli-propagation-jl,8,10,0.1,12345,1e-6,1e-16,27051,2.828427e+00,4701,1.118273e-04,6.914840e-03,7.337651e-06,2.638321e-03,0,875,1.88890295305684,1.888902654574,2.984828e-07 +tfim,pauli-strings-jl,8,10,0.1,12345,1e-6,1e-16,27051,2.828427e+00,4701,1.118273e-04,6.914840e-03,7.337651e-06,2.638321e-03,0,875,1.88890295305684,1.888902654574,2.984828e-07 +tfim,pauli-prop,8,10,0.1,12345,1e-6,1e-16,27051,2.828427e+00,4161,2.838847e-04,1.707832e-02,1.911237e-05,5.130690e-03,0,1415,1.88893188165528,1.888902654574,2.922708e-05 +tfim,monoprop,8,10,0.1,12345,1e-6,1e-16,27051,2.828427e+00,4662,1.182467e-04,7.215810e-03,7.389538e-06,2.782631e-03,1,915,1.888910840223,1.888902654574,8.185649e-06 +tfim,ppvm,8,10,0.1,12345,1e-7,1e-16,27051,2.828427e+00,6780,1.239548e-05,8.753233e-04,7.429950e-07,2.963253e-04,0,996,1.888901406583,1.888902654574,1.247991e-06 +tfim,pauli-propagation-jl,8,10,0.1,12345,1e-7,1e-16,27051,2.828427e+00,6780,1.239548e-05,8.753233e-04,7.429950e-07,2.963253e-04,0,996,1.88890140658276,1.888902654574,1.247991e-06 +tfim,pauli-strings-jl,8,10,0.1,12345,1e-7,1e-16,27051,2.828427e+00,6780,1.239548e-05,8.753233e-04,7.429950e-07,2.963253e-04,0,996,1.88890140658276,1.888902654574,1.247991e-06 +tfim,pauli-prop,8,10,0.1,12345,1e-7,1e-16,27051,2.828427e+00,6056,3.227852e-05,2.215272e-03,1.725103e-06,6.248028e-04,0,1720,1.88890165053338,1.888902654574,1.004041e-06 +tfim,monoprop,8,10,0.1,12345,1e-7,1e-16,27051,2.828427e+00,6753,1.309534e-05,9.190766e-04,7.516440e-07,3.063929e-04,1,1024,1.888902140509,1.888902654574,5.140650e-07 +heisenberg,ppvm,8,10,0.1,12345,1e-3,1e-16,16384,1.000000e+00,4566,1.935867e-01,1.683728e+01,6.612049e-03,8.467940e+00,370,2994,0.1843817263679,0.1835878942086,7.938322e-04 +heisenberg,pauli-propagation-jl,8,10,0.1,12345,1e-3,1e-16,16384,1.000000e+00,4566,1.935867e-01,1.683728e+01,6.612049e-03,8.467940e+00,370,2994,0.184381726367865,0.1835878942086,7.938322e-04 +heisenberg,pauli-strings-jl,8,10,0.1,12345,1e-3,1e-16,16384,1.000000e+00,4566,1.935867e-01,1.683728e+01,6.612049e-03,8.467940e+00,370,2994,0.184381726367865,0.1835878942086,7.938322e-04 +heisenberg,pauli-prop,8,10,0.1,12345,1e-3,1e-16,16384,1.000000e+00,5072,2.384650e-01,2.082594e+01,1.022082e-02,9.244654e+00,1051,3169,0.186064764275212,0.1835878942086,2.476870e-03 +heisenberg,monoprop,8,10,0.1,12345,1e-3,1e-16,16384,1.000000e+00,5106,1.937250e-01,1.714593e+01,6.293714e-03,8.315984e+00,836,2920,0.18421262641,0.1835878942086,6.247322e-04 +heisenberg,ppvm,8,10,0.1,12345,1e-4,1e-16,16384,1.000000e+00,11923,2.564185e-02,2.511013e+00,1.063198e-03,5.963471e-01,1107,2274,0.1834786826111,0.1835878942086,1.092116e-04 +heisenberg,pauli-propagation-jl,8,10,0.1,12345,1e-4,1e-16,16384,1.000000e+00,11923,2.564185e-02,2.511013e+00,1.063198e-03,5.963471e-01,1107,2274,0.183478682611051,0.1835878942086,1.092116e-04 +heisenberg,pauli-strings-jl,8,10,0.1,12345,1e-4,1e-16,16384,1.000000e+00,11923,2.564185e-02,2.511013e+00,1.063198e-03,5.963471e-01,1107,2274,0.183478682611051,0.1835878942086,1.092116e-04 +heisenberg,pauli-prop,8,10,0.1,12345,1e-4,1e-16,16384,1.000000e+00,13628,4.451207e-02,4.398424e+00,2.046005e-03,6.386030e-01,2836,2298,0.183655881056651,0.1835878942086,6.798685e-05 +heisenberg,monoprop,8,10,0.1,12345,1e-4,1e-16,16384,1.000000e+00,13143,2.725847e-02,2.714465e+00,1.193874e-03,5.551494e-01,2074,2021,0.183546769437,0.1835878942086,4.112477e-05 +heisenberg,ppvm,8,10,0.1,12345,1e-5,1e-16,16384,1.000000e+00,16101,2.426734e-03,2.449232e-01,1.074420e-04,1.125860e-02,963,420,0.1835902065899,0.1835878942086,2.312381e-06 +heisenberg,pauli-propagation-jl,8,10,0.1,12345,1e-5,1e-16,16384,1.000000e+00,16101,2.426734e-03,2.449232e-01,1.074420e-04,1.125860e-02,963,420,0.18359020658986,0.1835878942086,2.312381e-06 +heisenberg,pauli-strings-jl,8,10,0.1,12345,1e-5,1e-16,16384,1.000000e+00,16101,2.426734e-03,2.449232e-01,1.074420e-04,1.125860e-02,963,420,0.183590206589861,0.1835878942086,2.312381e-06 +heisenberg,pauli-prop,8,10,0.1,12345,1e-5,1e-16,16384,1.000000e+00,17803,4.507725e-03,4.730143e-01,1.675695e-04,1.250100e-02,2677,432,0.183584912095183,0.1835878942086,2.982113e-06 +heisenberg,monoprop,8,10,0.1,12345,1e-5,1e-16,16384,1.000000e+00,17223,2.557524e-03,2.663380e-01,1.096506e-04,9.925007e-03,2027,362,0.183585575353,0.1835878942086,2.318856e-06 +heisenberg,ppvm,8,10,0.1,12345,1e-6,1e-16,16384,1.000000e+00,16324,1.873617e-04,1.844112e-02,8.671387e-06,1.957258e-04,191,82,0.1835880798476,0.1835878942086,1.856390e-07 +heisenberg,pauli-propagation-jl,8,10,0.1,12345,1e-6,1e-16,16384,1.000000e+00,16324,1.873617e-04,1.844112e-02,8.671387e-06,1.957258e-04,191,82,0.183588079847589,0.1835878942086,1.856390e-07 +heisenberg,pauli-strings-jl,8,10,0.1,12345,1e-6,1e-16,16384,1.000000e+00,16324,1.873617e-04,1.844112e-02,8.671387e-06,1.957258e-04,191,82,0.183588079847589,0.1835878942086,1.856390e-07 +heisenberg,pauli-prop,8,10,0.1,12345,1e-6,1e-16,16384,1.000000e+00,17004,3.764296e-04,3.802784e-02,1.821500e-05,2.767212e-04,894,105,0.183587851042604,0.1835878942086,4.316600e-08 +heisenberg,monoprop,8,10,0.1,12345,1e-6,1e-16,16384,1.000000e+00,16910,1.993482e-04,2.007446e-02,1.047708e-05,1.937806e-04,774,79,0.183587892322,0.1835878942086,1.886600e-09 +heisenberg,ppvm,8,10,0.1,12345,1e-7,1e-16,16384,1.000000e+00,16377,1.541357e-05,1.514858e-03,7.285205e-07,3.268561e-06,39,14,0.1835878872944,0.1835878942086,6.914200e-09 +heisenberg,pauli-propagation-jl,8,10,0.1,12345,1e-7,1e-16,16384,1.000000e+00,16377,1.541357e-05,1.514858e-03,7.285205e-07,3.268561e-06,39,14,0.183587887294405,0.1835878942086,6.914195e-09 +heisenberg,pauli-strings-jl,8,10,0.1,12345,1e-7,1e-16,16384,1.000000e+00,16377,1.541357e-05,1.514858e-03,7.285205e-07,3.268561e-06,39,14,0.183587887294405,0.1835878942086,6.914195e-09 +heisenberg,pauli-prop,8,10,0.1,12345,1e-7,1e-16,16384,1.000000e+00,16528,3.026389e-05,3.008580e-03,1.430835e-06,5.571167e-06,195,19,0.18358789229432,0.1835878942086,1.914280e-09 +heisenberg,monoprop,8,10,0.1,12345,1e-7,1e-16,16384,1.000000e+00,16558,1.539943e-05,1.536256e-03,8.884821e-07,3.199623e-06,217,11,0.183587893774,0.1835878942086,4.346000e-10 diff --git a/benchmarks/cross-library/accuracy_divergence.csv b/benchmarks/cross-library/accuracy_divergence.csv new file mode 100644 index 000000000..669426026 --- /dev/null +++ b/benchmarks/cross-library/accuracy_divergence.csv @@ -0,0 +1,99 @@ +model,library,qubits,steps,dt,seed,atol,ref_atol,ref_terms,ref_norm,terms,l2_err,l1_err,max_coeff_err,lost_mass,kept_subthreshold,dropped_above_atol,observable,ref_observable,obs_abs_err +heisenberg,ppvm,6,10,0.05,12345,1e-3,1e-16,1024,1.000000e+00,222,4.036339e-02,8.303254e-01,3.634578e-03,4.669530e-01,0,155,0.3832181169286,0.3822563306389,9.617863e-04 +heisenberg,monoprop,6,10,0.05,12345,1e-3,1e-16,1024,1.000000e+00,224,4.214458e-02,8.422781e-01,4.098614e-03,4.650453e-01,2,155,0.383158101684,0.3822563306389,9.017710e-04 +heisenberg,ppvm,6,10,0.05,12345,1e-5,1e-16,1024,1.000000e+00,826,5.404658e-04,1.318947e-02,6.420822e-05,1.748059e-03,18,76,0.3822591781592,0.3822563306389,2.847520e-06 +heisenberg,monoprop,6,10,0.05,12345,1e-5,1e-16,1024,1.000000e+00,906,5.160546e-04,1.273802e-02,6.591156e-05,1.255044e-03,74,52,0.382257798983,0.3822563306389,1.468344e-06 +heisenberg,ppvm,6,20,0.05,12345,1e-3,1e-16,1024,1.000000e+00,866,1.070213e-01,2.716866e+00,1.192541e-02,4.248833e-01,90,130,0.1859379068078,0.1853914774626,5.464293e-04 +heisenberg,monoprop,6,20,0.05,12345,1e-3,1e-16,1024,1.000000e+00,993,1.252160e-01,3.207887e+00,1.363090e-02,3.507914e-01,180,93,0.185871276693,0.1853914774626,4.797992e-04 +heisenberg,ppvm,6,20,0.05,12345,1e-5,1e-16,1024,1.000000e+00,1039,6.429122e-04,1.651200e-02,7.105414e-05,1.100133e-04,20,3,0.1854168229734,0.1853914774626,2.534551e-05 +heisenberg,monoprop,6,20,0.05,12345,1e-5,1e-16,1024,1.000000e+00,1167,7.136553e-04,1.958904e-02,7.192523e-05,1.181164e-04,149,4,0.185416479607,0.1853914774626,2.500214e-05 +heisenberg,ppvm,6,40,0.05,12345,1e-3,1e-16,1024,1.000000e+00,1177,1.474150e-01,4.066484e+00,1.613299e-02,2.478436e-01,239,49,0.1284102386314,0.1308659700642,2.455731e-03 +heisenberg,monoprop,6,40,0.05,12345,1e-3,1e-16,1024,1.000000e+00,1430,2.353190e-01,7.134715e+00,2.271986e-02,2.480553e-01,486,43,0.130211985822,0.1308659700642,6.539842e-04 +heisenberg,ppvm,6,40,0.05,12345,1e-5,1e-16,1024,1.000000e+00,1024,6.308929e-04,1.575149e-02,7.857540e-05,0,0,0,0.130870034274,0.1308659700642,4.064210e-06 +heisenberg,monoprop,6,40,0.05,12345,1e-5,1e-16,1024,1.000000e+00,1099,6.908877e-04,1.833722e-02,6.336108e-05,0,75,0,0.130868964327,0.1308659700642,2.994263e-06 +heisenberg,ppvm,6,80,0.05,12345,1e-3,1e-16,1024,1.000000e+00,1236,1.646609e-01,4.623798e+00,2.112876e-02,1.582509e-01,268,34,0.08887939929655,0.09030014507815,1.420746e-03 +heisenberg,monoprop,6,80,0.05,12345,1e-3,1e-16,1024,1.000000e+00,1638,3.087508e-01,9.415847e+00,3.496292e-02,2.588310e-01,675,39,0.075129187818,0.09030014507815,1.517096e-02 +heisenberg,ppvm,6,80,0.05,12345,1e-5,1e-16,1024,1.000000e+00,1024,6.290098e-04,1.561913e-02,7.908085e-05,0,0,0,0.09032734267639,0.09030014507815,2.719760e-05 +heisenberg,monoprop,6,80,0.05,12345,1e-5,1e-16,1024,1.000000e+00,1035,6.629870e-04,1.658641e-02,7.977269e-05,0,11,0,0.090328492789,0.09030014507815,2.834771e-05 +heisenberg,ppvm,6,160,0.05,12345,1e-3,1e-16,1024,1.000000e+00,1213,1.813172e-01,5.016238e+00,2.172605e-02,1.693484e-01,246,25,0.09555696196697,0.09803624235248,2.479280e-03 +heisenberg,monoprop,6,160,0.05,12345,1e-3,1e-16,1024,1.000000e+00,1691,4.000477e-01,1.195438e+01,4.531704e-02,3.583095e-01,735,36,0.118446290328,0.09803624235248,2.041005e-02 +heisenberg,ppvm,6,160,0.05,12345,1e-5,1e-16,1024,1.000000e+00,1024,6.334569e-04,1.596263e-02,6.675496e-05,0,0,0,0.09802627447504,0.09803624235248,9.967877e-06 +heisenberg,monoprop,6,160,0.05,12345,1e-5,1e-16,1024,1.000000e+00,1022,6.520457e-04,1.635314e-02,7.891152e-05,7.021406e-05,0,2,0.098031933021,0.09803624235248,4.309331e-06 +heisenberg,ppvm,6,320,0.05,12345,1e-3,1e-16,1024,1.000000e+00,1235,2.375216e-01,6.534776e+00,2.462977e-02,3.085507e-01,275,46,0.2136063818482,0.2171604004367,3.554019e-03 +heisenberg,monoprop,6,320,0.05,12345,1e-3,1e-16,1024,1.000000e+00,1787,6.576496e-01,1.928416e+01,7.418257e-02,7.182283e-01,821,40,0.192691392802,0.2171604004367,2.446901e-02 +heisenberg,ppvm,6,320,0.05,12345,1e-5,1e-16,1024,1.000000e+00,1024,6.369157e-04,1.611839e-02,7.839926e-05,0,0,0,0.2171604704937,0.2171604004367,7.005700e-08 +heisenberg,monoprop,6,320,0.05,12345,1e-5,1e-16,1024,1.000000e+00,1024,6.487131e-04,1.640341e-02,7.181142e-05,0,0,0,0.217165017466,0.2171604004367,4.617029e-06 +heisenberg,ppvm,8,10,0.02,12345,1e-3,1e-16,16561,1.000000e+00,27,1.365602e-02,1.124382e-01,4.679180e-03,6.805587e-02,0,26,0.8522590829718,0.8522087425321,5.034044e-05 +heisenberg,monoprop,8,10,0.02,12345,1e-3,1e-16,16561,1.000000e+00,25,1.452084e-02,1.197392e-01,5.736855e-03,7.172758e-02,0,28,0.852274606892,0.8522087425321,6.586436e-05 +heisenberg,ppvm,8,10,0.05,12345,1e-3,1e-16,16384,1.000000e+00,222,4.577082e-02,1.518473e+00,3.673186e-03,1.197095e+00,0,263,0.3832181169286,0.3822563787026,9.617382e-04 +heisenberg,monoprop,8,10,0.05,12345,1e-3,1e-16,16384,1.000000e+00,224,4.684098e-02,1.525291e+00,3.921037e-03,1.195189e+00,2,263,0.383158101684,0.3822563787026,9.017230e-04 +heisenberg,ppvm,8,10,0.1,12345,1e-3,1e-16,16384,1.000000e+00,4566,1.935867e-01,1.683728e+01,6.612049e-03,8.467940e+00,370,2994,0.1843817263679,0.1835878942086,7.938322e-04 +heisenberg,monoprop,8,10,0.1,12345,1e-3,1e-16,16384,1.000000e+00,5106,1.937250e-01,1.714593e+01,6.293714e-03,8.315984e+00,836,2920,0.18421262641,0.1835878942086,6.247322e-04 +heisenberg,ppvm,8,10,0.2,12345,1e-3,1e-16,16384,1.000000e+00,9617,3.392238e-01,3.496440e+01,1.721673e-02,1.503669e+01,2670,5303,0.1206720275243,0.1197752470608,8.967805e-04 +heisenberg,monoprop,8,10,0.2,12345,1e-3,1e-16,16384,1.000000e+00,9516,3.656091e-01,3.825462e+01,1.981698e-02,1.708788e+01,3084,5818,0.119257133464,0.1197752470608,5.181136e-04 +heisenberg,ppvm,8,10,0.4,12345,1e-3,1e-16,16384,1.000000e+00,12275,3.954437e-01,4.123504e+01,1.592698e-02,1.312605e+01,2316,3979,0.06411724002328,0.06498038532114,8.631453e-04 +heisenberg,monoprop,8,10,0.4,12345,1e-3,1e-16,16384,1.000000e+00,11524,3.180263e-01,3.235889e+01,1.186265e-02,1.048401e+01,1169,3583,0.064849564292,0.06498038532114,1.308210e-04 +heisenberg,ppvm,8,10,0.6,12345,1e-3,1e-16,16384,1.000000e+00,13027,3.794862e-01,3.972636e+01,1.750531e-02,1.173227e+01,3457,3830,0.1472159226886,0.1492943370647,2.078414e-03 +heisenberg,monoprop,8,10,0.6,12345,1e-3,1e-16,16384,1.000000e+00,12431,2.526846e-01,2.554907e+01,1.121828e-02,6.632730e+00,1625,2594,0.149259337365,0.1492943370647,3.499970e-05 +heisenberg,ppvm,8,10,0.785,12345,1e-3,1e-16,7322,1.000000e+00,1,9.422127e-03,3.564081e-02,3.185207e-03,3.561038e-02,0,14,0,5.707176610115e-06,5.707177e-06 +heisenberg,monoprop,8,10,0.785,12345,1e-3,1e-16,7322,1.000000e+00,1,9.422127e-03,3.564081e-02,3.185207e-03,3.561038e-02,0,14,0,5.707176610115e-06,5.707177e-06 +scramble,ppvm,8,400,0.05,1,1e-3,1e-16,65535,1.000000e+00,7145,9.460647e-01,1.886151e+02,2.247844e-02,1.588307e+02,1273,44679,0.1790007556962,0.1614730211106,1.752773e-02 +scramble,monoprop,8,400,0.05,1,1e-3,1e-16,65535,1.000000e+00,7923,9.619359e-01,1.897827e+02,2.840829e-02,1.554452e+02,1401,44029,0.167740351377,0.1614730211106,6.267330e-03 +scramble,ppvm,8,400,0.05,1,1e-4,1e-16,65535,1.000000e+00,64007,1.366369e-01,2.789267e+01,2.364321e-03,6.780471e-01,1332,1293,0.1614802576756,0.1614730211106,7.236565e-06 +scramble,monoprop,8,400,0.05,1,1e-4,1e-16,65535,1.000000e+00,63920,4.179348e-01,8.505128e+01,9.345567e-03,2.067460e+00,1480,1528,0.165644417085,0.1614730211106,4.171396e-03 +scramble,ppvm,8,400,0.05,1,1e-5,1e-16,65535,1.000000e+00,65388,7.801179e-03,1.593150e+00,1.427393e-04,3.874730e-03,110,119,0.1614469519445,0.1614730211106,2.606917e-05 +scramble,monoprop,8,400,0.05,1,1e-5,1e-16,65535,1.000000e+00,65376,1.940221e-02,3.961147e+00,3.854044e-04,9.243616e-03,122,143,0.161298097215,0.1614730211106,1.749239e-04 +scramble,ppvm,8,400,0.05,2,1e-3,1e-16,65535,1.000000e+00,7277,9.563637e-01,1.915102e+02,2.113910e-02,1.610994e+02,1276,44811,0.145258196215,0.141763190312,3.495006e-03 +scramble,monoprop,8,400,0.05,2,1e-3,1e-16,65535,1.000000e+00,7884,9.710104e-01,1.920922e+02,2.926046e-02,1.580419e+02,1338,44266,0.121961388302,0.141763190312,1.980180e-02 +scramble,ppvm,8,400,0.05,2,1e-4,1e-16,65535,1.000000e+00,64004,1.406716e-01,2.866367e+01,2.309150e-03,6.676462e-01,1275,1297,0.1411270619147,0.141763190312,6.361284e-04 +scramble,monoprop,8,400,0.05,2,1e-4,1e-16,65535,1.000000e+00,63950,4.073952e-01,8.292705e+01,7.614204e-03,1.971686e+00,1420,1496,0.138618759706,0.141763190312,3.144431e-03 +scramble,ppvm,8,400,0.05,2,1e-5,1e-16,65535,1.000000e+00,65400,8.667319e-03,1.771308e+00,1.364485e-04,3.740238e-03,106,96,0.1417936293827,0.141763190312,3.043907e-05 +scramble,monoprop,8,400,0.05,2,1e-5,1e-16,65535,1.000000e+00,65398,1.939419e-02,3.962640e+00,3.397129e-04,8.654144e-03,124,116,0.141898202448,0.141763190312,1.350121e-04 +scramble,ppvm,8,400,0.05,3,1e-3,1e-16,65535,1.000000e+00,7384,9.450019e-01,1.887800e+02,2.825414e-02,1.580012e+02,1296,44432,0.1524323856682,0.1460185792372,6.413806e-03 +scramble,monoprop,8,400,0.05,3,1e-3,1e-16,65535,1.000000e+00,8026,9.643916e-01,1.901604e+02,3.122063e-02,1.550255e+02,1377,43871,0.142537145171,0.1460185792372,3.481434e-03 +scramble,ppvm,8,400,0.05,3,1e-4,1e-16,65535,1.000000e+00,63904,1.404446e-01,2.871432e+01,2.399302e-03,7.247692e-01,1277,1410,0.1452846799804,0.1460185792372,7.338993e-04 +scramble,monoprop,8,400,0.05,3,1e-4,1e-16,65535,1.000000e+00,64008,4.156444e-01,8.458307e+01,8.494982e-03,1.963916e+00,1432,1461,0.140959989003,0.1460185792372,5.058590e-03 +scramble,ppvm,8,400,0.05,3,1e-5,1e-16,65535,1.000000e+00,65376,8.038709e-03,1.642135e+00,1.416798e-04,4.002692e-03,101,117,0.1460074411703,0.1460185792372,1.113807e-05 +scramble,monoprop,8,400,0.05,3,1e-5,1e-16,65535,1.000000e+00,65378,1.951992e-02,3.980961e+00,3.259493e-04,9.704331e-03,124,138,0.146049390259,0.1460185792372,3.081102e-05 +scramble,ppvm,8,400,0.05,4,1e-3,1e-16,65535,1.000000e+00,7409,9.518168e-01,1.894747e+02,2.758227e-02,1.578000e+02,1251,44408,0.1682610320682,0.1693302302423,1.069198e-03 +scramble,monoprop,8,400,0.05,4,1e-3,1e-16,65535,1.000000e+00,8156,9.682760e-01,1.903117e+02,4.164777e-02,1.539202e+02,1307,43717,0.144043912834,0.1693302302423,2.528632e-02 +scramble,ppvm,8,400,0.05,4,1e-4,1e-16,65535,1.000000e+00,63869,1.409938e-01,2.882227e+01,2.557839e-03,7.418089e-01,1298,1408,0.1686796776657,0.1693302302423,6.505526e-04 +scramble,monoprop,8,400,0.05,4,1e-4,1e-16,65535,1.000000e+00,63968,4.106727e-01,8.365478e+01,7.280307e-03,1.928183e+00,1464,1475,0.165243860705,0.1693302302423,4.086370e-03 +scramble,ppvm,8,400,0.05,4,1e-5,1e-16,65535,1.000000e+00,65376,8.336955e-03,1.701727e+00,1.487382e-04,4.134139e-03,123,126,0.1693221010763,0.1693302302423,8.129166e-06 +scramble,monoprop,8,400,0.05,4,1e-5,1e-16,65535,1.000000e+00,65368,2.026217e-02,4.130641e+00,3.527960e-04,1.150466e-02,141,152,0.169290141837,0.1693302302423,4.008841e-05 +scramble,ppvm,8,400,0.05,5,1e-3,1e-16,65535,1.000000e+00,7168,9.504922e-01,1.888251e+02,2.381869e-02,1.581427e+02,1314,44541,0.1488295179599,0.1622150514644,1.338553e-02 +scramble,monoprop,8,400,0.05,5,1e-3,1e-16,65535,1.000000e+00,8263,9.733226e-01,1.903354e+02,4.313585e-02,1.532102e+02,1398,43530,0.187629107769,0.1622150514644,2.541406e-02 +scramble,ppvm,8,400,0.05,5,1e-4,1e-16,65535,1.000000e+00,63819,1.413663e-01,2.886298e+01,2.541082e-03,7.565301e-01,1294,1459,0.1601476023761,0.1622150514644,2.067449e-03 +scramble,monoprop,8,400,0.05,5,1e-4,1e-16,65535,1.000000e+00,63888,4.233586e-01,8.599954e+01,8.111627e-03,2.104861e+00,1458,1554,0.154900547436,0.1622150514644,7.314504e-03 +scramble,ppvm,8,400,0.05,5,1e-5,1e-16,65535,1.000000e+00,65372,7.935054e-03,1.618603e+00,1.321421e-04,4.264874e-03,115,128,0.1621842282147,0.1622150514644,3.082325e-05 +scramble,monoprop,8,400,0.05,5,1e-5,1e-16,65535,1.000000e+00,65383,1.933189e-02,3.942074e+00,3.162846e-04,9.839686e-03,141,143,0.162116789286,0.1622150514644,9.826218e-05 +scramble,ppvm,8,10,0.7853981633974483,1,1e-3,1e-16,65535,1.000000e+00,50657,3.090565e-01,6.278704e+01,5.755294e-03,1.610769e+01,5920,6774,0.002947642913682,0.004567937262123,1.620294e-03 +scramble,monoprop,8,10,0.7853981633974483,1,1e-3,1e-16,65535,1.000000e+00,49970,2.637431e-01,5.349200e+01,5.279884e-03,1.500207e+01,4766,6307,0.002980535216,0.004567937262123,1.587402e-03 +scramble,ppvm,8,10,0.7853981633974483,1,1e-4,1e-16,65535,1.000000e+00,64150,1.674435e-02,3.406150e+00,3.050943e-04,9.712100e-02,374,342,0.004554433189204,0.004567937262123,1.350407e-05 +scramble,monoprop,8,10,0.7853981633974483,1,1e-4,1e-16,65535,1.000000e+00,64144,1.270353e-02,2.583770e+00,2.536806e-04,8.731900e-02,294,268,0.004512298994,0.004567937262123,5.563827e-05 +scramble,ppvm,8,10,0.7853981633974483,1,1e-5,1e-16,65535,1.000000e+00,65396,1.041360e-03,2.120041e-01,2.233626e-05,7.722638e-04,26,21,0.004568585960411,0.004567937262123,6.486983e-07 +scramble,monoprop,8,10,0.7853981633974483,1,1e-5,1e-16,65535,1.000000e+00,65392,9.593503e-04,1.913561e-01,2.030360e-05,7.639987e-04,18,17,0.004566272673,0.004567937262123,1.664589e-06 +scramble,ppvm,8,10,0.7853981633974483,2,1e-3,1e-16,65535,1.000000e+00,48647,2.791048e-01,5.628207e+01,5.037719e-03,1.722668e+01,5167,7348,-0.003044049375097,-0.002123511847918,9.205375e-04 +scramble,monoprop,8,10,0.7853981633974483,2,1e-3,1e-16,65535,1.000000e+00,49443,2.734635e-01,5.507724e+01,5.526536e-03,1.610547e+01,5454,6839,-0.002009768618,-0.002123511847918,1.137432e-04 +scramble,ppvm,8,10,0.7853981633974483,2,1e-4,1e-16,65535,1.000000e+00,64060,1.508072e-02,3.042391e+00,3.327267e-04,9.911472e-02,394,348,-0.002128546499306,-0.002123511847918,5.034651e-06 +scramble,monoprop,8,10,0.7853981633974483,2,1e-4,1e-16,65535,1.000000e+00,64063,1.363255e-02,2.758181e+00,3.033626e-04,9.342819e-02,353,304,-0.002116198144,-0.002123511847918,7.313704e-06 +scramble,ppvm,8,10,0.7853981633974483,2,1e-5,1e-16,65535,1.000000e+00,65377,8.178769e-04,1.652772e-01,1.911102e-05,8.209238e-04,27,15,-0.002124049692306,-0.002123511847918,5.378444e-07 +scramble,monoprop,8,10,0.7853981633974483,2,1e-5,1e-16,65535,1.000000e+00,65362,6.915502e-04,1.389042e-01,2.041713e-05,9.752001e-04,18,21,-0.002124065586,-0.002123511847918,5.537381e-07 +scramble,ppvm,8,10,0.7853981633974483,3,1e-3,1e-16,65534,1.000000e+00,23800,1.482153e-01,2.838560e+01,3.781926e-03,1.934654e+01,2061,4604,0.01021329788007,0.01011042655569,1.028713e-04 +scramble,monoprop,8,10,0.7853981633974483,3,1e-3,1e-16,65534,1.000000e+00,23958,1.416023e-01,2.720370e+01,3.105871e-03,1.887551e+01,1907,4292,0.010168429392,0.01011042655569,5.800284e-05 +scramble,ppvm,8,10,0.7853981633974483,3,1e-4,1e-16,65534,1.000000e+00,57545,1.677474e-02,3.310245e+00,4.291547e-04,5.726769e-01,1649,2112,0.01012727159909,0.01011042655569,1.684504e-05 +scramble,monoprop,8,10,0.7853981633974483,3,1e-4,1e-16,65534,1.000000e+00,57569,1.497634e-02,2.933245e+00,4.037880e-04,5.407710e-01,1440,1879,0.010170823571,0.01011042655569,6.039702e-05 +scramble,ppvm,8,10,0.7853981633974483,3,1e-5,1e-16,65534,1.000000e+00,64720,1.185833e-03,2.343507e-01,2.743055e-05,5.164075e-03,175,166,0.01011032002169,0.01011042655569,1.065340e-07 +scramble,monoprop,8,10,0.7853981633974483,3,1e-5,1e-16,65534,1.000000e+00,64695,9.601826e-04,1.883416e-01,2.589419e-05,5.002271e-03,133,149,0.010108359939,0.01011042655569,2.066617e-06 +scramble,ppvm,8,10,0.7853981633974483,4,1e-3,1e-16,65535,1.000000e+00,44916,2.344832e-01,4.721697e+01,4.659655e-03,1.772357e+01,4861,7238,-0.002857638598484,-0.002233897581292,6.237410e-04 +scramble,monoprop,8,10,0.7853981633974483,4,1e-3,1e-16,65535,1.000000e+00,44165,2.366092e-01,4.750515e+01,4.174483e-03,1.888975e+01,4683,7811,-0.002729812368,-0.002233897581292,4.959148e-04 +scramble,ppvm,8,10,0.7853981633974483,4,1e-4,1e-16,65535,1.000000e+00,63664,1.656759e-02,3.339823e+00,3.475736e-04,1.364974e-01,544,528,-0.002347368442479,-0.002233897581292,1.134709e-04 +scramble,monoprop,8,10,0.7853981633974483,4,1e-4,1e-16,65535,1.000000e+00,63678,1.610363e-02,3.253601e+00,3.508339e-04,1.298318e-01,494,464,-0.002305939048,-0.002233897581292,7.204147e-05 +scramble,ppvm,8,10,0.7853981633974483,4,1e-5,1e-16,65535,1.000000e+00,65344,1.076490e-03,2.173143e-01,2.137590e-05,1.167930e-03,44,36,-0.0022253215307,-0.002233897581292,8.576051e-06 +scramble,monoprop,8,10,0.7853981633974483,4,1e-5,1e-16,65535,1.000000e+00,65344,9.685830e-04,1.950917e-01,2.328722e-05,1.113917e-03,37,29,-0.002225810421,-0.002233897581292,8.087160e-06 +scramble,ppvm,8,10,0.7853981633974483,5,1e-3,1e-16,65535,1.000000e+00,46519,2.524708e-01,5.087362e+01,4.986171e-03,1.754877e+01,5039,7322,0,-0.001223130468711,1.223130e-03 +scramble,monoprop,8,10,0.7853981633974483,5,1e-3,1e-16,65535,1.000000e+00,47075,2.425934e-01,4.885867e+01,4.802603e-03,1.636502e+01,5028,6755,0,-0.001223130468711,1.223130e-03 +scramble,ppvm,8,10,0.7853981633974483,5,1e-4,1e-16,65535,1.000000e+00,63800,1.575686e-02,3.174853e+00,3.102006e-04,1.252977e-01,441,469,-0.001210275579338,-0.001223130468711,1.285489e-05 +scramble,monoprop,8,10,0.7853981633974483,5,1e-4,1e-16,65535,1.000000e+00,63817,1.319639e-02,2.629690e+00,2.919619e-04,1.111590e-01,347,358,-0.001235391553,-0.001223130468711,1.226108e-05 +scramble,ppvm,8,10,0.7853981633974483,5,1e-5,1e-16,65535,1.000000e+00,65366,8.791609e-04,1.777130e-01,2.111646e-05,8.729767e-04,28,16,-0.001221170841282,-0.001223130468711,1.959627e-06 +scramble,monoprop,8,10,0.7853981633974483,5,1e-5,1e-16,65535,1.000000e+00,65365,6.298597e-04,1.269766e-01,1.810326e-05,8.522953e-04,22,11,-0.001224291877,-0.001223130468711,1.161408e-06 diff --git a/benchmarks/cross-library/plot_accuracy.py b/benchmarks/cross-library/plot_accuracy.py new file mode 100644 index 000000000..0ec9135ec --- /dev/null +++ b/benchmarks/cross-library/plot_accuracy.py @@ -0,0 +1,315 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +"""Render the truncation-error scaling from `xbench_accuracy.py`'s CSVs. + +Four panels, because four separate questions were asked of this data and each one +has a different answer: + +1. **Error vs `atol`** — every engine falls one decade per decade of `atol`, so + the two truncation rules share a convergence order and differ only in the + constant. This panel is why the rules are not qualitatively apart. +2. **Error ratio vs depth** — the loss compounds per gate, so the gap grows with + circuit depth. +3. **Error ratio vs rotation angle** — the ratio *crosses 1.0*. Large angles + favour monoprop, because that is where accumulate-then-truncate has its own + failure mode: `truncate()` after each gate permanently deletes a term whose + merged coefficient transiently cancelled. Any summary quoting one ratio is + hiding this panel. +4. **The scrambling instance** — where the rules genuinely separate, beside a + `θ = π/4` control on the same circuit family that reverses the sign. + +Ratios are monoprop / ppvm on the `L2` distance to a converged reference, so +above 1.0 means monoprop is less accurate. The scalar `observable` is +deliberately not plotted: its truncation error changes sign, so ratios built from +it are dominated by whichever engine happened to land near a zero crossing (see +`README.md`). + + uv run --no-project --with matplotlib python3 plot_accuracy.py \ + --csv accuracy.csv --divergence accuracy_divergence.csv \ + --out target/xbench/accuracy.png +""" + +from __future__ import annotations + +import argparse +import csv +import statistics +import textwrap +from collections import defaultdict +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from plot_xbench import SERIES, SURFACE, TEXT_PRIMARY, TEXT_SECONDARY, style + +BASELINE = "ppvm" +PAIR = ("ppvm", "monoprop") +# Ratio panels plot a *derived* quantity, not an engine, so they stay off the +# engine palette — those hues are reserved for identity throughout the harness. +RATIO_INK = ["#0b0b0b", "#8a8986"] +RATIO_MARKS = ["v", "o"] + + +def load(path: Path) -> list[dict[str, str]]: + with path.open() as fh: + return list(csv.DictReader(fh)) + + +def relative(rows, library: str) -> dict[float, list[float]]: + """`{atol: [relative L2 error, ...]}` for one engine, pooled over seeds.""" + out: dict[float, list[float]] = defaultdict(list) + for r in rows: + if r["library"] == library: + out[float(r["atol"])].append(float(r["l2_err"]) / float(r["ref_norm"])) + return out + + +def ratio_vs(rows, key: str) -> dict[float, list[float]]: + """`{x: [monoprop/ppvm error ratio, ...]}` as `key` is swept. + + A ratio is only formed inside one fully-specified cell — same model, width, + depth, angle, seed and `atol` — so sweeping one axis never divides across + another. Remaining axes (seeds) pool into the list. + """ + cells: dict[tuple, dict[str, float]] = defaultdict(dict) + for r in rows: + if r["library"] not in PAIR: + continue + cell = (r["model"], r["qubits"], r["steps"], r["dt"], r["seed"], r["atol"]) + cells[cell][r["library"]] = float(r["l2_err"]) + cells[cell]["x"] = float(r[key]) + out: dict[float, list[float]] = defaultdict(list) + for libs in cells.values(): + if len(libs) < 3 or not libs.get(BASELINE): + continue + out[libs["x"]].append(libs["monoprop"] / libs[BASELINE]) + return dict(sorted(out.items())) + + +def band(ax, data, color, marker, label) -> None: + """Median line, with a min/max ribbon where an axis pooled several seeds.""" + xs = list(data) + ax.plot( + xs, + [statistics.median(data[x]) for x in xs], + color=color, + marker=marker, + markersize=5, + linewidth=1.6, + label=label, + ) + if any(len(v) > 1 for v in data.values()): + ax.fill_between( + xs, + [min(data[x]) for x in xs], + [max(data[x]) for x in xs], + color=color, + alpha=0.16, + linewidth=0, + ) + + +def panel_atol(ax, rows) -> None: + """Absolute error against atol, every engine, both Trotter models.""" + for model, dash in (("tfim", "-"), ("heisenberg", "--")): + for lib, (label, color, marker) in SERIES.items(): + pts = sorted( + (float(r["atol"]), float(r["l2_err"]) / float(r["ref_norm"])) + for r in rows + if r["model"] == model and r["library"] == lib + ) + if pts: + ax.plot( + *zip(*pts), + dash, + color=color, + marker=marker, + markersize=4, + linewidth=1.4, + alpha=0.9, + label=label if model == "tfim" else None, + ) + # Slope-1 guide, anchored on the baseline's own worst point so it sits beside + # the data rather than floating below it -- the eye is comparing gradients. + anchor = max( + (float(r["atol"]), float(r["l2_err"]) / float(r["ref_norm"])) + for r in rows + if r["model"] == "tfim" and r["library"] == BASELINE + ) + lo = min(float(r["atol"]) for r in rows) + ax.plot( + [lo, anchor[0]], + [anchor[1] * lo / anchor[0], anchor[1]], + ":", + color=TEXT_SECONDARY, + linewidth=1.3, + label="slope 1 (error ∝ atol)", + ) + ax.set_xscale("log") + ax.set_yscale("log") + ax.invert_xaxis() + ax.set_xlabel("truncation threshold atol") + ax.set_ylabel("relative L2 error ‖c − c*‖ / ‖c*‖") + ax.set_title( + "1 · Every rule converges at the same order\n" + "solid TFIM, dashed Heisenberg — n=8, 10 steps, dt=0.1", + fontsize=10, + color=TEXT_PRIMARY, + loc="left", + ) + ax.legend(fontsize=7, frameon=False, labelcolor=TEXT_SECONDARY, loc="lower left") + + +def panel_ratio(ax, rows, key, xlabel, title) -> None: + """monoprop / ppvm error ratio against one swept axis, one line per atol. + + The swept values are logarithmically spaced and uneven, so the axis is log + and ticks are pinned to the values actually run. + """ + ax.set_xscale("log") + ax.minorticks_off() + # Loosest threshold first: that is the series with the largest effect, and + # `sorted` on the raw strings would put 1e-5 ahead of 1e-3. + xs: list[float] = [] + for i, atol in enumerate( + sorted({r["atol"] for r in rows}, key=float, reverse=True) + ): + data = ratio_vs([r for r in rows if r["atol"] == atol], key) + if data: + band( + ax, + data, + RATIO_INK[i % len(RATIO_INK)], + RATIO_MARKS[i % len(RATIO_MARKS)], + f"atol = {atol}", + ) + xs = list(data) + # Set after the scale, which would otherwise install its own locator. + ax.set_xticks(xs) + ax.set_xticklabels([f"{x:g}" for x in xs], fontsize=8) + ax.axhline(1.0, color=SERIES["ppvm"][1], linewidth=1.2, linestyle=":") + ax.annotate( + "equal accuracy", + (0.03, 1.0), + xycoords=("axes fraction", "data"), + fontsize=7, + color=SERIES["ppvm"][1], + va="bottom", + ) + ax.set_xlabel(xlabel) + ax.set_ylabel("monoprop error / ppvm error") + ax.set_title(title, fontsize=10, color=TEXT_PRIMARY, loc="left") + ax.legend(fontsize=7, frameon=False, labelcolor=TEXT_SECONDARY) + + +def panel_scramble(ax, rows) -> None: + """The scrambling instance, small angle against the theta = pi/4 control.""" + for dt, dash, tag in ( + ("0.05", "-", "dt=0.05, 3200 gates"), + ("0.7853981633974483", "--", "dt=π/4, 80 gates"), + ): + sub = [r for r in rows if r["dt"] == dt] + for lib in PAIR: + data = relative(sub, lib) + if data: + label, color, marker = SERIES[lib] + ax.plot( + sorted(data), + [statistics.median(data[x]) for x in sorted(data)], + dash, + color=color, + marker=marker, + markersize=5, + linewidth=1.6, + label=f"{label} — {tag}", + ) + ax.set_xscale("log") + ax.set_yscale("log") + ax.invert_xaxis() + ax.set_xlabel("truncation threshold atol") + ax.set_ylabel("relative L2 error") + ax.set_title( + "4 · Scrambling instance: where they separate\n" + "n=8, all 65535 Pauli words populated, median of 5 seeds", + fontsize=10, + color=TEXT_PRIMARY, + loc="left", + ) + ax.legend(fontsize=7, frameon=False, labelcolor=TEXT_SECONDARY, loc="lower left") + + +CAPTION = ( + "Error is the L2 distance of the whole propagated coefficient vector from a " + "converged reference (ppvm at atol=1e-16), relative to ‖c*‖. ppvm, " + "PauliPropagation.jl and PauliStrings.jl accumulate every contribution to a term " + "and then threshold the merged coefficient; pauli-prop and monoprop threshold each " + "branch as it is emitted. A Pauli rotation sends Q to exactly two words, so a " + "merged value can exceed the larger contribution by at most 2x — which bounds the " + "divergence and ties both errors to the same atol. Panels 2-4: monoprop needs small " + "sin θ (a wide rejection band), depth (to populate and compound it) and a scrambled " + "operator (so the loss does not average out) simultaneously before it separates; " + "drop any one and the gap collapses or reverses. Ribbons are min/max over seeds." +) + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--csv", type=Path, required=True, help="the 5-engine atol sweep") + ap.add_argument( + "--divergence", + type=Path, + required=True, + help="the depth / angle / scramble sweeps", + ) + ap.add_argument("--out", type=Path, required=True) + args = ap.parse_args() + + div = load(args.divergence) + # The two Heisenberg sweeps are told apart by width, which is how they were + # run: depth at n=6 (cheap enough for 320 steps), angle at n=8. + depth = [r for r in div if r["model"] == "heisenberg" and r["qubits"] == "6"] + angle = [r for r in div if r["model"] == "heisenberg" and r["qubits"] == "8"] + scramble = [r for r in div if r["model"] == "scramble"] + for name, rows in (("depth", depth), ("angle", angle), ("scramble", scramble)): + if not rows: + raise SystemExit(f"no {name} rows in {args.divergence}") + + fig, axes = plt.subplots(2, 2, figsize=(13.0, 9.6), facecolor=SURFACE) + for ax in axes.flat: + style(ax) + + panel_atol(axes[0][0], load(args.csv)) + panel_ratio( + axes[0][1], + depth, + "steps", + "Trotter steps", + "2 · The gap compounds with depth\nHeisenberg n=6, dt=0.05 (sin θ ≈ 0.1)", + ) + panel_ratio( + axes[1][0], + angle, + "dt", + "dt (θ = 2·dt, so dt = π/8 gives sin θ = 0.707)", + "3 · …and reverses at large angles\nHeisenberg n=8, 10 steps", + ) + panel_scramble(axes[1][1], scramble) + + fig.text( + 0.008, + 0.012, + textwrap.fill(CAPTION, 168), + fontsize=7.5, + color=TEXT_SECONDARY, + va="bottom", + ) + fig.tight_layout(rect=(0, 0.072, 1, 1)) + args.out.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(args.out, dpi=200, facecolor=SURFACE) + print(f"wrote {args.out}") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/cross-library/plot_xbench.py b/benchmarks/cross-library/plot_xbench.py new file mode 100644 index 000000000..cd5f28dd5 --- /dev/null +++ b/benchmarks/cross-library/plot_xbench.py @@ -0,0 +1,244 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +"""Render the cross-library benchmark from `run_xbench.py`'s `results.csv`. + +One row per model, three panels: runtime vs qubit count (log-y), runtime +relative to `ppvm`, and the size of the propagated operator — which is the +workload, and the thing that makes the runtimes comparable in the first place. + + uv run --no-project --with matplotlib python3 plot_xbench.py \ + --csv target/xbench/results.csv --out target/xbench/xbench.png +""" + +from __future__ import annotations + +import argparse +import csv +import textwrap +from collections import defaultdict +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +# Slots from the validated default palette, in this fixed order — it clears +# every hard gate on the adjacent pairlist that line charts use (worst CVD +# ΔE 9.2, worst normal-vision ΔE 27.5). Colour follows the entity, so a run with +# a subset of `--libs` keeps every survivor's hue. Magenta and aqua sit below +# 3:1 on the light surface, which obligates relief — `results.csv`, the driver's +# summary table, and the per-series markers are it. +BASELINE = "ppvm" +SERIES = { + "ppvm": ("ppvm (this repo)", "#2a78d6", "o"), + "pauli-propagation-jl": ("PauliPropagation.jl", "#eb6834", "s"), + "pauli-strings-jl": ("PauliStrings.jl", "#1baf7a", "^"), + "pauli-prop": ("pauli-prop (Qiskit)", "#4a3aa7", "D"), + "monoprop": ("monoprop", "#e87ba4", "v"), +} +TEXT_PRIMARY = "#0b0b0b" +TEXT_SECONDARY = "#52514e" +GRID = "#d8d7d3" +SURFACE = "#fcfcfb" +MODEL_TITLES = { + "tfim": "TFIM Trotter — ⟨0|Σ Z_i(t)|0⟩", + "heisenberg": "Heisenberg correlations — tr[Z₀ Z₀(t)]/2ⁿ", +} + + +def load(path: Path): + """`{model: {library: {n: row}}}`.""" + out: dict[str, dict[str, dict[int, dict[str, str]]]] = defaultdict( + lambda: defaultdict(dict) + ) + with path.open() as fh: + for row in csv.DictReader(fh): + out[row["model"]][row["library"]][int(row["qubits"])] = row + return out + + +def style(ax) -> None: + """Recessive grid and axes; the data carries the ink.""" + ax.set_facecolor(SURFACE) + ax.grid(True, which="major", color=GRID, linewidth=0.8, alpha=0.9) + ax.grid(True, which="minor", color=GRID, linewidth=0.5, alpha=0.5) + ax.set_axisbelow(True) + for side in ("top", "right"): + ax.spines[side].set_visible(False) + for side in ("left", "bottom"): + ax.spines[side].set_color(GRID) + ax.tick_params(colors=TEXT_SECONDARY, labelsize=9) + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--csv", type=Path, required=True) + ap.add_argument("--out", type=Path, required=True) + ap.add_argument("--title", default=None) + args = ap.parse_args() + + data = load(args.csv) + models = [m for m in ("tfim", "heisenberg") if m in data] + fig, axes = plt.subplots( + len(models), + 3, + figsize=(15.5, 4.4 * len(models)), + squeeze=False, + facecolor=SURFACE, + ) + + for r, model in enumerate(models): + per_lib = data[model] + ax_t, ax_s, ax_n = axes[r] + widths = sorted({n for lib in per_lib.values() for n in lib}) + term_series: dict[str, tuple[int, ...]] = {} + for key, (label, color, marker) in SERIES.items(): + if key not in per_lib: + continue + ns = sorted(per_lib[key]) + times = [float(per_lib[key][n]["time_s"]) for n in ns] + ax_t.plot( + ns, + times, + color=color, + marker=marker, + markersize=6, + linewidth=2, + label=label, + ) + terms = [int(per_lib[key][n]["terms"]) for n in ns] + ax_n.plot( + ns, + terms, + color=color, + marker=marker, + markersize=6, + linewidth=2, + label=label, + ) + term_series[key] = tuple(terms) + if key != BASELINE and BASELINE in per_lib: + shared = [n for n in ns if n in per_lib[BASELINE]] + ratios = [ + float(per_lib[key][n]["time_s"]) + / float(per_lib[BASELINE][n]["time_s"]) + for n in shared + ] + ax_s.plot( + shared, + ratios, + color=color, + marker=marker, + markersize=6, + linewidth=2, + label=label, + ) + + # Engines whose support is identical draw the same line, so the ones + # underneath are invisible. Say so rather than let the panel imply that + # only the top series was measured. + if BASELINE in term_series: + same = [k for k, v in term_series.items() if v == term_series[BASELINE]] + if len(same) > 1: + ax_n.annotate( + "identical (exactly):\n" + + "\n".join(SERIES[k][0].split(" (")[0] for k in same), + # The support rises left-to-right, so the low-right corner + # is the one that stays clear of the marks. + xy=(0.97, 0.04), + xycoords="axes fraction", + fontsize=8, + color=TEXT_SECONDARY, + ha="right", + va="bottom", + ) + + ax_s.axhline(1.0, color=SERIES[BASELINE][1], linewidth=2, linestyle=(0, (4, 3))) + ax_s.annotate( + f"{SERIES[BASELINE][0]} = 1", + xy=(0.02, 1.0), + xycoords=("axes fraction", "data"), + va="bottom", + fontsize=8, + color=TEXT_SECONDARY, + ) + + for ax, ylabel, title in ( + (ax_t, "runtime (s, min of repeats)", "runtime"), + (ax_s, f"× {BASELINE}", "relative runtime"), + (ax_n, "terms in the propagated operator", "workload size"), + ): + style(ax) + ax.set_yscale("log") + # Qubit counts are integers; let matplotlib interpolate ticks and it + # invents 6.25 qubits. + ax.set_xticks(widths) + ax.set_xticklabels([str(n) for n in widths]) + ax.set_xlabel("qubits", color=TEXT_SECONDARY, fontsize=9) + ax.set_ylabel(ylabel, color=TEXT_SECONDARY, fontsize=9) + ax.set_title(title, color=TEXT_PRIMARY, fontsize=10, loc="left") + + ax_t.set_ylabel("runtime (s, min of repeats)", color=TEXT_SECONDARY, fontsize=9) + ax_t.annotate( + MODEL_TITLES.get(model, model), + xy=(0, 1.16), + xycoords="axes fraction", + fontsize=12, + fontweight="bold", + color=TEXT_PRIMARY, + ) + + # One figure-level legend above the panels: repeating it per row wastes + # space, and inside the axes it lands on the fastest series. + handles, labels = axes[0][0].get_legend_handles_labels() + fig.legend( + handles, + labels, + frameon=False, + fontsize=9.5, + labelcolor=TEXT_SECONDARY, + ncol=len(labels), + loc="upper left", + bbox_to_anchor=(0.006, 0.995), + ) + + sample = next(iter(next(iter(data.values())).values())) + row = next(iter(sample.values())) + n_engines = len({lib for per_lib in data.values() for lib in per_lib}) + caption = ( + f"first-order Trotter, steps={row['steps']}, dt={row['dt']}, " + f"truncation |c| < {row['atol']}; single-threaded; min of repeats. " + f"All {n_engines} engines are validated to propagate the identical operator " + "term-for-term before timing, but pauli-prop and monoprop prune at " + "branch-creation time rather than after accumulation, so at this " + "truncation they carry a different support — compare the workload panel " + "before reading their ratios. monoprop is capped to one thread " + "(monoprop_NUM_THREADS=1); uncapped it takes one partition per core. " + "Per-point numbers in results.csv." + ) + if args.title: + fig.suptitle( + args.title, fontsize=14, color=TEXT_PRIMARY, x=0.006, y=0.995, ha="left" + ) + # Legend sits just under the title when there is one. + fig.legends[0].set_bbox_to_anchor((0.006, 0.962)) + wrapped = textwrap.fill(caption, width=178) + fig.text( + 0.008, + 0.004, + wrapped, + fontsize=8.5, + color=TEXT_SECONDARY, + ha="left", + va="bottom", + ) + top = 0.90 if args.title else 0.945 + fig.tight_layout(rect=(0, 0.018 * (wrapped.count("\n") + 1) + 0.012, 1, top)) + args.out.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(args.out, dpi=160, facecolor=SURFACE) + print(f"wrote {args.out}") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/cross-library/run_xbench.py b/benchmarks/cross-library/run_xbench.py new file mode 100644 index 000000000..cf1b9c5b0 --- /dev/null +++ b/benchmarks/cross-library/run_xbench.py @@ -0,0 +1,366 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +"""Drive the cross-library Pauli-propagation benchmark and merge the results. + +Runs the same two circuits — TFIM Trotter and Heisenberg-model correlations — +through `ppvm`, PauliPropagation.jl, PauliStrings.jl, Qiskit's `pauli-prop` and +monoprop, all from one parameter contract, and writes one tidy CSV. + +Before it will report a single timing it **validates**: every engine dumps its +propagated support at a small width and the driver diffs them term-for-term +against `ppvm`. A cross-library benchmark whose engines are quietly computing +different things is worse than no benchmark, and every bug this check caught +while the harness was being written produced plausible-looking numbers that +agreed on the observable to nine digits — a reversed circuit in the `pauli-prop` +runner, duplicate Paulis collapsing in its readout, and monoprop's `exp(+iθH)` +sign convention. + + uv run --no-project python3 run_xbench.py --help +""" + +from __future__ import annotations + +import argparse +import csv +import os +import shutil +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +JULIA_PROJECT = REPO / "julia-benchmarks" +BASELINE = "ppvm" +CSV_COLUMNS = [ + "model", + "library", + "qubits", + "steps", + "dt", + "atol", + "time_s", + "terms", + "observable", +] + + +@dataclass(frozen=True) +class Runner: + """One engine: how to invoke it, and whether it is available here.""" + + name: str + argv: list[str] + needs: str # the executable that must exist on PATH + env: dict[str, str] = field(default_factory=dict) + + def available(self) -> bool: + return shutil.which(self.needs) is not None + + +def _here(name: str) -> str: + return str(Path(__file__).with_name(name)) + + +RUNNERS = { + "ppvm": Runner( + "ppvm", + [ + "cargo", + "run", + "--release", + "-q", + "-p", + "ppvm-pauli-sum", + "--example", + "xbench", + ], + "cargo", + ), + "pauli-propagation-jl": Runner( + "pauli-propagation-jl", + [ + "julia", + f"--project={JULIA_PROJECT}", + "-t1", + str(JULIA_PROJECT / "benches" / "xbench_pp.jl"), + ], + "julia", + ), + "pauli-strings-jl": Runner( + "pauli-strings-jl", + [ + "julia", + f"--project={JULIA_PROJECT}", + "-t1", + str(JULIA_PROJECT / "benches" / "xbench_ps.jl"), + ], + "julia", + ), + "pauli-prop": Runner( + "pauli-prop", + [ + "uv", + "run", + "--no-project", + "--with", + "pauli-prop", + "python3", + _here("xbench_qiskit.py"), + ], + "uv", + ), + # monoprop takes one serial partition per physical core when left alone, so + # the cap is part of the invocation, not an optional extra. The runner + # re-asserts it from its own CPU/wall ratio and fails if it did not land. + "monoprop": Runner( + "monoprop", + [ + "uv", + "run", + "--no-project", + "--with", + "monoprop", + "python3", + _here("xbench_monoprop.py"), + ], + "uv", + {"monoprop_NUM_THREADS": "1", "monoprop_PARTITIONS": "off"}, + ), +} + + +def invoke(runner: Runner, env_extra: dict[str, str], quiet: bool) -> str: + """Run one engine and return its stdout, streaming its stderr as progress.""" + env = os.environ.copy() + env.update(env_extra) + env.update(runner.env) + proc = subprocess.run( + runner.argv, + cwd=REPO, + env=env, + capture_output=True, + text=True, + check=False, + ) + if proc.returncode != 0: + sys.stderr.write(proc.stderr) + raise SystemExit(f"{runner.name} failed with exit code {proc.returncode}") + if not quiet and proc.stderr.strip(): + sys.stderr.write(proc.stderr) + return proc.stdout + + +def parse_dump(text: str) -> dict[str, float]: + """Parse a `DUMP=1` support listing into `{word: coefficient}`.""" + terms: dict[str, float] = {} + for line in text.splitlines(): + if not line or line.startswith("#"): + continue + word, coeff = line.split() + terms[word] = float(coeff) + return terms + + +def validate( + libs: list[str], params: dict[str, str], atol_cmp: float, quiet: bool +) -> None: + """Assert every engine propagates the *same* operator, term for term.""" + print("validating: all engines must agree term-for-term", file=sys.stderr) + for model in ("tfim", "heisenberg"): + env = dict(params) + env.update( + {"MODEL": model, "QUBITS": "4", "STEPS": "3", "ATOL": "1e-14", "DUMP": "1"} + ) + reference: dict[str, float] | None = None + ref_lib = "" + for lib in libs: + terms = parse_dump(invoke(RUNNERS[lib], env, quiet=True)) + if reference is None: + reference, ref_lib = terms, lib + print( + f" {model}: {lib} — {len(terms)} terms (reference)", + file=sys.stderr, + ) + continue + missing = set(reference) - set(terms) + extra = set(terms) - set(reference) + worst = max( + (abs(reference[w] - terms[w]) for w in set(reference) & set(terms)), + default=0.0, + ) + if missing or extra or worst > atol_cmp: + raise SystemExit( + f"{model}: {lib} disagrees with {ref_lib} — " + f"{len(terms)} vs {len(reference)} terms, " + f"{len(missing)} missing, {len(extra)} extra, max|Δc|={worst:.3e} " + f"(tolerance {atol_cmp:.0e}). Refusing to report timings." + ) + print( + f" {model}: {lib} — {len(terms)} terms, max|Δc|={worst:.1e} OK", + file=sys.stderr, + ) + print("validation passed\n", file=sys.stderr) + + +def main() -> None: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("--model", default="both", choices=["tfim", "heisenberg", "both"]) + ap.add_argument( + "--qubits", default="8,12,16,20,24,28,32", help="applies to every model" + ) + ap.add_argument( + "--qubits-tfim", + default=None, + help="override --qubits for the TFIM sweep (its support grows linearly in n, " + "so it reaches far wider systems than the Heisenberg one at equal cost)", + ) + ap.add_argument( + "--qubits-heisenberg", default=None, help="override --qubits for Heisenberg" + ) + ap.add_argument("--steps", type=int, default=10) + ap.add_argument("--dt", type=float, default=0.1) + ap.add_argument("--j", type=float, default=1.0, help="bond coupling J") + ap.add_argument("--h", type=float, default=1.0, help="field strength h") + ap.add_argument( + "--atol", type=float, default=1e-6, help="coefficient truncation threshold" + ) + ap.add_argument( + "--iters", type=int, default=3, help="timed repeats; the minimum is reported" + ) + ap.add_argument("--libs", default=",".join(RUNNERS)) + ap.add_argument("--out", default="target/xbench", type=Path) + ap.add_argument("--skip-validate", action="store_true") + ap.add_argument( + "--validate-tol", + type=float, + default=1e-10, + help="absolute coefficient tolerance for the cross-engine agreement check", + ) + ap.add_argument( + "--reuse", + type=Path, + default=None, + help="re-print the summary from an existing results.csv", + ) + ap.add_argument("-q", "--quiet", action="store_true") + args = ap.parse_args() + + if args.reuse is not None: + with args.reuse.open() as fh: + summarize(list(csv.DictReader(fh))) + return + + libs = [lib.strip() for lib in args.libs.split(",") if lib.strip()] + for lib in libs: + if lib not in RUNNERS: + raise SystemExit(f"unknown library {lib!r}; known: {', '.join(RUNNERS)}") + missing = [lib for lib in libs if not RUNNERS[lib].available()] + if missing: + print( + f"skipping (missing {', '.join(RUNNERS[m].needs for m in missing)}): " + f"{', '.join(missing)}", + file=sys.stderr, + ) + libs = [lib for lib in libs if lib not in missing] + if not libs: + raise SystemExit("no runnable engines") + + params = { + "DT": repr(args.dt), + "JCOUP": repr(args.j), + "HFIELD": repr(args.h), + "ITERS": str(args.iters), + } + if not args.skip_validate: + validate(libs, params, args.validate_tol, args.quiet) + + models = ["tfim", "heisenberg"] if args.model == "both" else [args.model] + per_model_qubits = { + "tfim": args.qubits_tfim or args.qubits, + "heisenberg": args.qubits_heisenberg or args.qubits, + } + rows: list[dict[str, str]] = [] + for model in models: + for lib in libs: + env = dict(params) + env.update( + { + "MODEL": model, + "QUBITS": per_model_qubits[model], + "STEPS": str(args.steps), + "ATOL": repr(args.atol), + } + ) + print(f"running {model} / {lib}", file=sys.stderr) + out = invoke(RUNNERS[lib], env, args.quiet) + reader = csv.DictReader(line for line in out.splitlines() if line.strip()) + rows.extend(dict(row) for row in reader) + + out_dir = REPO / args.out if not args.out.is_absolute() else args.out + out_dir.mkdir(parents=True, exist_ok=True) + csv_path = out_dir / "results.csv" + with csv_path.open("w", newline="") as fh: + writer = csv.DictWriter(fh, fieldnames=CSV_COLUMNS) + writer.writeheader() + writer.writerows(rows) + + summarize(rows) + print(f"\nwrote {csv_path}", file=sys.stderr) + + +def summarize(rows: list[dict[str, str]]) -> None: + """Print a time table plus the speedup of every engine relative to `ppvm`.""" + by_model: dict[str, dict[int, dict[str, dict[str, str]]]] = {} + for row in rows: + by_model.setdefault(row["model"], {}).setdefault(int(row["qubits"]), {})[ + row["library"] + ] = row + + for model, per_n in by_model.items(): + libs = sorted({lib for cells in per_n.values() for lib in cells}) + print(f"\n=== {model} ===") + header = f"{'n':>4} {'terms':>9} " + " ".join(f"{lib:>22}" for lib in libs) + print(header) + print("-" * len(header)) + for n in sorted(per_n): + cells = per_n[n] + base = cells.get(BASELINE) + terms = base["terms"] if base else next(iter(cells.values()))["terms"] + line = f"{n:>4} {terms:>9} " + parts = [] + for lib in libs: + row = cells.get(lib) + if row is None: + parts.append(f"{'—':>22}") + continue + t = float(row["time_s"]) + if base is not None and lib != BASELINE: + ratio = t / float(base["time_s"]) + parts.append(f"{t:>11.4f}s ({ratio:5.2f}x)") + else: + parts.append(f"{t:>11.4f}s {'(1.00x)':>9}") + print(line + " ".join(parts)) + + # A runtime ratio only means something at equal work. Any engine whose + # truncation rule leaves it carrying a materially different support is + # called out here rather than left to the reader to spot in the plot. + drift: dict[str, list[str]] = {} + for n in sorted(per_n): + base = per_n[n].get(BASELINE) + if base is None: + continue + for lib, row in per_n[n].items(): + if lib == BASELINE: + continue + rel = int(row["terms"]) / int(base["terms"]) - 1.0 + if abs(rel) > 0.02: + drift.setdefault(lib, []).append(f"n={n}: {rel:+.0%}") + for lib, notes in drift.items(): + print(f" note: {lib} support vs {BASELINE} — {', '.join(notes)}") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/cross-library/xbench_accuracy.py b/benchmarks/cross-library/xbench_accuracy.py new file mode 100644 index 000000000..4132936e2 --- /dev/null +++ b/benchmarks/cross-library/xbench_accuracy.py @@ -0,0 +1,197 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +"""Measure what each engine's truncation rule costs in accuracy, not in time. + +`run_xbench.py` answers "how fast", holding `atol` fixed. It cannot answer "how +wrong", because the engines do not all mean the same thing by `atol`: `ppvm`, +PauliPropagation.jl and PauliStrings.jl accumulate every contribution to a term +and then drop the merged coefficient, while `pauli-prop` and monoprop test a +branch's prospective coefficient before they emit it. Same threshold, different +rule, so the fair question is how far each lands from the untruncated answer. + +This sweeps `atol` at a small width and diffs each engine's **whole coefficient +vector** against a converged reference. The vector norm is the point. Judging +this on the scalar `observable` is actively misleading, because its truncation +error changes sign — `ppvm` on TFIM walks 7.7e-4, 1.8e-3, 1.0e-4, 3.0e-7, 1.2e-6 +over `atol` 1e-3 … 1e-7, and that accidental near-zero at 1e-6 manufactures a 27x +gap against monoprop where the norm shows 6 %. Both are recorded so the +discrepancy stays visible rather than being something you have to rediscover. + +The reference is the baseline engine at `--ref-atol`. Widths whose reachable +sector saturates give an exact reference (Heisenberg `n=8` tops out at 16 384 +terms); elsewhere check it has converged before trusting a small ratio. + + uv run --no-project python3 xbench_accuracy.py --help +""" + +from __future__ import annotations + +import argparse +import csv +import itertools +import math +import sys +from pathlib import Path + +from run_xbench import BASELINE, RUNNERS, invoke, parse_dump + +CSV_COLUMNS = [ + "model", + "library", + "qubits", + "steps", + "dt", + "seed", + "atol", + "ref_atol", + "ref_terms", + "ref_norm", + "terms", + "l2_err", + "l1_err", + "max_coeff_err", + "lost_mass", + "kept_subthreshold", + "dropped_above_atol", + "observable", + "ref_observable", + "obs_abs_err", +] + + +def norm(terms: dict[str, float]) -> float: + """`‖c‖₂`. Unitary conjugation preserves it, so it is the scale the absolute + errors should be read against.""" + return math.sqrt(sum(v * v for v in terms.values())) + + +def measure(approx: dict[str, float], ref: dict[str, float], atol: float) -> dict: + """Compare one propagated support against the converged one.""" + words = set(approx) | set(ref) + diffs = [abs(approx.get(w, 0.0) - ref.get(w, 0.0)) for w in words] + return { + "terms": len(approx), + "l2_err": math.sqrt(sum(d * d for d in diffs)), + "l1_err": sum(diffs), + "max_coeff_err": max(diffs, default=0.0), + # Weight the engine discarded that the reference says was there. + "lost_mass": sum(abs(c) for w, c in ref.items() if w not in approx), + # Rows held whose converged value is under the threshold they were given: + # the signature of a branch that cleared `atol` when it was emitted and + # was never re-tested after later contributions cancelled it. + "kept_subthreshold": sum(1 for w in approx if abs(ref.get(w, 0.0)) < atol), + # The opposite failure: mass thrown away that belonged above `atol`. + "dropped_above_atol": sum( + 1 for w, c in ref.items() if w not in approx and abs(c) >= atol + ), + } + + +def observable(text: str) -> float: + """Pull the `observable` column out of a runner's one-row CSV.""" + return float( + next(csv.DictReader(l for l in text.splitlines() if l.strip()))["observable"] + ) + + +def run(lib: str, env: dict[str, str], quiet: bool) -> tuple[dict[str, float], float]: + """Get one engine's support and its readout at these parameters.""" + dump = parse_dump(invoke(RUNNERS[lib], {**env, "DUMP": "1"}, quiet)) + return dump, observable(invoke(RUNNERS[lib], env, quiet)) + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--models", default="tfim,heisenberg") + ap.add_argument("--qubits", type=int, default=8) + ap.add_argument( + "--steps", + default="10", + help="comma-separated depths; the reference is recomputed per depth", + ) + ap.add_argument("--dt", default="0.1", help="comma-separated angles scales") + ap.add_argument("--atols", default="1e-3,1e-4,1e-5,1e-6,1e-7") + ap.add_argument( + "--seeds", + default="12345", + help="comma-separated circuit seeds; only `scramble` reads them, and it " + "needs several because a random instance varies", + ) + ap.add_argument( + "--ref-atol", + default="1e-16", + help="threshold for the reference run; must be converged at this width", + ) + ap.add_argument("--libs", default=",".join(RUNNERS)) + ap.add_argument("--out", type=Path, default=Path("target/xbench/accuracy.csv")) + ap.add_argument("--quiet", action="store_true") + args = ap.parse_args() + + libs = [lib for lib in args.libs.split(",") if lib.strip()] + if unknown := set(libs) - set(RUNNERS): + raise SystemExit(f"unknown libraries: {sorted(unknown)}") + if missing := [lib for lib in libs if not RUNNERS[lib].available()]: + print(f"skipping (toolchain absent): {', '.join(missing)}", file=sys.stderr) + libs = [lib for lib in libs if lib not in missing] + if BASELINE not in libs: + raise SystemExit(f"{BASELINE} is the reference and cannot be skipped") + + base = {"QUBITS": str(args.qubits), "ITERS": "1", "JCOUP": "1.0", "HFIELD": "1.0"} + grid = itertools.product( + args.models.split(","), + args.steps.split(","), + args.dt.split(","), + args.seeds.split(","), + ) + rows = [] + for model, steps, dt, seed in grid: + env = {**base, "MODEL": model, "STEPS": steps, "DT": dt, "SEED": seed} + # The reference depends on every axis, so it is recomputed per cell. + ref, ref_obs = run(BASELINE, {**env, "ATOL": args.ref_atol}, args.quiet) + print( + f"{model} n={args.qubits} steps={steps} dt={dt} seed={seed}: reference is" + f" {BASELINE} at atol={args.ref_atol} — {len(ref)} terms," + f" observable {ref_obs:.12g}", + file=sys.stderr, + ) + for atol in args.atols.split(","): + for lib in libs: + approx, obs = run(lib, {**env, "ATOL": atol}, args.quiet) + stats = measure(approx, ref, float(atol)) + rows.append( + { + "model": model, + "library": lib, + "qubits": args.qubits, + "steps": steps, + "dt": dt, + "seed": seed, + "atol": atol, + "ref_atol": args.ref_atol, + "ref_terms": len(ref), + "ref_norm": f"{norm(ref):.6e}", + "observable": f"{obs:.15g}", + "ref_observable": f"{ref_obs:.15g}", + "obs_abs_err": f"{abs(obs - ref_obs):.6e}", + **{ + k: f"{v:.6e}" if isinstance(v, float) else v + for k, v in stats.items() + }, + } + ) + print( + f" atol={atol} {lib:>20}: {stats['terms']:>7} terms," + f" L2 {stats['l2_err']:.3e}, obs err {rows[-1]['obs_abs_err']}", + file=sys.stderr, + ) + + args.out.parent.mkdir(parents=True, exist_ok=True) + with args.out.open("w", newline="") as fh: + writer = csv.DictWriter(fh, fieldnames=CSV_COLUMNS) + writer.writeheader() + writer.writerows(rows) + print(f"wrote {len(rows)} rows to {args.out}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/cross-library/xbench_monoprop.py b/benchmarks/cross-library/xbench_monoprop.py new file mode 100644 index 000000000..2a2c2abfc --- /dev/null +++ b/benchmarks/cross-library/xbench_monoprop.py @@ -0,0 +1,301 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +"""The monoprop side of the cross-library Pauli-propagation benchmark. + +See `README.md` for the shared circuit definitions, the parameter contract, and +the CSV schema — every runner reads the same environment variables and prints the +same columns. + +Three things about monoprop need saying, because each of them silently changes +the number this file prints: + +**It is parallel by default.** With one MPI rank the engine takes one serial +partition per *physical core* (`resolve_partition_count_`), so an uncapped run on +this 14-core machine is a ~9.5x-CPU, ~3x-wall-faster run than the serial one, and +is not comparable with the other single-threaded engines. `monoprop_NUM_THREADS=1` +and `monoprop_PARTITIONS=off` pin it to one partition, and the runner **measures +its own CPU/wall ratio and fails** if the cap did not take. An environment +variable that is read once into a cached C++ static is exactly the kind of setting +that goes stale without anyone noticing. + +**Its angles are not the spec's angles.** `ExpGate` applies `exp(+iθH)`, so the +spec's `exp(-iθ/2·G)` needs `θ_monoprop = -θ_spec/2`. All four other engines take +`θ_spec` directly. + +**It reports more rows than it has terms.** The engine retains monomials whose +coefficient has cancelled to exactly zero, and `size()` counts those. At +`n=12` Heisenberg that is 3.06M tracked rows against 1.32M terms above the +truncation threshold. The `terms` column is the above-threshold support, which is +what the other four engines mean by it; the tracked size goes to stderr. + + MODEL=tfim QUBITS=8,16,24 STEPS=10 DT=0.1 JCOUP=1.0 HFIELD=1.0 ATOL=1e-6 \ + uv run --no-project --with monoprop python3 xbench_monoprop.py +""" + +from __future__ import annotations + +import os + +# Must precede the import: the C++ side reads these once into a cached static +# (`monoprop::config::get()`), so a later assignment would be ignored. The driver +# sets them in the subprocess environment too; this makes a direct invocation of +# this file single-threaded as well, rather than quietly parallel. +os.environ.setdefault("monoprop_NUM_THREADS", "1") +os.environ.setdefault("monoprop_PARTITIONS", "off") + +import resource +import sys +import time + +from monoprop import ( + Circuit, + ExpGate, + Pauli, + PauliOperator, + PauliPropagator, +) + +MODEL = os.environ.get("MODEL", "tfim") +STEPS = int(os.environ.get("STEPS", "10")) +DT = float(os.environ.get("DT", "0.1")) +JCOUP = float(os.environ.get("JCOUP", "1.0")) +HFIELD = float(os.environ.get("HFIELD", "1.0")) +ATOL = float(os.environ.get("ATOL", "1e-6")) +ITERS = int(os.environ.get("ITERS", "3")) +SEED = int(os.environ.get("SEED", "12345")) +# A serial run has CPU/wall ~1. Anything materially above it means the thread cap +# did not take and the timing is not comparable with the other engines. +CPU_WALL_MAX = float(os.environ.get("CPU_WALL_MAX", "1.5")) + +THETA_BOND = 2 * JCOUP * DT +THETA_SITE = 2 * HFIELD * DT + + +def cpu_seconds() -> float: + """Total CPU time (user+sys) charged to this process and its children.""" + me = resource.getrusage(resource.RUSAGE_SELF) + kids = resource.getrusage(resource.RUSAGE_CHILDREN) + return me.ru_utime + me.ru_stime + kids.ru_utime + kids.ru_stime + + +def seed_operator(n: int) -> PauliOperator: + """`Σ_i Z_i` for TFIM, `Z_0` for Heisenberg and scramble.""" + if MODEL == "tfim": + terms: dict[Pauli | str, float] = {Pauli("Z", (i,)): 1.0 for i in range(n)} + else: + terms = {Pauli("Z", (0,)): 1.0} + return PauliOperator(terms, n) + + +class SplitMix64: + """splitmix64, matching `SplitMix64` in `examples/xbench.rs` bit for bit. + + Both runners have to emit the *same* random circuit, and neither language's + stdlib RNG is specified tightly enough to rely on. This is short enough to + state in both and is checked by the term-for-term dump diff. + """ + + MASK = (1 << 64) - 1 + + def __init__(self, seed: int) -> None: + self.state = seed & self.MASK + + def next(self) -> int: + self.state = (self.state + 0x9E3779B97F4A7C15) & self.MASK + z = self.state + z = ((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9) & self.MASK + z = ((z ^ (z >> 27)) * 0x94D049BB133111EB) & self.MASK + return z ^ (z >> 31) + + def unit(self) -> float: + """Uniform in `[0, 1)`, from the top 53 bits.""" + return (self.next() >> 11) / float(1 << 53) + + +AXES = "XYZ" + + +def scramble_gates(n: int) -> list[tuple[str, tuple[int, ...], float]]: + """`STEPS · n` random all-to-all two-qubit Pauli rotations. + + Draw order — pair, offset, axis, axis, angle — must match `scramble_gates` + in `examples/xbench.rs` exactly, including the offset trick that avoids a + rejection loop for `b != a` (a loop would consume a variable number of + draws and desynchronise the two streams). + """ + if n < 2: + raise SystemExit("scramble needs at least 2 qubits") + rng = SplitMix64(SEED) + theta_max = 2 * JCOUP * DT + gates = [] + for _ in range(STEPS * n): + a = rng.next() % n + b = (a + 1 + rng.next() % (n - 1)) % n + pa = AXES[rng.next() % 3] + pb = AXES[rng.next() % 3] + theta = theta_max * rng.unit() + # P_a ⊗ P_b is symmetric under swapping both, so normalise to ascending + # qubit order -- monoprop's `Pauli` expects sorted supports. + if a > b: + a, b, pa, pb = b, a, pb, pa + gates.append((pa + pb, (a, b), theta)) + return gates + + +def gate_list(n: int) -> list[tuple[str, tuple[int, ...], float]]: + """The gate sequence in *application* order, per the spec.""" + if MODEL == "scramble": + return scramble_gates(n) + gates: list[tuple[str, tuple[int, ...], float]] = [] + for _ in range(STEPS): + if MODEL == "tfim": + gates += [("X", (i,), THETA_SITE) for i in range(n)] + gates += [("ZZ", (i, i + 1), THETA_BOND) for i in range(n - 1)] + else: + for i in range(n - 1): + gates += [ + ("XX", (i, i + 1), THETA_BOND), + ("YY", (i, i + 1), THETA_BOND), + ("ZZ", (i, i + 1), THETA_BOND), + ] + gates += [("Z", (i,), THETA_SITE) for i in range(n)] + return gates + + +def build_circuit(n: int) -> Circuit: + """The spec's gate sequence as a monoprop `Circuit`. + + Two conversions happen here, and the term-for-term validation in + `run_xbench.py` is what pinned both of them down: + + * **Angle.** `ExpGate` applies `exp(+iθH)` (its own docstring flags the + positive sign as the difference from Qiskit's `r

`), while the shared + spec's gate is `exp(-iθ_spec/2 · G)`. Hence `θ = -θ_spec/2`. + * **Order.** Like Qiskit's `pauli-prop`, the propagator conjugates from the + end of the gate list backwards, so the spec's application order is + obtained by appending in reverse. + + Neither is cosmetic. All eight sign/order combinations were checked against + the reference dump: forward order loses terms outright (109 against TFIM's + 124), and every wrong angle keeps the right support while moving + coefficients by up to 1.3. Only this one lands within 5e-13. + """ + gates = list(reversed(gate_list(n))) + exp_gates = [ + ExpGate(PauliOperator({Pauli(string, qubits): 1.0}, n), index) + for index, (string, qubits, _theta) in enumerate(gates) + ] + parameters = [-0.5 * theta for (_s, _q, theta) in gates] + return Circuit(gates=exp_gates, parameters=parameters) + + +def propagate(n: int, circuit: Circuit) -> PauliPropagator: + """Heisenberg-picture propagation with coefficient-only truncation. + + `cutoff` is monoprop's mandatory bound on retained Pauli *weight*, which the + other four engines have no analogue of; `cutoff=n` is the whole register, so + it never binds and `lower_atol` is left as the only truncation — the shared + `|c| < atol` rule. + """ + return PauliPropagator.from_circuit( + circuit, seed_operator(n), cutoff=n, lower_atol=ATOL + ) + + +def support(mp: PauliPropagator, n: int) -> dict[str, float]: + """`{word: coefficient}` with site 0 leftmost, exact-zero rows dropped. + + `atol=0.0` asks the engine for everything it holds, and the threshold is + applied here so the filter is the same one the other engines apply during + propagation. + """ + operator = mp.evolved_operator(atol=0.0) + terms: dict[str, float] = {} + for pauli, coeff in operator.terms.items(): + value = float(coeff.real if isinstance(coeff, complex) else coeff) + if abs(value) < ATOL: + continue + chars = ["I"] * n + for qubit, letter in zip(pauli.qubits, pauli.string): + chars[qubit] = letter + terms["".join(chars)] = value + return terms + + +def readout(terms: dict[str, float], n: int) -> float: + """`⟨0…0|O|0…0⟩` for TFIM; the `Z_0` autocorrelator for Heisenberg.""" + if MODEL == "tfim": + # ⟨0|Z|0⟩ = 1 and ⟨0|X|0⟩ = ⟨0|Y|0⟩ = 0, so only the X/Y-free terms survive. + return sum(c for w, c in terms.items() if "X" not in w and "Y" not in w) + return terms.get("Z" + "I" * (n - 1), 0.0) + + +def dump(n: int) -> None: + """Print the propagated support as `word coefficient`, largest first.""" + terms = support(propagate(n, build_circuit(n)), n) + print(f"# {len(terms)} terms") + for word in sorted(terms, key=lambda w: (-abs(terms[w]), w)): + print(f"{word} {terms[word]:+.12e}") + + +def main() -> None: + qubits = [ + int(t) for t in os.environ.get("QUBITS", "8,12,16,20,24,28,32").split(",") + ] + if os.environ.get("DUMP"): + dump(qubits[0]) + return + print( + f"monoprop {MODEL}: steps={STEPS} dt={DT} J={JCOUP} h={HFIELD} atol={ATOL} " + f"iters={ITERS} threads={os.environ['monoprop_NUM_THREADS']} " + f"partitions={os.environ['monoprop_PARTITIONS']} cores={os.cpu_count()}", + file=sys.stderr, + ) + print("model,library,qubits,steps,dt,atol,time_s,terms,observable") + for n in qubits: + # Circuit construction is not propagation; built once, outside the timed + # region, as in every other runner. monoprop's `propagate` does expand + # the gate list internally each call, but that is 0.0–2.2% of the total + # at these widths (measured), not enough to move a ratio. + circuit = build_circuit(n) + + best = float("inf") + cpu_at_best = 0.0 + propagator = None + for _ in range(ITERS): + cpu0, wall0 = cpu_seconds(), time.perf_counter() + propagator = propagate(n, circuit) + wall = time.perf_counter() - wall0 + if wall < best: + best, cpu_at_best = wall, cpu_seconds() - cpu0 + + # A parallel run would report a wall time the other engines never had the + # chance to compete with. Refuse to print it. + ratio = cpu_at_best / best if best > 0 else 1.0 + if ratio > CPU_WALL_MAX: + raise SystemExit( + f"n={n}: monoprop used {ratio:.2f}x CPU per wall second " + f"({cpu_at_best:.3f}s CPU in {best:.3f}s wall), so the thread cap " + f"did not take and this timing is not comparable with the other " + f"single-threaded engines. Check monoprop_NUM_THREADS / " + f"monoprop_PARTITIONS reach the subprocess." + ) + + assert propagator is not None + # Materializing the support is O(size) in Python and dwarfs the + # propagation at the wide end, so it happens once, after timing. + terms = support(propagator, n) + obs = readout(terms, n) + print( + f"{MODEL},monoprop,{n},{STEPS},{DT},{ATOL},{best:.7g},{len(terms)},{obs!r}" + ) + print( + f" n={n} {best:.4f}s {len(terms)} terms obs={obs} " + f"[cpu/wall={ratio:.2f} tracked={propagator.size()}]", + file=sys.stderr, + ) + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/benchmarks/cross-library/xbench_qiskit.py b/benchmarks/cross-library/xbench_qiskit.py new file mode 100644 index 000000000..6122a60e3 --- /dev/null +++ b/benchmarks/cross-library/xbench_qiskit.py @@ -0,0 +1,190 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +"""The Qiskit `pauli-prop` side of the cross-library Pauli-propagation benchmark. + +See `README.md` for the shared circuit definitions, the parameter contract, and +the CSV schema — every runner reads the same environment variables and prints +the same columns. + +`pauli-prop` truncates on `atol` *and* on a mandatory `max_terms` cap, which the +other three engines do not have. `MAX_TERMS` therefore defaults high enough to +be non-binding, and the runner asserts it never bound: if the propagated support +ever reaches the cap the row is not comparable and the run fails rather than +quietly reporting a differently-truncated number. + + MODEL=tfim QUBITS=8,16,24 STEPS=10 DT=0.1 JCOUP=1.0 HFIELD=1.0 ATOL=1e-6 \ + uv run --no-project --with pauli-prop python3 xbench_qiskit.py +""" + +from __future__ import annotations + +import os +import sys +import time + +import numpy as np +from pauli_prop import propagate_through_circuit +from qiskit import QuantumCircuit +from qiskit.quantum_info import SparsePauliOp + +MODEL = os.environ.get("MODEL", "tfim") +STEPS = int(os.environ.get("STEPS", "10")) +DT = float(os.environ.get("DT", "0.1")) +JCOUP = float(os.environ.get("JCOUP", "1.0")) +HFIELD = float(os.environ.get("HFIELD", "1.0")) +ATOL = float(os.environ.get("ATOL", "1e-6")) +ITERS = int(os.environ.get("ITERS", "3")) +MAX_TERMS = int(os.environ.get("MAX_TERMS", str(1 << 22))) + +THETA_BOND = 2 * JCOUP * DT +THETA_SITE = 2 * HFIELD * DT + + +def seed_operator(n: int) -> SparsePauliOp: + """`Σ_i Z_i` for TFIM, `Z_0` for Heisenberg. + + Qiskit Pauli labels are little-endian: the rightmost character is qubit 0. + """ + if MODEL == "tfim": + labels = ["I" * (n - 1 - i) + "Z" + "I" * i for i in range(n)] + else: + labels = ["I" * (n - 1) + "Z"] + return SparsePauliOp(labels, coeffs=np.ones(len(labels))) + + +def gate_list(n: int) -> list[tuple[str, int]]: + """`STEPS` first-order Trotter steps in *application* order, per the spec.""" + gates: list[tuple[str, int]] = [] + for _ in range(STEPS): + if MODEL == "tfim": + gates += [("rx", i) for i in range(n)] + gates += [("rzz", i) for i in range(n - 1)] + else: + for i in range(n - 1): + gates += [("rxx", i), ("ryy", i), ("rzz", i)] + gates += [("rz", i) for i in range(n)] + return gates + + +def build_circuit(n: int) -> QuantumCircuit: + """The spec's gate sequence, as a circuit `pauli-prop` will apply in order. + + A Qiskit circuit whose instructions are appended `g_1 … g_k` denotes the + unitary `U = g_k ⋯ g_1`, and in the Heisenberg frame `pauli-prop` computes + `U† O U` by conjugating **from the end of the instruction list backwards**. + So the gate `pauli-prop` applies first is the one appended last, and the + spec's application order is obtained by appending in reverse. + + This is not cosmetic. Appending in forward order propagates a genuinely + different operator: at `n=4, steps=3, atol=1e-14` it yields 108 terms + against TFIM's reference 124 and 61 against Heisenberg's 64, with + coefficients off by up to 0.1. Reversed, all four engines agree + term-for-term to the last bit — which is what `run_xbench.py --validate` + checks before it will report a timing. + """ + qc = QuantumCircuit(n) + for name, i in reversed(gate_list(n)): + if name == "rx": + qc.rx(THETA_SITE, i) + elif name == "rz": + qc.rz(THETA_SITE, i) + elif name == "rxx": + qc.rxx(THETA_BOND, i, i + 1) + elif name == "ryy": + qc.ryy(THETA_BOND, i, i + 1) + elif name == "rzz": + qc.rzz(THETA_BOND, i, i + 1) + else: # pragma: no cover - the list above is closed + raise ValueError(f"unknown gate {name}") + return qc + + +def support(op: SparsePauliOp) -> dict[str, float]: + """`{word: coefficient}` with site 0 leftmost, duplicate Paulis summed. + + `propagate_through_circuit` returns an operator that may list the same Pauli + more than once — `len(op)` counts rows, not distinct terms — so the support + size comparable with the other three engines is the size of this mapping. + Qiskit labels are little-endian, hence the reversal. + """ + terms: dict[str, float] = {} + for pauli, coeff in zip(op.paulis, np.asarray(op.coeffs)): + word = str(pauli)[::-1] + terms[word] = terms.get(word, 0.0) + float(np.real(coeff)) + return terms + + +def readout(op: SparsePauliOp, n: int) -> float: + """`⟨0…0|O|0…0⟩` for TFIM; the `Z_0` autocorrelator for Heisenberg.""" + paulis = op.paulis + coeffs = np.asarray(op.coeffs) + if MODEL == "tfim": + # ⟨0|Z|0⟩ = 1 and ⟨0|X|0⟩ = ⟨0|Y|0⟩ = 0, so only the X-free terms survive. + diagonal = ~paulis.x.any(axis=1) + return float(np.real(coeffs[diagonal].sum())) + z0_x = np.zeros(n, dtype=bool) + z0_z = np.zeros(n, dtype=bool) + z0_z[0] = True + hit = (paulis.x == z0_x).all(axis=1) & (paulis.z == z0_z).all(axis=1) + return float(np.real(coeffs[hit].sum())) + + +def dump(n: int) -> None: + """Print the propagated support as `word coefficient`, largest first. + + Site 0 leftmost, matching the Rust runner's `DUMP=1` output so the driver + can diff the two term-for-term. + """ + out, _bias = propagate_through_circuit( + seed_operator(n), build_circuit(n), max_terms=MAX_TERMS, atol=ATOL, frame="h" + ) + terms = support(out) + print(f"# {len(terms)} terms") + for word in sorted(terms, key=lambda w: (-abs(terms[w]), w)): + print(f"{word} {terms[word]:+.12e}") + + +def main() -> None: + qubits = [ + int(t) for t in os.environ.get("QUBITS", "8,12,16,20,24,28,32").split(",") + ] + if os.environ.get("DUMP"): + dump(qubits[0]) + return + print( + f"pauli-prop {MODEL}: steps={STEPS} dt={DT} J={JCOUP} h={HFIELD} " + f"atol={ATOL} iters={ITERS} max_terms={MAX_TERMS}", + file=sys.stderr, + ) + print("model,library,qubits,steps,dt,atol,time_s,terms,observable") + for n in qubits: + # Circuit construction is not propagation; built once, outside the + # timed region, as in every other runner. + circuit = build_circuit(n) + seed = seed_operator(n) + + best = float("inf") + terms = 0 + obs = float("nan") + for _ in range(ITERS): + t0 = time.perf_counter() + out, _bias = propagate_through_circuit( + seed, circuit, max_terms=MAX_TERMS, atol=ATOL, frame="h" + ) + best = min(best, time.perf_counter() - t0) + # Distinct Paulis, not the returned operator's row count. + terms = len(support(out)) + obs = readout(out, n) + if terms >= MAX_TERMS: + raise SystemExit( + f"n={n}: support hit the max_terms cap ({terms} >= {MAX_TERMS}); " + "raise MAX_TERMS — this row would be truncated differently from " + "the other engines and is not comparable" + ) + print(f"{MODEL},pauli-prop,{n},{STEPS},{DT},{ATOL},{best:.7g},{terms},{obs!r}") + print(f" n={n} {best:.4f}s {terms} terms obs={obs}", file=sys.stderr) + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/crates/ppvm-cli/README.md b/crates/ppvm-cli/README.md index 722767aba..d5138403e 100644 --- a/crates/ppvm-cli/README.md +++ b/crates/ppvm-cli/README.md @@ -122,15 +122,15 @@ wherever you want execution to stop: ``` fn @main() { - const.u64 0 - circuit.h + cpu::cpu.const u64, 0 + circuit::circuit.h // execution pauses here - breakpoint + cpu::cpu.breakpoint - const.u64 0 - circuit.measure - ret + cpu::cpu.const u64, 0 + circuit::circuit.measure + cpu::cpu.ret 0 } ``` @@ -145,6 +145,10 @@ measurements: Measurements: 0 ``` +The `next:` display shows the runtime instruction formatter (for example, +`const.u64`); `.sst` source files must use the qualified v0.4 spelling shown +above. + To step through a program that has no breakpoints, pass `-b`/`--break-at-start` to pause before the very first instruction: diff --git a/crates/ppvm-cli/examples/bit_flip_correction.sst b/crates/ppvm-cli/examples/bit_flip_correction.sst index a76d1e267..70352c1fa 100644 --- a/crates/ppvm-cli/examples/bit_flip_correction.sst +++ b/crates/ppvm-cli/examples/bit_flip_correction.sst @@ -3,64 +3,64 @@ device circuit.n_qubits 5; // Data: q0, q1, q2. // Syndrome ancillas: q3, q4. fn @main() { - const.u64 1 - const.f64 0.25 - const.f64 0.0 - const.f64 0.0 - circuit.paulierror + cpu::cpu.const u64, 1 + cpu::cpu.const f64, 0.25 + cpu::cpu.const f64, 0.0 + cpu::cpu.const f64, 0.0 + circuit::circuit.paulierror - const.u64 0 - const.u64 3 - circuit.cnot - const.u64 1 - const.u64 3 - circuit.cnot - const.u64 3 - circuit.measure + cpu::cpu.const u64, 0 + cpu::cpu.const u64, 3 + circuit::circuit.cnot + cpu::cpu.const u64, 1 + cpu::cpu.const u64, 3 + circuit::circuit.cnot + cpu::cpu.const u64, 3 + circuit::circuit.measure - const.u64 1 - const.u64 4 - circuit.cnot - const.u64 2 - const.u64 4 - circuit.cnot - const.u64 4 - circuit.measure + cpu::cpu.const u64, 1 + cpu::cpu.const u64, 4 + circuit::circuit.cnot + cpu::cpu.const u64, 2 + cpu::cpu.const u64, 4 + circuit::circuit.cnot + cpu::cpu.const u64, 4 + circuit::circuit.measure - const.u32 1 - eq.u32 - cond_br @s12_one, @s12_zero + cpu::cpu.const u32, 1 + cpu::cpu.eq u32 + cpu::cpu.cond_br @s12_one, @s12_zero -@s12_one: - const.u32 1 - eq.u32 - cond_br @correct_q1, @correct_q2 +cpu::cpu.label @s12_one + cpu::cpu.const u32, 1 + cpu::cpu.eq u32 + cpu::cpu.cond_br @correct_q1, @correct_q2 -@s12_zero: - const.u32 1 - eq.u32 - cond_br @correct_q0, @readout +cpu::cpu.label @s12_zero + cpu::cpu.const u32, 1 + cpu::cpu.eq u32 + cpu::cpu.cond_br @correct_q0, @readout -@correct_q0: - const.u64 0 - circuit.x - br @readout +cpu::cpu.label @correct_q0 + cpu::cpu.const u64, 0 + circuit::circuit.x + cpu::cpu.br @readout -@correct_q1: - const.u64 1 - circuit.x - br @readout +cpu::cpu.label @correct_q1 + cpu::cpu.const u64, 1 + circuit::circuit.x + cpu::cpu.br @readout -@correct_q2: - const.u64 2 - circuit.x +cpu::cpu.label @correct_q2 + cpu::cpu.const u64, 2 + circuit::circuit.x -@readout: - const.u64 0 - circuit.measure - const.u64 1 - circuit.measure - const.u64 2 - circuit.measure - ret +cpu::cpu.label @readout + cpu::cpu.const u64, 0 + circuit::circuit.measure + cpu::cpu.const u64, 1 + circuit::circuit.measure + cpu::cpu.const u64, 2 + circuit::circuit.measure + cpu::cpu.ret 0 } diff --git a/crates/ppvm-cli/examples/ghz.sst b/crates/ppvm-cli/examples/ghz.sst index aa88b8a65..7be5f2e3b 100644 --- a/crates/ppvm-cli/examples/ghz.sst +++ b/crates/ppvm-cli/examples/ghz.sst @@ -3,25 +3,25 @@ device circuit.n_qubits 3; // Prepare a 3-qubit GHZ state (|000> + |111>)/sqrt(2) and measure every qubit. // The three outcomes are perfectly correlated, so each shot reads 0 0 0 or 1 1 1. fn @main() { - const.u64 0 - circuit.h + cpu::cpu.const u64, 0 + circuit::circuit.h - const.u64 0 - const.u64 1 - circuit.cnot + cpu::cpu.const u64, 0 + cpu::cpu.const u64, 1 + circuit::circuit.cnot - const.u64 1 - const.u64 2 - circuit.cnot + cpu::cpu.const u64, 1 + cpu::cpu.const u64, 2 + circuit::circuit.cnot - const.u64 0 - circuit.measure + cpu::cpu.const u64, 0 + circuit::circuit.measure - const.u64 1 - circuit.measure + cpu::cpu.const u64, 1 + circuit::circuit.measure - const.u64 2 - circuit.measure + cpu::cpu.const u64, 2 + circuit::circuit.measure - ret + cpu::cpu.ret 0 } diff --git a/crates/ppvm-cli/examples/heisenberg_zz.sst b/crates/ppvm-cli/examples/heisenberg_zz.sst index 028e7e098..4aad28632 100644 --- a/crates/ppvm-cli/examples/heisenberg_zz.sst +++ b/crates/ppvm-cli/examples/heisenberg_zz.sst @@ -11,7 +11,7 @@ device circuit.coefficient_threshold 1e-10; // sum directly: 1.0 + 0.5 = 1.5. Add `circuit.cnot; circuit.h; circuit.truncate` // in textbook-reversed order to evolve before tracing. fn @main() { - const.str "[XZ]?*" - circuit.trace - ret + cpu::cpu.const str, "[XZ]?*" + circuit::circuit.trace + cpu::cpu.ret 0 } diff --git a/crates/ppvm-cli/examples/loop_feedforward.sst b/crates/ppvm-cli/examples/loop_feedforward.sst index f1a7f6d87..0446467a8 100644 --- a/crates/ppvm-cli/examples/loop_feedforward.sst +++ b/crates/ppvm-cli/examples/loop_feedforward.sst @@ -8,37 +8,37 @@ device circuit.n_qubits 2; // prints 4 bits: q0_round1 q0_round2 q0_round3 q1. fn @main() { // Loop counter starts at 0 and lives on the bottom of the stack. - const.u64 0 + cpu::cpu.const u64, 0 -@loop: +cpu::cpu.label @loop // Flip a fair coin: H then measure q0 (pushes outcome 0/1 as u32). - const.u64 0 - circuit.h - const.u64 0 - circuit.measure + cpu::cpu.const u64, 0 + circuit::circuit.h + cpu::cpu.const u64, 0 + circuit::circuit.measure // Feed-forward: if the outcome was 1, apply X to q1. - const.u32 1 - eq.u32 - cond_br @flip, @next + cpu::cpu.const u32, 1 + cpu::cpu.eq u32 + cpu::cpu.cond_br @flip, @next -@flip: - const.u64 1 - circuit.x - br @next +cpu::cpu.label @flip + cpu::cpu.const u64, 1 + circuit::circuit.x + cpu::cpu.br @next -@next: +cpu::cpu.label @next // counter += 1, then loop while counter < 3. - const.u64 1 - add.u64 - dup - const.u64 3 - lt.u64 - cond_br @loop, @done + cpu::cpu.const u64, 1 + cpu::cpu.add u64 + cpu::cpu.dup + cpu::cpu.const u64, 3 + cpu::cpu.lt u64 + cpu::cpu.cond_br @loop, @done -@done: +cpu::cpu.label @done // Final readout of q1. - const.u64 1 - circuit.measure - ret + cpu::cpu.const u64, 1 + circuit::circuit.measure + cpu::cpu.ret 0 } diff --git a/crates/ppvm-cli/examples/simple_loop.sst b/crates/ppvm-cli/examples/simple_loop.sst index 3591973ac..c68bd0d1b 100644 --- a/crates/ppvm-cli/examples/simple_loop.sst +++ b/crates/ppvm-cli/examples/simple_loop.sst @@ -1,33 +1,33 @@ device circuit.n_qubits 1; fn @main() { - const.u64 0 + cpu::cpu.const u64, 0 -@loop: - const.u64 0 - circuit.h - const.u64 0 - circuit.measure +cpu::cpu.label @loop + cpu::cpu.const u64, 0 + circuit::circuit.h + cpu::cpu.const u64, 0 + circuit::circuit.measure // stop here to investigate - breakpoint + cpu::cpu.breakpoint - const.u32 1 - eq.u32 - cond_br @flip, @next + cpu::cpu.const u32, 1 + cpu::cpu.eq u32 + cpu::cpu.cond_br @flip, @next -@flip: - const.u64 0 - circuit.x +cpu::cpu.label @flip + cpu::cpu.const u64, 0 + circuit::circuit.x -@next: - const.u64 1 - add.u64 - dup - const.u64 2 - lt.u64 - cond_br @loop, @done +cpu::cpu.label @next + cpu::cpu.const u64, 1 + cpu::cpu.add u64 + cpu::cpu.dup + cpu::cpu.const u64, 2 + cpu::cpu.lt u64 + cpu::cpu.cond_br @loop, @done -@done: - ret +cpu::cpu.label @done + cpu::cpu.ret 0 } diff --git a/crates/ppvm-cli/src/commands.rs b/crates/ppvm-cli/src/commands.rs index 574d3c174..9ffd2d1c7 100644 --- a/crates/ppvm-cli/src/commands.rs +++ b/crates/ppvm-cli/src/commands.rs @@ -120,8 +120,8 @@ pub fn parse(file: &str, format: Format) -> Result<()> { } Format::Pretty => { println!("Module:"); - println!(" Headers: {}", parsed.headers.len()); - for (i, header) in parsed.headers.iter().enumerate() { + println!(" Headers: {}", parsed.header.len()); + for (i, header) in parsed.header.iter().enumerate() { println!(" [{}] {:?}", i, header); } println!(" Functions: {}", parsed.functions.len()); @@ -169,7 +169,7 @@ enum DebugCommand { Quit, } -/// Step through a program interactively, pausing at `breakpoint` instructions. +/// Step through a program interactively, pausing at `cpu::cpu.breakpoint` instructions. /// With `break_at_start`, also pauses before the first instruction so any /// program can be stepped from the beginning. pub fn debug(file: &str, break_at_start: bool) -> Result<()> { @@ -219,7 +219,7 @@ fn debug_loop( StepOutcome::Breakpoint => { paused = true; ever_paused = true; - writeln!(output, "-- breakpoint hit --")?; + writeln!(output, "-- cpu::cpu.breakpoint hit --")?; } StepOutcome::Return | StepOutcome::Halt => { writeln!(output, "Program finished.")?; @@ -240,7 +240,7 @@ fn debug_loop( if !ever_paused { writeln!( output, - "(no breakpoint was hit; pass --break-at-start to step from the beginning)" + "(no cpu::cpu.breakpoint was hit; pass --break-at-start to step from the beginning)" )?; } Ok(()) @@ -292,8 +292,7 @@ mod tests { use std::fs; /// Minimal program that compiles and measures q0 in |0> (deterministic). - const PROGRAM: &str = - "device circuit.n_qubits 1;\nfn @main() { const.u64 0\n circuit.measure\n ret }\n"; + const PROGRAM: &str = "device circuit.n_qubits 1;\nfn @main() { cpu::cpu.const u64, 0\n circuit::circuit.measure\n cpu::cpu.ret 0 }\n"; fn row(outcomes: &[MeasurementOutcome]) -> MeasurementResult { outcomes.iter().copied().collect() @@ -411,7 +410,7 @@ mod tests { const TRACE_PROGRAM: &str = "device circuit.n_qubits 1;\n\ device circuit.backend paulisum;\n\ device circuit.observable Z;\n\ - fn @main() { const.str \"Z?*\"\n circuit.trace\n ret }\n"; + fn @main() { cpu::cpu.const str, \"Z?*\"\n circuit::circuit.trace\n cpu::cpu.ret 0 }\n"; let src = temp_file("ppvm_cli_run_trace.sst", TRACE_PROGRAM); let out = std::env::temp_dir().join("ppvm_cli_run_trace.txt"); let _ = fs::remove_file(&out); @@ -513,8 +512,8 @@ mod tests { // ─── debug ───────────────────────────────────────────────────────── - /// Program with a `breakpoint` before measuring q0 in |0> (deterministic). - const BREAKPOINT_PROGRAM: &str = "device circuit.n_qubits 1;\nfn @main() { breakpoint\n const.u64 0\n circuit.measure\n ret }\n"; + /// Program with a `cpu::cpu.breakpoint` before measuring q0 in |0> (deterministic). + const BREAKPOINT_PROGRAM: &str = "device circuit.n_qubits 1;\nfn @main() { cpu::cpu.breakpoint\n cpu::cpu.const u64, 0\n circuit::circuit.measure\n cpu::cpu.ret 0 }\n"; /// Drive `debug_loop` with scripted input, returning the captured output. fn run_debug(program: &str, name: &str, break_at_start: bool, script: &str) -> String { @@ -528,7 +527,7 @@ mod tests { #[test] fn debug_break_at_start_steps_through_to_finish() { - // PROGRAM is const.u64 0 / circuit.measure / ret = 3 steps. + // PROGRAM is cpu::cpu.const u64, 0 / circuit::circuit.measure / ret = 3 steps. let out = run_debug(PROGRAM, "ppvm_cli_debug_step.sst", true, "s\ns\ns\n"); assert!( out.contains("next: Measure"), @@ -550,12 +549,12 @@ mod tests { #[test] fn debug_honors_authored_breakpoint() { - // Not breaking at start: must run until the `breakpoint` pauses it. + // Not breaking at start: must run until the `cpu::cpu.breakpoint` pauses it. let out = run_debug(BREAKPOINT_PROGRAM, "ppvm_cli_debug_bp.sst", false, "c\n"); - assert!(out.contains("-- breakpoint hit --"), "{out}"); + assert!(out.contains("-- cpu::cpu.breakpoint hit --"), "{out}"); assert!(out.contains("Program finished."), "{out}"); - // A breakpoint was hit, so no "use --break-at-start" hint. - assert!(!out.contains("no breakpoint was hit"), "{out}"); + // A cpu::cpu.breakpoint was hit, so no "use --break-at-start" hint. + assert!(!out.contains("no cpu::cpu.breakpoint was hit"), "{out}"); } #[test] @@ -571,10 +570,10 @@ mod tests { #[test] fn debug_without_breakpoint_prints_hint() { - // No breakpoint, no break-at-start, empty input: runs straight through + // No cpu::cpu.breakpoint, no break-at-start, empty input: runs straight through // and tells the user how to step. let out = run_debug(PROGRAM, "ppvm_cli_debug_hint.sst", false, ""); assert!(out.contains("Program finished."), "{out}"); - assert!(out.contains("no breakpoint was hit"), "{out}"); + assert!(out.contains("no cpu::cpu.breakpoint was hit"), "{out}"); } } diff --git a/crates/ppvm-cli/src/main.rs b/crates/ppvm-cli/src/main.rs index ffa73f6ea..2d8062b97 100644 --- a/crates/ppvm-cli/src/main.rs +++ b/crates/ppvm-cli/src/main.rs @@ -78,7 +78,7 @@ enum Commands { format: commands::MeasurementFormat, }, - /// Step through a program interactively, pausing at `breakpoint` instructions + /// Step through a program interactively, pausing at `cpu::cpu.breakpoint` instructions Debug { /// Input file (.sst source or .ssb bytecode) #[arg(value_name = "FILE")] diff --git a/crates/ppvm-pauli-sum/benches/truncation-weight.rs b/crates/ppvm-pauli-sum/benches/truncation-weight.rs index 0177a3a0e..a363fccb5 100644 --- a/crates/ppvm-pauli-sum/benches/truncation-weight.rs +++ b/crates/ppvm-pauli-sum/benches/truncation-weight.rs @@ -64,7 +64,7 @@ where .strategy(strat) .build(); for (w, c) in terms { - state += (w.clone(), *c); + state += (*w, *c); } state } diff --git a/crates/ppvm-pauli-sum/examples/hash_quality.rs b/crates/ppvm-pauli-sum/examples/hash_quality.rs index bd434c930..655710ca9 100644 --- a/crates/ppvm-pauli-sum/examples/hash_quality.rs +++ b/crates/ppvm-pauli-sum/examples/hash_quality.rs @@ -13,7 +13,7 @@ //! * compare both for `[u8;8]` and `[u8;16]` storage use std::collections::HashMap; -use std::hash::{BuildHasher, Hash, Hasher}; +use std::hash::{BuildHasher, Hash}; use ppvm_pauli_sum::prelude::*; use ppvm_pauli_sum::strategy::CoefficientThreshold; @@ -70,13 +70,7 @@ where H: BuildHasher + Default, { let hasher = H::default(); - let hashes: Vec = keys - .map(|k| { - let mut h = hasher.build_hasher(); - k.hash(&mut h); - h.finish() - }) - .collect(); + let hashes: Vec = keys.map(|k| hasher.hash_one(&k)).collect(); let n = hashes.len(); let mut counts: HashMap = HashMap::new(); diff --git a/crates/ppvm-pauli-sum/examples/xbench.rs b/crates/ppvm-pauli-sum/examples/xbench.rs new file mode 100644 index 000000000..3b048487b --- /dev/null +++ b/crates/ppvm-pauli-sum/examples/xbench.rs @@ -0,0 +1,354 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! The `ppvm` side of the cross-library Pauli-propagation benchmark. +//! +//! Two Trotter workloads — TFIM magnetization and a Heisenberg autocorrelator — +//! propagated in the Heisenberg picture with a coefficient-magnitude truncation +//! after every gate. See `benchmarks/cross-library/README.md` for the shared +//! spec: the gate order, the `θ = 2·c·dt` convention, the environment contract, +//! and the CSV schema that every runner in that harness prints. +//! +//! ```bash +//! MODEL=tfim QUBITS=8,16,24 STEPS=10 DT=0.1 JCOUP=1.0 HFIELD=1.0 ATOL=1e-6 \ +//! cargo run --release -p ppvm-pauli-sum --example xbench +//! ``` + +use std::time::Instant; + +use ppvm_pauli_sum::prelude::*; +use ppvm_pauli_sum::strategy::CoefficientThreshold; + +#[derive(Clone, Copy)] +struct Params { + model: Model, + steps: usize, + dt: f64, + j: f64, + h: f64, + atol: f64, + iters: usize, + seed: u64, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Model { + Tfim, + Heisenberg, + Scramble, +} + +impl Model { + fn parse(s: &str) -> Self { + match s { + "tfim" => Model::Tfim, + "heisenberg" => Model::Heisenberg, + "scramble" => Model::Scramble, + other => { + panic!("unknown MODEL {other:?} (expected `tfim`, `heisenberg` or `scramble`)") + } + } + } + + fn name(self) -> &'static str { + match self { + Model::Tfim => "tfim", + Model::Heisenberg => "heisenberg", + Model::Scramble => "scramble", + } + } +} + +/// splitmix64. Reimplemented rather than pulled in as a dependency because the +/// monoprop runner has to emit a bit-identical gate sequence from Python, and +/// this is short enough to state twice and check against a term-for-term diff. +struct SplitMix64(u64); + +impl SplitMix64 { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// Uniform in `[0, 1)`, from the top 53 bits. + fn unit(&mut self) -> f64 { + (self.next() >> 11) as f64 / (1u64 << 53) as f64 + } +} + +/// One two-qubit Pauli rotation `exp(-i θ/2 · P_a ⊗ P_b)`. +#[derive(Clone, Copy)] +struct Gate { + axis_a: [u8; 2], + axis_b: [u8; 2], + a: usize, + b: usize, + theta: f64, +} + +/// `[x, z]` bits for the three non-identity Paulis, indexed `0..3`. +const AXES: [[u8; 2]; 3] = [[1, 0], [1, 1], [0, 1]]; + +/// The `scramble` workload: `steps · n` two-qubit Pauli rotations on uniformly +/// random *all-to-all* pairs, with random axes and random angles in `(0, 2·J·dt]`. +/// +/// Unlike the two Trotter models this has no lattice, no conserved quantity and +/// no uniform angle, so the propagated operator spreads over the whole `4^n` +/// space and the coefficient distribution is genuinely scrambled rather than +/// hierarchically ordered by Pauli weight. +fn scramble_gates(n: usize, p: Params) -> Vec { + assert!(n >= 2, "scramble needs at least 2 qubits"); + let mut rng = SplitMix64(p.seed); + let theta_max = 2.0 * p.j * p.dt; + (0..p.steps * n) + .map(|_| { + let a = (rng.next() % n as u64) as usize; + // Offset by 1..n-1 so `b != a` without a rejection loop, which would + // desynchronise the two implementations' draw counts. + let b = (a + 1 + (rng.next() % (n as u64 - 1)) as usize) % n; + let axis_a = AXES[(rng.next() % 3) as usize]; + let axis_b = AXES[(rng.next() % 3) as usize]; + Gate { + axis_a, + axis_b, + a, + b, + theta: theta_max * rng.unit(), + } + }) + .collect() +} + +fn env_f64(key: &str, default: f64) -> f64 { + std::env::var(key) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(default) +} + +fn env_usize(key: &str, default: usize) -> usize { + std::env::var(key) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(default) +} + +impl Params { + fn from_env() -> Self { + Params { + model: Model::parse(&std::env::var("MODEL").unwrap_or_else(|_| "tfim".to_string())), + steps: env_usize("STEPS", 10), + dt: env_f64("DT", 0.1), + j: env_f64("JCOUP", 1.0), + h: env_f64("HFIELD", 1.0), + atol: env_f64("ATOL", 1e-6), + iters: env_usize("ITERS", 3), + seed: env_usize("SEED", 12345) as u64, + } + } +} + +/// Smallest power-of-two byte width that holds `n` qubits with room to spare. +fn storage_bytes(n: usize) -> usize { + let need = n.div_ceil(8); + let mut k = 0; + while (1usize << k) <= need { + k += 1; + } + 1usize << k +} + +/// A single-site Pauli word `p` at `site`, identity elsewhere. +fn site_word(n: usize, site: usize, p: char) -> String { + (0..n).map(|j| if j == site { p } else { 'I' }).collect() +} + +type Cfg = config::fxhash::Byte>; + +/// Build the seed observable for `model` on `n` sites. +fn seed(n: usize, p: Params) -> PauliSum> { + let mut sum: PauliSum> = PauliSum::builder() + .n_qubits(n) + .strategy(CoefficientThreshold(p.atol)) + .capacity(1 << 12) + .build(); + match p.model { + Model::Tfim => { + for i in 0..n { + sum += (PauliWord::from(site_word(n, i, 'Z').as_str()), 1.0); + } + } + Model::Heisenberg | Model::Scramble => { + sum += (PauliWord::from(site_word(n, 0, 'Z').as_str()), 1.0); + } + } + sum +} + +/// Propagate the shared gate sequence through `state`. +fn propagate(state: &mut PauliSum>, n: usize, p: Params, gates: &[Gate]) { + let theta_bond = 2.0 * p.j * p.dt; + let theta_site = 2.0 * p.h * p.dt; + if p.model == Model::Scramble { + for g in gates { + state.rotate_2(g.axis_a, g.axis_b, g.a, g.b, g.theta); + state.truncate(); + } + return; + } + for _ in 0..p.steps { + match p.model { + Model::Tfim => { + for i in 0..n { + state.rx(i, theta_site); + state.truncate(); + } + for i in 0..n.saturating_sub(1) { + state.rzz(i, i + 1, theta_bond); + state.truncate(); + } + } + Model::Heisenberg => { + for i in 0..n.saturating_sub(1) { + state.rxx(i, i + 1, theta_bond); + state.truncate(); + state.ryy(i, i + 1, theta_bond); + state.truncate(); + state.rzz(i, i + 1, theta_bond); + state.truncate(); + } + for i in 0..n { + state.rz(i, theta_site); + state.truncate(); + } + } + Model::Scramble => unreachable!("handled above"), + } + } +} + +/// `⟨0…0|O|0…0⟩` for TFIM; the `Z_0` coefficient for Heisenberg. +/// +/// The diagonal contraction is spelled out rather than delegated: `⟨0|Z|0⟩ = 1` +/// and `⟨0|X|0⟩ = ⟨0|Y|0⟩ = 0`, so exactly the X-free terms survive and each +/// contributes its own coefficient. +fn readout(state: &PauliSum>, n: usize, p: Params) -> f64 { + match p.model { + Model::Tfim => state + .iter() + .filter(|(word, _)| (0..n).all(|i| !word.get_xbit(i))) + .map(|(_, c)| *c) + .sum(), + Model::Heisenberg | Model::Scramble => state + .data() + .get(&PauliWord::from(site_word(n, 0, 'Z').as_str())) + .copied() + .unwrap_or(0.0), + } +} + +/// Run one model at width `n`, returning `(best seconds, final support, observable)`. +fn run(n: usize, p: Params) -> (f64, usize, f64) { + let base = seed::(n, p); + let gates = if p.model == Model::Scramble { + scramble_gates(n, p) + } else { + Vec::new() + }; + let mut best = f64::INFINITY; + let mut terms = 0usize; + let mut observable = f64::NAN; + for _ in 0..p.iters { + let mut state = base.clone(); + let t0 = Instant::now(); + propagate(&mut state, n, p, &gates); + best = best.min(t0.elapsed().as_secs_f64()); + terms = state.len(); + observable = readout(&state, n, p); + } + (best, terms, observable) +} + +/// Print the whole propagated support as `word coefficient`, largest first — +/// the format the driver diffs across engines. +fn dump(n: usize, p: Params) { + let mut state = seed::(n, p); + let gates = if p.model == Model::Scramble { + scramble_gates(n, p) + } else { + Vec::new() + }; + propagate(&mut state, n, p, &gates); + let mut out: Vec<(String, f64)> = state.iter().map(|(k, c)| (k.to_string(), *c)).collect(); + out.sort_by(|a, b| { + b.1.abs() + .partial_cmp(&a.1.abs()) + .unwrap() + .then(a.0.cmp(&b.0)) + }); + println!("# {} terms", out.len()); + for (word, coeff) in out { + println!("{word} {coeff:+.12e}"); + } +} + +macro_rules! dispatch { + ($f:ident, $n:expr, $p:expr) => { + match storage_bytes($n) { + 2 => $f::<2>($n, $p), + 4 => $f::<4>($n, $p), + 8 => $f::<8>($n, $p), + 16 => $f::<16>($n, $p), + 32 => $f::<32>($n, $p), + 64 => $f::<64>($n, $p), + b => panic!("no storage tier for {b} bytes (n = {})", $n), + } + }; +} + +fn main() { + let p = Params::from_env(); + let qubits: Vec = std::env::var("QUBITS") + .ok() + .map(|s| s.split(',').filter_map(|t| t.trim().parse().ok()).collect()) + .unwrap_or_else(|| vec![8, 12, 16, 20, 24, 28, 32]); + + if std::env::var("DUMP").is_ok() { + let n = qubits[0]; + dispatch!(dump, n, p); + return; + } + + eprintln!( + "ppvm {}: steps={} dt={} J={} h={} atol={:e} iters={}", + p.model.name(), + p.steps, + p.dt, + p.j, + p.h, + p.atol, + p.iters + ); + + println!("model,library,qubits,steps,dt,atol,time_s,terms,observable"); + for &n in &qubits { + let (t, terms, obs) = dispatch!(run, n, p); + println!( + "{},ppvm,{},{},{},{:e},{:.6},{},{:.12e}", + p.model.name(), + n, + p.steps, + p.dt, + p.atol, + t, + terms, + obs + ); + eprintln!(" n={n:3} {t:9.4}s {terms:>9} terms obs={obs:+.9e}"); + use std::io::Write; + std::io::stdout().flush().ok(); + } +} diff --git a/crates/ppvm-python-native/src/lib.rs b/crates/ppvm-python-native/src/lib.rs index d78dd6655..b323c1adc 100644 --- a/crates/ppvm-python-native/src/lib.rs +++ b/crates/ppvm-python-native/src/lib.rs @@ -16,7 +16,9 @@ pub(crate) fn flat_pairs(targets: &[usize]) -> PyResult> { )); } Ok(targets - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .map(|pair| (pair[0], pair[1])) .collect()) } diff --git a/crates/ppvm-stim/src/executor.rs b/crates/ppvm-stim/src/executor.rs index 592d31e5d..7254786e6 100644 --- a/crates/ppvm-stim/src/executor.rs +++ b/crates/ppvm-stim/src/executor.rs @@ -44,7 +44,9 @@ fn qubits(targets: &[Target]) -> SmallVec<[usize; TARGETS_INLINE]> { /// Only valid when no target is a measurement record (see [`has_record_control`]). fn qubit_pairs(targets: &[Target]) -> SmallVec<[(usize, usize); TARGETS_INLINE / 2]> { targets - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .map(|p| (qubit(p[0]), qubit(p[1]))) .collect() } @@ -575,7 +577,7 @@ pub fn execute_validated( // With no record control present, keep the batched fast path. GateName::CX | GateName::ZCX | GateName::CNot => { if has_record_control(targets) { - for p in targets.chunks_exact(2) { + for p in targets.as_chunks::<2>().0 { match p[0] { Target::Qubit(c) => tab.cnot(c, qubit(p[1])), Target::Rec(k) => { @@ -591,7 +593,7 @@ pub fn execute_validated( } GateName::CY | GateName::ZCY => { if has_record_control(targets) { - for p in targets.chunks_exact(2) { + for p in targets.as_chunks::<2>().0 { match p[0] { Target::Qubit(c) => tab.cy(c, qubit(p[1])), Target::Rec(k) => { @@ -607,7 +609,7 @@ pub fn execute_validated( } GateName::CZ | GateName::ZCZ => { if has_record_control(targets) { - for p in targets.chunks_exact(2) { + for p in targets.as_chunks::<2>().0 { match p[0] { Target::Qubit(c) => tab.cz(c, qubit(p[1])), Target::Rec(k) => { diff --git a/crates/ppvm-stim/src/validate.rs b/crates/ppvm-stim/src/validate.rs index be5605823..e614821ad 100644 --- a/crates/ppvm-stim/src/validate.rs +++ b/crates/ppvm-stim/src/validate.rs @@ -163,7 +163,7 @@ fn check_record_controls( } // Targets come in (control, target) pairs; the target may never be a record, // and a control may never look back past the start of the record. - for pair in targets.chunks_exact(2) { + for pair in targets.as_chunks::<2>().0 { if matches!(pair[1], Target::Rec(_)) { return Err(ExecError::InvalidRecordControl { name: name.canonical_name().to_string(), diff --git a/crates/ppvm-tableau-sum/benches/msd.rs b/crates/ppvm-tableau-sum/benches/msd.rs index aba8a44cc..dddf40c52 100644 --- a/crates/ppvm-tableau-sum/benches/msd.rs +++ b/crates/ppvm-tableau-sum/benches/msd.rs @@ -53,7 +53,12 @@ fn msd() -> Sampler, u128> { let mut tab: GTabSum = GeneralizedTableauSum::new(n_qubits, 1e-10, 1e-8); let qubit_addrs: Vec = (0..n_qubits).collect(); - let ql: Vec<&[usize]> = qubit_addrs.chunks_exact(17).collect(); + let ql: Vec<&[usize]> = qubit_addrs + .as_chunks::<17>() + .0 + .iter() + .map(|chunk| &chunk[..]) + .collect(); // Phase 1: Encoding (H + T + encode per block) for q in ql.iter() { diff --git a/crates/ppvm-tableau-sum/examples/msd-noisy-bench.rs b/crates/ppvm-tableau-sum/examples/msd-noisy-bench.rs index 11e7f018a..f2165d1a4 100644 --- a/crates/ppvm-tableau-sum/examples/msd-noisy-bench.rs +++ b/crates/ppvm-tableau-sum/examples/msd-noisy-bench.rs @@ -97,7 +97,12 @@ fn build(seed: u64) -> GTabSum { let mut tab: GTabSum = GeneralizedTableauSum::new_with_seed(n_qubits, 1e-10, sum_cutoff, seed); let qubit_addrs: Vec = (0..n_qubits).collect(); - let ql: Vec<&[usize]> = qubit_addrs.chunks_exact(17).collect(); + let ql: Vec<&[usize]> = qubit_addrs + .as_chunks::<17>() + .0 + .iter() + .map(|chunk| &chunk[..]) + .collect(); for q in ql.iter() { let encoding_qubit = q[7]; diff --git a/crates/ppvm-tableau-sum/examples/msd-noisy-compare.rs b/crates/ppvm-tableau-sum/examples/msd-noisy-compare.rs index 1a8ed284a..335fb86fe 100644 --- a/crates/ppvm-tableau-sum/examples/msd-noisy-compare.rs +++ b/crates/ppvm-tableau-sum/examples/msd-noisy-compare.rs @@ -250,19 +250,32 @@ fn l1_distance_stats(a: &[[f64; 3]], b: &[[f64; 3]]) -> (f64, f64) { (max, mean) } -/// Run one sweep at fixed noise rate `p`. Builds the pure baseline and an -/// alt-seed pure run (for the shot-noise floor), then sweeps `cutoffs` on -/// the sum backend. Prints a comparison table. -fn run_sweep( - label: &str, +struct RunSweepArgs<'a> { + label: &'a str, n_qubits: usize, n_shots: usize, p: f64, - cutoffs: &[f64], + cutoffs: &'a [f64], pure_seed: u64, sum_seed: u64, - ql: &[&[usize]], -) { + ql: &'a [&'a [usize]], +} + +/// Run one sweep at fixed noise rate `p`. Builds the pure baseline and an +/// alt-seed pure run (for the shot-noise floor), then sweeps `cutoffs` on +/// the sum backend. Prints a comparison table. +fn run_sweep(args: RunSweepArgs) { + let RunSweepArgs { + label, + n_qubits, + n_shots, + p, + cutoffs, + pure_seed, + sum_seed, + ql, + } = args; + println!("\n========================================================"); println!("Sweep: {label}"); println!(" p_loss = p_depolarize = {p:.0e}"); @@ -353,7 +366,12 @@ fn main() { let sum_seed: u64 = 0x00C0_FFEE; let qubit_addrs: Vec = (0..n_qubits).collect(); - let ql: Vec<&[usize]> = qubit_addrs.chunks_exact(17).collect(); + let ql: Vec<&[usize]> = qubit_addrs + .as_chunks::<17>() + .0 + .iter() + .map(|chunk| &chunk[..]) + .collect(); println!("MSD-noisy comparison: GeneralizedTableauSum vs GeneralizedTableau"); println!(" n_qubits = {n_qubits}"); @@ -378,21 +396,21 @@ fn main() { // safe regime. The convergence in this regime requires cutoff far // below p^2 and is combinatorially out of reach; included for // comparison only. - run_sweep( - "msd-noisy parameters (p = 1e-4)", + run_sweep(RunSweepArgs { + label: "msd-noisy parameters (p = 1e-4)", n_qubits, n_shots, - 1e-4, - &[1e-3, 5e-4, 1e-4, 1e-5, 1e-6, 1e-7, 5e-8], + p: 1e-4, + cutoffs: &[1e-3, 5e-4, 1e-4, 1e-5, 1e-6, 1e-7, 5e-8], pure_seed, sum_seed, - &ql, - ); - run_sweep( - "amplified noise (p = 1e-2)", + ql: &ql, + }); + run_sweep(RunSweepArgs { + label: "amplified noise (p = 1e-2)", n_qubits, n_shots, - 1e-2, + p: 1e-2, // p^2 = 1e-4 is the boundary mentioned in the script's preamble: // - cutoffs > p (1e-1, 1e-2) drop single-error structure → 1 branch // - cutoffs in (p^2, p) keep first-order branches (~789..2025) @@ -405,9 +423,9 @@ fn main() { // cutoff ≪ (p/3)^3 ≈ 4e-8 to also capture three-error events, // which is combinatorially out of reach. With this list the full // example takes ~90s in release on an 8-core laptop. - &[1e-1, 1e-2, 1e-3, 1e-4, 5e-5, 1e-5], + cutoffs: &[1e-1, 1e-2, 1e-3, 1e-4, 5e-5, 1e-5], pure_seed, sum_seed, - &ql, - ); + ql: &ql, + }); } diff --git a/crates/ppvm-tableau-sum/examples/msd-noisy-map.rs b/crates/ppvm-tableau-sum/examples/msd-noisy-map.rs index 34d2aaf3f..f1798ca2e 100644 --- a/crates/ppvm-tableau-sum/examples/msd-noisy-map.rs +++ b/crates/ppvm-tableau-sum/examples/msd-noisy-map.rs @@ -96,7 +96,12 @@ fn main() { let mut tab: GTabSum = GeneralizedTableauSum::new(n_qubits, 1e-10, sum_cutoff); let qubit_addrs: Vec = (0..n_qubits).collect(); - let ql: Vec<&[usize]> = qubit_addrs.chunks_exact(17).collect(); + let ql: Vec<&[usize]> = qubit_addrs + .as_chunks::<17>() + .0 + .iter() + .map(|chunk| &chunk[..]) + .collect(); // Phase 1: Encoding (H + T + encode per block) for q in ql.iter() { diff --git a/crates/ppvm-tableau-sum/examples/msd-noisy.rs b/crates/ppvm-tableau-sum/examples/msd-noisy.rs index d4299d7d9..cbf016c6c 100644 --- a/crates/ppvm-tableau-sum/examples/msd-noisy.rs +++ b/crates/ppvm-tableau-sum/examples/msd-noisy.rs @@ -94,7 +94,12 @@ fn main() { let mut tab: GTabSum = GeneralizedTableauSum::new(n_qubits, 1e-10, sum_cutoff); let qubit_addrs: Vec = (0..n_qubits).collect(); - let ql: Vec<&[usize]> = qubit_addrs.chunks_exact(17).collect(); + let ql: Vec<&[usize]> = qubit_addrs + .as_chunks::<17>() + .0 + .iter() + .map(|chunk| &chunk[..]) + .collect(); // Phase 1: Encoding (H + T + encode per block) for q in ql.iter() { diff --git a/crates/ppvm-tableau-sum/examples/msd.rs b/crates/ppvm-tableau-sum/examples/msd.rs index b20f9a351..282ce925a 100644 --- a/crates/ppvm-tableau-sum/examples/msd.rs +++ b/crates/ppvm-tableau-sum/examples/msd.rs @@ -50,7 +50,12 @@ fn main() { let mut tab: GTabSum = GeneralizedTableauSum::new(n_qubits, 1e-10, 1e-8); let qubit_addrs: Vec = (0..n_qubits).collect(); - let ql: Vec<&[usize]> = qubit_addrs.chunks_exact(17).collect(); + let ql: Vec<&[usize]> = qubit_addrs + .as_chunks::<17>() + .0 + .iter() + .map(|chunk| &chunk[..]) + .collect(); // Phase 1: Encoding (H + T + encode per block) for q in ql.iter() { diff --git a/crates/ppvm-tableau-sum/examples/truncation-scaling.rs b/crates/ppvm-tableau-sum/examples/truncation-scaling.rs index 8e1f4df9e..32825687c 100644 --- a/crates/ppvm-tableau-sum/examples/truncation-scaling.rs +++ b/crates/ppvm-tableau-sum/examples/truncation-scaling.rs @@ -89,7 +89,7 @@ fn apply_layer( tab.depolarize1(q, p_depolarize); } - let pairs: &[(usize, usize)] = if layer_idx % 2 == 0 { + let pairs: &[(usize, usize)] = if layer_idx.is_multiple_of(2) { &[(0, 1), (2, 3)] } else { &[(1, 2)] @@ -188,7 +188,7 @@ fn pure_trajectory_shots( .collect() } -fn run_main_sweep( +struct MainSweepConfig<'a> { n_qubits: usize, depth: usize, p: f64, @@ -196,8 +196,20 @@ fn run_main_sweep( circuit_seed: u64, sum_seed: u64, reference_cutoff: f64, - cutoffs: &[f64], -) { + cutoffs: &'a [f64], +} + +fn run_main_sweep(config: MainSweepConfig<'_>) { + let MainSweepConfig { + n_qubits, + depth, + p, + n_shots, + circuit_seed, + sum_seed, + reference_cutoff, + cutoffs, + } = config; println!("\n========================================================"); println!("Main sweep: random brickwork (Clifford + T), n={n_qubits}, depth={depth}, p={p:.0e}"); println!( @@ -366,16 +378,16 @@ fn main() { // p = 0.01: main sweep — orders 1..~5 visible analytically, orders 1..2 // resolvable by sampling at 1e6 shots. - run_main_sweep( + run_main_sweep(MainSweepConfig { n_qubits, depth, - 1e-2, + p: 1e-2, n_shots, circuit_seed, sum_seed, - 1e-14, - &[1e-1, 1e-2, 1e-3, 1e-4, 1e-5, 1e-6, 1e-7, 1e-8, 1e-9, 1e-10], - ); + reference_cutoff: 1e-14, + cutoffs: &[1e-1, 1e-2, 1e-3, 1e-4, 1e-5, 1e-6, 1e-7, 1e-8, 1e-9, 1e-10], + }); // p = 1e-3: realistic regime, analytic only. At p=1e-3 the dropped // mass at cutoff = 1e-4 is already ~p^? ; see what the sweep shows. diff --git a/crates/ppvm-tableau/examples/profile_measure_all.rs b/crates/ppvm-tableau/examples/profile_measure_all.rs index 9aa135c60..ee55bffcb 100644 --- a/crates/ppvm-tableau/examples/profile_measure_all.rs +++ b/crates/ppvm-tableau/examples/profile_measure_all.rs @@ -174,10 +174,10 @@ fn main() { let mut per_qubit_runs: Vec> = vec![Vec::with_capacity(n_runs); n]; for _ in 0..n_runs { let mut t = base.fork(Some(42)); - for q in 0..n { + for (q, runs) in per_qubit_runs.iter_mut().enumerate() { let start = Instant::now(); let _ = LossyMeasure::measure(&mut t, q); - per_qubit_runs[q].push(start.elapsed()); + runs.push(start.elapsed()); } } let per_qubit_medians: Vec = per_qubit_runs.into_iter().map(median).collect(); diff --git a/crates/ppvm-tableau/examples/profile_measure_all_flame.rs b/crates/ppvm-tableau/examples/profile_measure_all_flame.rs index 9014377bd..3d043c2cc 100644 --- a/crates/ppvm-tableau/examples/profile_measure_all_flame.rs +++ b/crates/ppvm-tableau/examples/profile_measure_all_flame.rs @@ -21,8 +21,8 @@ //! - `compute_decomposition` cost //! - `update_tableau_according_to_outcome` cost //! - HashMap traffic in the case-a path -//! And a small adjacent subtree for `fork` (expect ~1% based on the -//! instrumented run). +//! And a small adjacent subtree for `fork` (expect ~1% based on the +//! instrumented run). //! //! NOTE: the MSD setup is duplicated from `profile_measure_all.rs` — fine for //! examples, hoist into a shared module if we keep growing these. diff --git a/crates/ppvm-tableau/examples/profile_msd.rs b/crates/ppvm-tableau/examples/profile_msd.rs index 2f4f1b7ef..5afa8c392 100644 --- a/crates/ppvm-tableau/examples/profile_msd.rs +++ b/crates/ppvm-tableau/examples/profile_msd.rs @@ -60,7 +60,12 @@ fn main() { let mut tab: Tab = GeneralizedTableau::new(n_qubits, 1e-10); let qubit_addrs: Vec = (0..n_qubits).collect(); - let ql: Vec<&[usize]> = qubit_addrs.chunks_exact(17).collect(); + let ql: Vec<&[usize]> = qubit_addrs + .as_chunks::<17>() + .0 + .iter() + .map(|chunk| &chunk[..]) + .collect(); // Phase 1: Encoding (H + T + encode per block) let t0 = Instant::now(); diff --git a/crates/ppvm-tableau/src/data.rs b/crates/ppvm-tableau/src/data.rs index 7b8d008db..e96209f60 100644 --- a/crates/ppvm-tableau/src/data.rs +++ b/crates/ppvm-tableau/src/data.rs @@ -1565,7 +1565,7 @@ mod tests { tab.cnot(0, 1); tab.ry(2, 0.7); // non-Clifford: branches the coefficient vector assert!( - tab.coefficients.iter().count() > 1, + tab.coefficients.len() > 1, "rotation should branch the coefficient vector" ); @@ -1575,8 +1575,8 @@ mod tests { snapshot_tableau(&tab.tableau), snapshot_tableau(&fresh.tableau) ); - let coeffs: Vec<_> = tab.coefficients.iter().copied().collect(); - let fresh_coeffs: Vec<_> = fresh.coefficients.iter().copied().collect(); + let coeffs: Vec<_> = tab.coefficients.to_vec(); + let fresh_coeffs: Vec<_> = fresh.coefficients.to_vec(); assert_eq!(coeffs, fresh_coeffs); } diff --git a/crates/ppvm-tableau/tests/msd_batch.rs b/crates/ppvm-tableau/tests/msd_batch.rs index 292f06406..28bac8dc7 100644 --- a/crates/ppvm-tableau/tests/msd_batch.rs +++ b/crates/ppvm-tableau/tests/msd_batch.rs @@ -78,7 +78,12 @@ fn run_msd_naive(seed: u64) -> String { let n = 85; let mut tab: Tab = GeneralizedTableau::new_with_seed(n, 1e-10, seed); let qa: Vec = (0..n).collect(); - let ql: Vec<&[usize]> = qa.chunks_exact(17).collect(); + let ql: Vec<&[usize]> = qa + .as_chunks::<17>() + .0 + .iter() + .map(|chunk| &chunk[..]) + .collect(); for q in &ql { tab.h(q[7]); @@ -131,7 +136,12 @@ fn run_msd_batch(seed: u64) -> String { let n = 85; let mut tab: Tab = GeneralizedTableau::new_with_seed(n, 1e-10, seed); let qa: Vec = (0..n).collect(); - let ql: Vec<&[usize]> = qa.chunks_exact(17).collect(); + let ql: Vec<&[usize]> = qa + .as_chunks::<17>() + .0 + .iter() + .map(|chunk| &chunk[..]) + .collect(); for q in &ql { tab.h(q[7]); diff --git a/crates/ppvm-tableau/tests/tableau.rs b/crates/ppvm-tableau/tests/tableau.rs index 215f35517..aad4d15d7 100644 --- a/crates/ppvm-tableau/tests/tableau.rs +++ b/crates/ppvm-tableau/tests/tableau.rs @@ -57,7 +57,7 @@ fn generalized_tableau() { assert_eq!(tableau.coefficients.len(), 2); let mut sorted_coefficients = tableau.coefficients.clone(); - sorted_coefficients.sort_by(|entry1, entry2| entry1.1.cmp(&entry2.1)); + sorted_coefficients.sort_by_key(|entry| entry.1); const PI: f64 = std::f64::consts::PI; let cos_pi_8: f64 = (PI / 8.0).cos(); @@ -94,7 +94,7 @@ fn test_generalized_tableau_phase() { tableau.t(0); let mut sorted_coefficients = tableau.coefficients.clone(); - sorted_coefficients.sort_by(|entry1, entry2| entry1.1.cmp(&entry2.1)); + sorted_coefficients.sort_by_key(|entry| entry.1); let expected_coefficients = [Complex { re: 0.5, im: 0.5 }, Complex { re: 0.5, im: -0.5 }]; @@ -116,7 +116,7 @@ fn test_generalized_tableau_phase() { tableau.t(0); let mut sorted_coefficients = tableau.coefficients.clone(); - sorted_coefficients.sort_by(|entry1, entry2| entry1.1.cmp(&entry2.1)); + sorted_coefficients.sort_by_key(|entry| entry.1); let expected_coefficients = [ Complex { @@ -377,7 +377,7 @@ fn test_two_t_gates_coefficients() { assert_eq!(tableau.coefficients.len(), 2); let mut sorted = tableau.coefficients.clone(); - sorted.sort_by(|a, b| a.1.cmp(&b.1)); + sorted.sort_by_key(|entry| entry.1); // Expected: TT|+⟩ represented as two branches with these coefficients let expected = [Complex { re: 0.5, im: 0.5 }, Complex { re: 0.5, im: -0.5 }]; diff --git a/crates/ppvm-traits/src/traits/ptm.rs b/crates/ppvm-traits/src/traits/ptm.rs index 457f705d2..f47fb5aa3 100644 --- a/crates/ppvm-traits/src/traits/ptm.rs +++ b/crates/ppvm-traits/src/traits/ptm.rs @@ -1,2 +1,4 @@ // SPDX-FileCopyrightText: 2026 The PPVM Authors // SPDX-License-Identifier: Apache-2.0 + +//! Pauli transfer matrix traits. diff --git a/crates/ppvm-tui/src/app.rs b/crates/ppvm-tui/src/app.rs index 215f4f21c..d4a768a76 100644 --- a/crates/ppvm-tui/src/app.rs +++ b/crates/ppvm-tui/src/app.rs @@ -36,7 +36,7 @@ pub struct AppState { /// True while a program is loaded (Program panel) vs a REPL session (Log). has_program: bool, /// True while the debugger is stopped and waiting for input (at start, after - /// a step, or at a breakpoint). + /// a step, or at a cpu::cpu.breakpoint). paused: bool, /// True once the loaded program has run to Return/Halt. finished: bool, @@ -236,7 +236,7 @@ impl AppState { } StepOutcome::Breakpoint => { self.paused = true; - self.set_status("-- breakpoint hit --"); + self.set_status("-- cpu::cpu.breakpoint hit --"); } StepOutcome::Return | StepOutcome::Halt => { self.paused = false; @@ -459,7 +459,7 @@ Meta / debug device N create a fresh N-qubit tableau device :load load a .sst / .ssb program (paused at start) Enter (empty) :s step one instruction - :continue :c run to the next breakpoint or the end + :continue :c run to the next cpu::cpu.breakpoint or the end :reset restart the loaded program / device :help :h toggle this help :quit :q (Ctrl-C) leave @@ -471,7 +471,7 @@ Gates (q = qubit index; angles / probabilities are floats) u3 rxx ryy rzz depolarize loss

depolarize2

- paulierror correlatedloss + pauli_error correlated_loss Line editing: ←/→ move · Home/End · Backspace/Del · ↑/↓ history"; @@ -602,9 +602,9 @@ mod tests { assert!(app.should_exit); } - /// A 1-qubit program with a breakpoint before measuring q0 (|0> -> 0). + /// A 1-qubit program with a cpu::cpu.breakpoint before measuring q0 (|0> -> 0). const BP_PROGRAM: &str = "device circuit.n_qubits 1;\n\ - fn @main() { breakpoint\n const.u64 0\n circuit.measure\n ret }\n"; + fn @main() { cpu::cpu.breakpoint\n cpu::cpu.const u64, 0\n circuit::circuit.measure\n cpu::cpu.ret 0 }\n"; #[test] fn load_source_starts_paused_with_a_listing() { @@ -624,7 +624,7 @@ mod tests { app.load_source(BP_PROGRAM).unwrap(); app.dispatch(":c"); assert!( - app.status().contains("breakpoint"), + app.status().contains("cpu::cpu.breakpoint"), "status: {}", app.status() ); @@ -642,7 +642,7 @@ mod tests { fn finishing_clears_paused() { let mut app = AppState::new(); app.load_source(BP_PROGRAM).unwrap(); - app.dispatch(":c"); // pause at breakpoint + app.dispatch(":c"); // pause at cpu::cpu.breakpoint assert!(app.paused()); app.dispatch(":c"); // run to Return assert!(!app.paused(), "paused must clear once the program finishes"); @@ -652,7 +652,7 @@ mod tests { fn finished_hint_does_not_suggest_stepping() { let mut app = AppState::new(); app.load_source(BP_PROGRAM).unwrap(); - app.dispatch(":c"); // pause at breakpoint + app.dispatch(":c"); // pause at cpu::cpu.breakpoint app.dispatch(":c"); // run to Return assert!( !app.hint().contains("step") && !app.hint().contains("continue"), @@ -686,11 +686,11 @@ mod tests { #[test] fn inject_gate_at_breakpoint_then_resume() { - // At the breakpoint, inject X on q0; resuming, the program measures |1>. + // At the cpu::cpu.breakpoint, inject X on q0; resuming, the program measures |1>. let mut app = AppState::new(); app.load_source(BP_PROGRAM).unwrap(); - app.dispatch(":c"); // run to the breakpoint - assert!(app.status().contains("breakpoint")); + app.dispatch(":c"); // run to the cpu::cpu.breakpoint + assert!(app.status().contains("cpu::cpu.breakpoint")); app.dispatch("x 0"); // inject while paused app.dispatch(":c"); // resume; program measures q0 assert!( diff --git a/crates/ppvm-tui/src/command.rs b/crates/ppvm-tui/src/command.rs index 0cea9ba15..75fc30000 100644 --- a/crates/ppvm-tui/src/command.rs +++ b/crates/ppvm-tui/src/command.rs @@ -48,8 +48,8 @@ pub fn gate_spec(name: &str) -> Option { "depolarize" => (Depolarize, 1, 1), "depolarize2" => (Depolarize2, 2, 1), "loss" => (Loss, 1, 1), - "paulierror" => (PauliError, 1, 3), - "correlatedloss" => (CorrelatedLoss, 2, 3), + "pauli_error" => (PauliError, 1, 3), + "correlated_loss" => (CorrelatedLoss, 2, 3), _ => return None, }; Some(GateSpec { @@ -72,7 +72,7 @@ pub enum Command { }, /// Advance one instruction (also the meaning of an empty line). Step, - /// Run to the next breakpoint or program end. + /// Run to the next cpu::cpu.breakpoint or program end. Continue, /// Reset the loaded program / device to its initial state. Reset, diff --git a/crates/ppvm-tui/src/widgets.rs b/crates/ppvm-tui/src/widgets.rs index f0dcf0cea..5a18a1d03 100644 --- a/crates/ppvm-tui/src/widgets.rs +++ b/crates/ppvm-tui/src/widgets.rs @@ -93,7 +93,7 @@ mod tests { use ratatui::backend::TestBackend; const BP_PROGRAM: &str = "device circuit.n_qubits 1;\n\ - fn @main() { breakpoint\n const.u64 0\n circuit.measure\n ret }\n"; + fn @main() { cpu::cpu.breakpoint\n cpu::cpu.const u64, 0\n circuit::circuit.measure\n cpu::cpu.ret 0 }\n"; #[test] fn renders_all_panels_without_panic() { diff --git a/crates/ppvm-vihaco/Cargo.toml b/crates/ppvm-vihaco/Cargo.toml index 9abb13991..fa9ecd110 100644 --- a/crates/ppvm-vihaco/Cargo.toml +++ b/crates/ppvm-vihaco/Cargo.toml @@ -19,9 +19,9 @@ num = "0.4.3" rayon = { version = "1.10", optional = true } ppvm-tableau = { version = "0.1.0", path = "../ppvm-tableau" } smallvec = "1.15.1" -vihaco = "0.1.1" -vihaco-cpu = "0.1.1" -vihaco-parser = "0.1.1" -vihaco-parser-core = "0.1.1" +vihaco = "0.4.0" +vihaco-cpu = "0.4.0" +vihaco-parser = "0.4.0" +vihaco-parser-derive = "0.4.0" vihaco-circuit-isa = { version = "0.1.0", path = "../vihaco-circuit-isa" } ppvm-pauli-sum = { version = "0.1.0", path = "../ppvm-pauli-sum" } diff --git a/crates/ppvm-vihaco/src/bytecode.rs b/crates/ppvm-vihaco/src/bytecode.rs index ef1153716..6cf376321 100644 --- a/crates/ppvm-vihaco/src/bytecode.rs +++ b/crates/ppvm-vihaco/src/bytecode.rs @@ -12,12 +12,344 @@ use std::io::{Read, Write}; use vihaco::instruction::{FromBytes, WriteBytes}; +use vihaco::module::{FunctionInfo, LabelInfo, Parameter, Signature}; +use vihaco::{Type, Value}; +use vihaco_cpu::RuntimeInstruction as CpuInstruction; use crate::PPVMModule; use crate::composite::{BackendKind, PPVM_MAGIC, PPVMDeviceInfo, PPVMInstruction}; +#[derive(Debug, Clone, vihaco::Instruction)] +enum BytecodeType { + Undefined, + String, + Bool, + I64, + U32, + U64, + F64, + FunctionRef, + HeapRef, +} + +#[derive(Debug, Clone, vihaco::Instruction)] +enum BytecodeValue { + Undefined, + String(u32), + Bool(bool), + I64(i64), + U32(u32), + U64(u64), + F64(f64), + FunctionRef(u32), + HeapRef(u32), +} + +/// Makes the boilerplate encode and decode functions for moving between +/// {Type|Value} and Bytecode{Type|Value}. +macro_rules! define_bytecode_codec { + ( + $source:ident, $encoded:ident, $encode:ident, $decode:ident; + $($variant:ident $(($binding:ident))?),+ $(,)? + ) => { + fn $encode(value: $source) -> $encoded { + match value { + $( + $source::$variant $(($binding))? => + $encoded::$variant $(($binding))?, + )+ + } + } + + fn $decode(value: $encoded) -> $source { + match value { + $( + $encoded::$variant $(($binding))? => + $source::$variant $(($binding))?, + )+ + } + } + }; +} + +define_bytecode_codec! { + Type, BytecodeType, encode_type, decode_type; + Undefined, String, Bool, I64, U32, U64, F64, FunctionRef, HeapRef, +} + +define_bytecode_codec! { + Value, BytecodeValue, encode_value, decode_value; + Undefined, String(v), Bool(v), I64(v), U32(v), U64(v), F64(v), FunctionRef(v), HeapRef(v), +} + +#[derive(Debug, Clone, vihaco::Instruction)] +enum BytecodeCpu { + Span(u32, u32, u32), + FunctionStart, + FunctionEnd, + Breakpoint, + Branch(u32), + ConditionalBranch(u32, u32), + Return(u32), + IndirectCall, + Call(u32, u32), + Halt, + Print, + Load(BytecodeType, u32), + Store(BytecodeType, u32), + Dup, + HeapAlloc(u32), + GetItem, + HeapDealloc, + Const(BytecodeType, BytecodeValue), + Add(BytecodeType), + Sub(BytecodeType), + Mul(BytecodeType), + Div(BytecodeType), + Rem(BytecodeType), + Neg(BytecodeType), + Shl(BytecodeType), + Shr(BytecodeType), + Rol(BytecodeType), + Ror(BytecodeType), + BitAnd(BytecodeType), + BitOr(BytecodeType), + BitXor(BytecodeType), + Not, + And, + Or, + Xor, + Eq(BytecodeType), + Ne(BytecodeType), + Lt(BytecodeType), + Gt(BytecodeType), + Le(BytecodeType), + Ge(BytecodeType), +} + +#[allow(clippy::upper_case_acronyms)] +#[derive(Debug, Clone, vihaco::Instruction)] +enum BytecodeCircuit { + TwoQubitPauliError, + Truncate, + Trace, + X, + Y, + Z, + H, + SqrtXAdj, + SqrtX, + SqrtYAdj, + SqrtY, + SAdj, + S, + CNOT, + CZ, + TAdj, + T, + RXX, + RYY, + RZZ, + RX, + RY, + RZ, + U3, + Measure, + Reset, + R, + Loss, + CorrelatedLoss, + PauliError, + Depolarize2, + Depolarize, +} + +#[derive(Debug, Clone, vihaco::Instruction)] +enum BytecodeInstruction { + Cpu(BytecodeCpu), + Circuit(BytecodeCircuit), +} + +fn encode_instruction(inst: &PPVMInstruction) -> eyre::Result { + let encoded = match inst { + PPVMInstruction::Cpu(inst) => BytecodeInstruction::Cpu(match inst { + CpuInstruction::Span(a, b, c) => BytecodeCpu::Span(*a, *b, *c), + CpuInstruction::FunctionStart => BytecodeCpu::FunctionStart, + CpuInstruction::FunctionEnd => BytecodeCpu::FunctionEnd, + CpuInstruction::Breakpoint => BytecodeCpu::Breakpoint, + CpuInstruction::Branch(v) => BytecodeCpu::Branch(*v), + CpuInstruction::ConditionalBranch(a, b) => BytecodeCpu::ConditionalBranch(*a, *b), + CpuInstruction::Return(v) => BytecodeCpu::Return(*v), + CpuInstruction::IndirectCall => BytecodeCpu::IndirectCall, + CpuInstruction::Call(a, b) => BytecodeCpu::Call(*a, *b), + CpuInstruction::Halt => BytecodeCpu::Halt, + CpuInstruction::Print => BytecodeCpu::Print, + CpuInstruction::Load(t, v) => BytecodeCpu::Load(encode_type(*t), *v), + CpuInstruction::Store(t, v) => BytecodeCpu::Store(encode_type(*t), *v), + CpuInstruction::Dup => BytecodeCpu::Dup, + CpuInstruction::HeapAlloc(v) => BytecodeCpu::HeapAlloc(*v), + CpuInstruction::GetItem => BytecodeCpu::GetItem, + CpuInstruction::HeapDealloc => BytecodeCpu::HeapDealloc, + CpuInstruction::Const(t, v) => BytecodeCpu::Const(encode_type(*t), encode_value(*v)), + CpuInstruction::Add(t) => BytecodeCpu::Add(encode_type(*t)), + CpuInstruction::Sub(t) => BytecodeCpu::Sub(encode_type(*t)), + CpuInstruction::Mul(t) => BytecodeCpu::Mul(encode_type(*t)), + CpuInstruction::Div(t) => BytecodeCpu::Div(encode_type(*t)), + CpuInstruction::Rem(t) => BytecodeCpu::Rem(encode_type(*t)), + CpuInstruction::Neg(t) => BytecodeCpu::Neg(encode_type(*t)), + CpuInstruction::Shl(t) => BytecodeCpu::Shl(encode_type(*t)), + CpuInstruction::Shr(t) => BytecodeCpu::Shr(encode_type(*t)), + CpuInstruction::Rol(t) => BytecodeCpu::Rol(encode_type(*t)), + CpuInstruction::Ror(t) => BytecodeCpu::Ror(encode_type(*t)), + CpuInstruction::BitAnd(t) => BytecodeCpu::BitAnd(encode_type(*t)), + CpuInstruction::BitOr(t) => BytecodeCpu::BitOr(encode_type(*t)), + CpuInstruction::BitXor(t) => BytecodeCpu::BitXor(encode_type(*t)), + CpuInstruction::Not => BytecodeCpu::Not, + CpuInstruction::And => BytecodeCpu::And, + CpuInstruction::Or => BytecodeCpu::Or, + CpuInstruction::Xor => BytecodeCpu::Xor, + CpuInstruction::Eq(t) => BytecodeCpu::Eq(encode_type(*t)), + CpuInstruction::Ne(t) => BytecodeCpu::Ne(encode_type(*t)), + CpuInstruction::Lt(t) => BytecodeCpu::Lt(encode_type(*t)), + CpuInstruction::Gt(t) => BytecodeCpu::Gt(encode_type(*t)), + CpuInstruction::Le(t) => BytecodeCpu::Le(encode_type(*t)), + CpuInstruction::Ge(t) => BytecodeCpu::Ge(encode_type(*t)), + CpuInstruction::Label(_) => { + return Err(eyre::eyre!( + "runtime labels are not serializable in PPVM bytecode" + )); + } + }), + PPVMInstruction::Circuit(inst) => BytecodeInstruction::Circuit(match inst { + vihaco_circuit_isa::CircuitInstruction::TwoQubitPauliError => { + BytecodeCircuit::TwoQubitPauliError + } + vihaco_circuit_isa::CircuitInstruction::Truncate => BytecodeCircuit::Truncate, + vihaco_circuit_isa::CircuitInstruction::Trace => BytecodeCircuit::Trace, + vihaco_circuit_isa::CircuitInstruction::X => BytecodeCircuit::X, + vihaco_circuit_isa::CircuitInstruction::Y => BytecodeCircuit::Y, + vihaco_circuit_isa::CircuitInstruction::Z => BytecodeCircuit::Z, + vihaco_circuit_isa::CircuitInstruction::H => BytecodeCircuit::H, + vihaco_circuit_isa::CircuitInstruction::SqrtXAdj => BytecodeCircuit::SqrtXAdj, + vihaco_circuit_isa::CircuitInstruction::SqrtX => BytecodeCircuit::SqrtX, + vihaco_circuit_isa::CircuitInstruction::SqrtYAdj => BytecodeCircuit::SqrtYAdj, + vihaco_circuit_isa::CircuitInstruction::SqrtY => BytecodeCircuit::SqrtY, + vihaco_circuit_isa::CircuitInstruction::SAdj => BytecodeCircuit::SAdj, + vihaco_circuit_isa::CircuitInstruction::S => BytecodeCircuit::S, + vihaco_circuit_isa::CircuitInstruction::CNOT => BytecodeCircuit::CNOT, + vihaco_circuit_isa::CircuitInstruction::CZ => BytecodeCircuit::CZ, + vihaco_circuit_isa::CircuitInstruction::TAdj => BytecodeCircuit::TAdj, + vihaco_circuit_isa::CircuitInstruction::T => BytecodeCircuit::T, + vihaco_circuit_isa::CircuitInstruction::RXX => BytecodeCircuit::RXX, + vihaco_circuit_isa::CircuitInstruction::RYY => BytecodeCircuit::RYY, + vihaco_circuit_isa::CircuitInstruction::RZZ => BytecodeCircuit::RZZ, + vihaco_circuit_isa::CircuitInstruction::RX => BytecodeCircuit::RX, + vihaco_circuit_isa::CircuitInstruction::RY => BytecodeCircuit::RY, + vihaco_circuit_isa::CircuitInstruction::RZ => BytecodeCircuit::RZ, + vihaco_circuit_isa::CircuitInstruction::U3 => BytecodeCircuit::U3, + vihaco_circuit_isa::CircuitInstruction::Measure => BytecodeCircuit::Measure, + vihaco_circuit_isa::CircuitInstruction::Reset => BytecodeCircuit::Reset, + vihaco_circuit_isa::CircuitInstruction::R => BytecodeCircuit::R, + vihaco_circuit_isa::CircuitInstruction::Loss => BytecodeCircuit::Loss, + vihaco_circuit_isa::CircuitInstruction::CorrelatedLoss => { + BytecodeCircuit::CorrelatedLoss + } + vihaco_circuit_isa::CircuitInstruction::PauliError => BytecodeCircuit::PauliError, + vihaco_circuit_isa::CircuitInstruction::Depolarize2 => BytecodeCircuit::Depolarize2, + vihaco_circuit_isa::CircuitInstruction::Depolarize => BytecodeCircuit::Depolarize, + }), + }; + Ok(encoded) +} + +fn decode_instruction(inst: BytecodeInstruction) -> PPVMInstruction { + match inst { + BytecodeInstruction::Cpu(inst) => PPVMInstruction::Cpu(match inst { + BytecodeCpu::Span(a, b, c) => CpuInstruction::Span(a, b, c), + BytecodeCpu::FunctionStart => CpuInstruction::FunctionStart, + BytecodeCpu::FunctionEnd => CpuInstruction::FunctionEnd, + BytecodeCpu::Breakpoint => CpuInstruction::Breakpoint, + BytecodeCpu::Branch(v) => CpuInstruction::Branch(v), + BytecodeCpu::ConditionalBranch(a, b) => CpuInstruction::ConditionalBranch(a, b), + BytecodeCpu::Return(v) => CpuInstruction::Return(v), + BytecodeCpu::IndirectCall => CpuInstruction::IndirectCall, + BytecodeCpu::Call(a, b) => CpuInstruction::Call(a, b), + BytecodeCpu::Halt => CpuInstruction::Halt, + BytecodeCpu::Print => CpuInstruction::Print, + BytecodeCpu::Load(t, v) => CpuInstruction::Load(decode_type(t), v), + BytecodeCpu::Store(t, v) => CpuInstruction::Store(decode_type(t), v), + BytecodeCpu::Dup => CpuInstruction::Dup, + BytecodeCpu::HeapAlloc(v) => CpuInstruction::HeapAlloc(v), + BytecodeCpu::GetItem => CpuInstruction::GetItem, + BytecodeCpu::HeapDealloc => CpuInstruction::HeapDealloc, + BytecodeCpu::Const(t, v) => CpuInstruction::Const(decode_type(t), decode_value(v)), + BytecodeCpu::Add(t) => CpuInstruction::Add(decode_type(t)), + BytecodeCpu::Sub(t) => CpuInstruction::Sub(decode_type(t)), + BytecodeCpu::Mul(t) => CpuInstruction::Mul(decode_type(t)), + BytecodeCpu::Div(t) => CpuInstruction::Div(decode_type(t)), + BytecodeCpu::Rem(t) => CpuInstruction::Rem(decode_type(t)), + BytecodeCpu::Neg(t) => CpuInstruction::Neg(decode_type(t)), + BytecodeCpu::Shl(t) => CpuInstruction::Shl(decode_type(t)), + BytecodeCpu::Shr(t) => CpuInstruction::Shr(decode_type(t)), + BytecodeCpu::Rol(t) => CpuInstruction::Rol(decode_type(t)), + BytecodeCpu::Ror(t) => CpuInstruction::Ror(decode_type(t)), + BytecodeCpu::BitAnd(t) => CpuInstruction::BitAnd(decode_type(t)), + BytecodeCpu::BitOr(t) => CpuInstruction::BitOr(decode_type(t)), + BytecodeCpu::BitXor(t) => CpuInstruction::BitXor(decode_type(t)), + BytecodeCpu::Not => CpuInstruction::Not, + BytecodeCpu::And => CpuInstruction::And, + BytecodeCpu::Or => CpuInstruction::Or, + BytecodeCpu::Xor => CpuInstruction::Xor, + BytecodeCpu::Eq(t) => CpuInstruction::Eq(decode_type(t)), + BytecodeCpu::Ne(t) => CpuInstruction::Ne(decode_type(t)), + BytecodeCpu::Lt(t) => CpuInstruction::Lt(decode_type(t)), + BytecodeCpu::Gt(t) => CpuInstruction::Gt(decode_type(t)), + BytecodeCpu::Le(t) => CpuInstruction::Le(decode_type(t)), + BytecodeCpu::Ge(t) => CpuInstruction::Ge(decode_type(t)), + }), + BytecodeInstruction::Circuit(inst) => PPVMInstruction::Circuit(match inst { + BytecodeCircuit::TwoQubitPauliError => { + vihaco_circuit_isa::CircuitInstruction::TwoQubitPauliError + } + BytecodeCircuit::Truncate => vihaco_circuit_isa::CircuitInstruction::Truncate, + BytecodeCircuit::Trace => vihaco_circuit_isa::CircuitInstruction::Trace, + BytecodeCircuit::X => vihaco_circuit_isa::CircuitInstruction::X, + BytecodeCircuit::Y => vihaco_circuit_isa::CircuitInstruction::Y, + BytecodeCircuit::Z => vihaco_circuit_isa::CircuitInstruction::Z, + BytecodeCircuit::H => vihaco_circuit_isa::CircuitInstruction::H, + BytecodeCircuit::SqrtXAdj => vihaco_circuit_isa::CircuitInstruction::SqrtXAdj, + BytecodeCircuit::SqrtX => vihaco_circuit_isa::CircuitInstruction::SqrtX, + BytecodeCircuit::SqrtYAdj => vihaco_circuit_isa::CircuitInstruction::SqrtYAdj, + BytecodeCircuit::SqrtY => vihaco_circuit_isa::CircuitInstruction::SqrtY, + BytecodeCircuit::SAdj => vihaco_circuit_isa::CircuitInstruction::SAdj, + BytecodeCircuit::S => vihaco_circuit_isa::CircuitInstruction::S, + BytecodeCircuit::CNOT => vihaco_circuit_isa::CircuitInstruction::CNOT, + BytecodeCircuit::CZ => vihaco_circuit_isa::CircuitInstruction::CZ, + BytecodeCircuit::TAdj => vihaco_circuit_isa::CircuitInstruction::TAdj, + BytecodeCircuit::T => vihaco_circuit_isa::CircuitInstruction::T, + BytecodeCircuit::RXX => vihaco_circuit_isa::CircuitInstruction::RXX, + BytecodeCircuit::RYY => vihaco_circuit_isa::CircuitInstruction::RYY, + BytecodeCircuit::RZZ => vihaco_circuit_isa::CircuitInstruction::RZZ, + BytecodeCircuit::RX => vihaco_circuit_isa::CircuitInstruction::RX, + BytecodeCircuit::RY => vihaco_circuit_isa::CircuitInstruction::RY, + BytecodeCircuit::RZ => vihaco_circuit_isa::CircuitInstruction::RZ, + BytecodeCircuit::U3 => vihaco_circuit_isa::CircuitInstruction::U3, + BytecodeCircuit::Measure => vihaco_circuit_isa::CircuitInstruction::Measure, + BytecodeCircuit::Reset => vihaco_circuit_isa::CircuitInstruction::Reset, + BytecodeCircuit::R => vihaco_circuit_isa::CircuitInstruction::R, + BytecodeCircuit::Loss => vihaco_circuit_isa::CircuitInstruction::Loss, + BytecodeCircuit::CorrelatedLoss => { + vihaco_circuit_isa::CircuitInstruction::CorrelatedLoss + } + BytecodeCircuit::PauliError => vihaco_circuit_isa::CircuitInstruction::PauliError, + BytecodeCircuit::Depolarize2 => vihaco_circuit_isa::CircuitInstruction::Depolarize2, + BytecodeCircuit::Depolarize => vihaco_circuit_isa::CircuitInstruction::Depolarize, + }), + } +} + /// Current `.ssb` format version. The reader rejects any other version. -pub const PPVM_BYTECODE_VERSION: u16 = 1; +pub const PPVM_BYTECODE_VERSION: u16 = 2; /// Byte length of the fixed portion of the header. The actual `header_size` /// in the stream may exceed this when the optional `observable` string is @@ -28,28 +360,11 @@ pub const PPVM_BYTECODE_VERSION: u16 = 1; /// + max_pauli_weight(8) + observable_present(1) = 33. const FIXED_HEADER_SIZE: u32 = 4 + 2 + 4 + 4 + 8 + 1 + 1 + 8 + 1; -/// Serialize a resolved module to the v1 `.ssb` byte stream. +/// Serialize a resolved module to the v2 `.ssb` byte stream. pub fn write_module(module: &PPVMModule, w: &mut W) -> eyre::Result<()> { - // v1 serializes only code, strings, and device info. Refuse to silently - // drop any table a future feature might populate. - let populated = if !module.functions.is_empty() { - Some("functions") - } else if !module.labels.is_empty() { - Some("labels") - } else if !module.constants.is_empty() { - Some("constants") - } else if !module.source_symbols.is_empty() { - Some("source_symbols") - } else if module.main_function.is_some() { - Some("main_function") - } else if module.file != 0 { - Some("file") - } else { - None - }; - if let Some(table) = populated { + if !module.constants.is_empty() || !module.source_symbols.is_empty() { return Err(eyre::eyre!( - "bytecode v1 cannot represent a populated `{table}`" + "bytecode v2 cannot represent constants or source symbols" )); } @@ -111,18 +426,44 @@ pub fn write_module(module: &PPVMModule, w: &mut W) -> eyre::Result<() w.write_all(s.as_bytes())?; } + // Metadata section: function and label tables are required for calls and + // branches to retain their resolved targets after a bytecode round trip. + write_u32(w, u32::try_from(module.functions.len())?)?; + for function in &module.functions { + write_u32(w, function.name)?; + write_u32(w, function.local_count)?; + write_u32(w, function.start_address)?; + write_u32(w, function.end_address)?; + write_u32(w, function.file)?; + write_u32(w, u32::try_from(function.signature.params.len())?)?; + for parameter in &function.signature.params { + write_u32(w, parameter.name)?; + parameter.ty.write_bytes(w)?; + } + write_u32(w, u32::try_from(function.signature.ret.len())?)?; + for ty in &function.signature.ret { + ty.write_bytes(w)?; + } + } + write_u32(w, u32::try_from(module.labels.len())?)?; + for label in &module.labels { + write_u32(w, label.address)?; + write_u32(w, label.name)?; + } + write_u32(w, module.main_function.unwrap_or(u32::MAX))?; + // Code section: count, then each instruction's fixed-width frame. let code_count = u32::try_from(module.code.len()).map_err(|_| eyre::eyre!("code length exceeds u32"))?; w.write_all(&code_count.to_le_bytes())?; for inst in &module.code { - inst.write_bytes(w)?; + encode_instruction(inst)?.write_bytes(w)?; } Ok(()) } -/// Reconstruct a module from a v1 `.ssb` byte stream. +/// Reconstruct a module from a v2 `.ssb` byte stream. pub fn read_module(r: &mut R) -> eyre::Result { // Header. let magic = read_u32(r)?; @@ -192,10 +533,51 @@ pub fn read_module(r: &mut R) -> eyre::Result { strings.push(String::from_utf8(bytes)?); } + let function_count = read_u32(r)?; + let mut functions = Vec::new(); + for _ in 0..function_count { + let name = read_u32(r)?; + let local_count = read_u32(r)?; + let start_address = read_u32(r)?; + let end_address = read_u32(r)?; + let file = read_u32(r)?; + let parameter_count = read_u32(r)?; + let mut params = Vec::new(); + for _ in 0..parameter_count { + params.push(Parameter { + name: read_u32(r)?, + ty: Type::from_bytes(r)?, + }); + } + let return_count = read_u32(r)?; + let mut ret = Vec::new(); + for _ in 0..return_count { + ret.push(Type::from_bytes(r)?); + } + functions.push(FunctionInfo { + name, + signature: Signature { params, ret }, + local_count, + start_address, + end_address, + file, + }); + } + let label_count = read_u32(r)?; + let mut labels = Vec::new(); + for _ in 0..label_count { + labels.push(LabelInfo { + address: read_u32(r)?, + name: read_u32(r)?, + }); + } + let main = read_u32(r)?; + let main_function = (main != u32::MAX).then_some(main); + let code_count = read_u32(r)?; let mut code = Vec::new(); for _ in 0..code_count { - code.push(PPVMInstruction::from_bytes(r)?); + code.push(decode_instruction(BytecodeInstruction::from_bytes(r)?)); } Ok(PPVMModule { @@ -208,6 +590,9 @@ pub fn read_module(r: &mut R) -> eyre::Result { max_pauli_weight, }, strings, + functions, + labels, + main_function, code, ..Default::default() }) @@ -263,6 +648,11 @@ fn read_u8(r: &mut R) -> eyre::Result { Ok(b[0]) } +fn write_u32(w: &mut W, value: u32) -> eyre::Result<()> { + w.write_all(&value.to_le_bytes())?; + Ok(()) +} + fn read_u16(r: &mut R) -> eyre::Result { let mut b = [0u8; 2]; r.read_exact(&mut b)?; @@ -297,7 +687,7 @@ fn skip_bytes(r: &mut R, n: u64) -> eyre::Result<()> { #[cfg(test)] mod tests { - use vihaco::Value; + use vihaco::{Type, Value}; use super::*; @@ -352,12 +742,12 @@ mod tests { #[test] fn round_trips_code() { use vihaco_circuit_isa::CircuitInstruction; - use vihaco_cpu::Instruction as Cpu; + use vihaco_cpu::RuntimeInstruction as Cpu; let mut m = empty_module(); m.extra.n_qubits = 2; m.code = vec![ - PPVMInstruction::Cpu(Cpu::Const(Value::U64(0))), + PPVMInstruction::Cpu(Cpu::Const(Type::U64, Value::U64(0))), PPVMInstruction::Circuit(CircuitInstruction::H), PPVMInstruction::Circuit(CircuitInstruction::R), PPVMInstruction::Cpu(Cpu::Branch(1)), @@ -378,7 +768,9 @@ mod tests { let mut m = empty_module(); m.extra.n_qubits = 3; m.strings = vec!["hi".to_string()]; - m.code = vec![PPVMInstruction::Cpu(vihaco_cpu::Instruction::Return(0))]; + m.code = vec![PPVMInstruction::Cpu( + vihaco_cpu::RuntimeInstruction::Return(0), + )]; let mut buf = Vec::new(); write_module(&m, &mut buf).unwrap(); @@ -400,12 +792,12 @@ mod tests { fn compile_to_bytes_round_trips_through_resolve() { let src = "device circuit.n_qubits 2;\n\ fn @main() {\n\ - const.u64 0\n\ - circuit.h\n\ - const.u64 0\n\ - const.u64 1\n\ - circuit.cnot\n\ - ret\n\ + cpu::cpu.const u64, 0\n\ + circuit::circuit.h\n\ + cpu::cpu.const u64, 0\n\ + cpu::cpu.const u64, 1\n\ + circuit::circuit.cnot\n\ + cpu::cpu.ret 0\n\ }\n"; let bytes = compile_to_bytes(src).unwrap(); @@ -419,11 +811,11 @@ mod tests { fn loaded_bytecode_executes_like_text() { let src = "device circuit.n_qubits 2;\n\ fn @main() {\n\ - const.u64 0\n circuit.h\n\ - const.u64 0\n const.u64 1\n circuit.cnot\n\ - const.u64 0\n circuit.measure\n\ - const.u64 1\n circuit.measure\n\ - ret\n }\n"; + cpu::cpu.const u64, 0\n circuit::circuit.h\n\ + cpu::cpu.const u64, 0\n cpu::cpu.const u64, 1\n circuit::circuit.cnot\n\ + cpu::cpu.const u64, 0\n circuit::circuit.measure\n\ + cpu::cpu.const u64, 1\n circuit::circuit.measure\n\ + cpu::cpu.ret 0\n }\n"; let bytes = compile_to_bytes(src).unwrap(); let mut machine = crate::composite::PPVM::default(); @@ -436,7 +828,7 @@ mod tests { #[test] fn load_bytecode_file_reads_from_disk() { let src = "device circuit.n_qubits 1;\n\ - fn @main() { const.u64 0\n circuit.measure\n ret }\n"; + fn @main() { cpu::cpu.const u64, 0\n circuit::circuit.measure\n cpu::cpu.ret 0 }\n"; let bytes = compile_to_bytes(src).unwrap(); let path = std::env::temp_dir().join("ppvm_load_bytecode_file_test.ssb"); std::fs::write(&path, &bytes).unwrap(); @@ -453,7 +845,9 @@ mod tests { fn read_rejects_truncated_input() { let mut m = empty_module(); m.extra.n_qubits = 2; - m.code = vec![PPVMInstruction::Cpu(vihaco_cpu::Instruction::Return(0))]; + m.code = vec![PPVMInstruction::Cpu( + vihaco_cpu::RuntimeInstruction::Return(0), + )]; let mut buf = Vec::new(); write_module(&m, &mut buf).unwrap(); @@ -463,7 +857,7 @@ mod tests { } #[test] - fn write_rejects_populated_functions_table() { + fn round_trips_populated_functions_table() { use vihaco::module::{FunctionInfo, Signature}; let mut m = empty_module(); @@ -481,8 +875,8 @@ mod tests { }); let mut buf = Vec::new(); - let err = write_module(&m, &mut buf).unwrap_err(); - assert!(err.to_string().contains("functions"), "err: {err}"); + write_module(&m, &mut buf).unwrap(); + assert_eq!(read_module(&mut buf.as_slice()).unwrap(), m); } #[test] diff --git a/crates/ppvm-vihaco/src/component.rs b/crates/ppvm-vihaco/src/component.rs index c0f1efa92..569091e15 100644 --- a/crates/ppvm-vihaco/src/component.rs +++ b/crates/ppvm-vihaco/src/component.rs @@ -14,7 +14,7 @@ use ppvm_pauli_sum::config::fx64hash::Byte8F64; use ppvm_pauli_sum::config::indexmap::ByteFxHashF64; use ppvm_pauli_sum::strategy::{CoefficientThreshold, CombinedStrategy, MaxPauliWeight}; use ppvm_tableau::prelude::*; -use vihaco::{Effects, component, observe}; +use vihaco::{Effects, dispatch, observe}; use vihaco_circuit_isa::{CircuitEffect, CircuitInstruction, CircuitMessage}; /// Largest qubit count any backend can simulate. The widest size bucket is @@ -71,7 +71,7 @@ pub struct CircuitExecutor, I: TableauIndex, C: SparseVec pub tab: GeneralizedTableau, } -#[component(instruction = CircuitInstruction, message = CircuitMessage, effect = CircuitOutcomeEffect)] +#[dispatch(instruction = CircuitInstruction, message = CircuitMessage, effect = CircuitOutcomeEffect)] impl CircuitExecutor where T: Config, @@ -449,7 +449,7 @@ pub struct PauliSumExecutor> { initial: PauliSum, } -#[component(instruction = CircuitInstruction, message = CircuitMessage, effect = CircuitOutcomeEffect)] +#[dispatch(instruction = CircuitInstruction, message = CircuitMessage, effect = CircuitOutcomeEffect)] impl PauliSumExecutor where T: Config, @@ -500,7 +500,7 @@ pub struct LossyPauliSumExecutor> { initial: PauliSum, } -#[component(instruction = CircuitInstruction, message = CircuitMessage, effect = CircuitOutcomeEffect)] +#[dispatch(instruction = CircuitInstruction, message = CircuitMessage, effect = CircuitOutcomeEffect)] impl LossyPauliSumExecutor where T: Config, @@ -855,7 +855,12 @@ pub enum Circuit { LossyPauliSum(LossyPauliSumCircuit), } -#[component(instruction = CircuitInstruction, message = CircuitMessage, effect = CircuitOutcomeEffect)] +impl vihaco::HasInstructionSet for Circuit { + type Runtime = vihaco_circuit_isa::CircuitInstruction; + type Syntax = vihaco_circuit_isa::CircuitSurfaceInstruction; +} + +#[dispatch(instruction = CircuitInstruction, message = CircuitMessage, effect = CircuitOutcomeEffect)] impl Circuit { /// Build a Tableau-backed circuit. Tableau init only needs `n_qubits` and /// `coefficient_threshold` from `info`; no observable required. diff --git a/crates/ppvm-vihaco/src/composite.rs b/crates/ppvm-vihaco/src/composite.rs index 72877bdb5..a74fc5c6a 100644 --- a/crates/ppvm-vihaco/src/composite.rs +++ b/crates/ppvm-vihaco/src/composite.rs @@ -5,8 +5,8 @@ use eyre::{Result, eyre}; use vihaco::frame::Frame; use vihaco::machine::StackFrame; use vihaco::observer::stdio::{StdoutEffect, StdoutObserver}; -use vihaco::traits::{GetProgramGlobal, ProgramCounter, StackMemory}; -use vihaco::{Effects, Observe, ProgramLoader, Value, composite, observe}; +use vihaco::traits::{GetProgramInfo, ProgramCounter, StackMemory}; +use vihaco::{Effects, NoContext, Observe, ProgramImage, Type, Value, composite, observe}; use vihaco_cpu::{CPU, CPUMessage}; /// Re-exported so consumers (e.g. the CLI debugger) can match on step results @@ -27,13 +27,12 @@ use vihaco_circuit_isa::{CircuitEffect, CircuitInstruction, CircuitMessage}; // existing `crate::composite::{…}` paths keep resolving. pub use crate::device_info::{BackendKind, PPVM_MAGIC, PPVMDeviceInfo}; -pub type Instruction = PPVMInstruction; +pub type PPVMInstruction = ppvm::runtime::Instruction; #[composite] #[derive(Default)] pub struct PPVM { - #[program] - loader: ProgramLoader, + loader: ProgramImage, #[device(0x00)] cpu: CPU, @@ -48,6 +47,8 @@ pub struct PPVM { trace_record: TraceObserver, } +pub(crate) use self::ppvm as ppvm_module; + #[derive(Debug, Clone)] pub enum PPVMEffect { Step(StepOutcome), @@ -137,8 +138,8 @@ impl PartialEq for PPVMInstruction { } } -impl From for PPVMInstruction { - fn from(value: vihaco_cpu::Instruction) -> Self { +impl From for PPVMInstruction { + fn from(value: vihaco_cpu::RuntimeInstruction) -> Self { Self::Cpu(value) } } @@ -150,9 +151,9 @@ impl From for PPVMInstruction { } impl PPVM { - fn resolve_cpu(&mut self, inst: &vihaco_cpu::Instruction) -> eyre::Result { + fn resolve_cpu(&mut self, inst: &vihaco_cpu::RuntimeInstruction) -> eyre::Result { match inst { - vihaco_cpu::Instruction::IndirectCall => { + vihaco_cpu::RuntimeInstruction::IndirectCall => { let function_id: u32 = self.cpu.stack_top()?.get_function_ref()?; let function = self.loader.get_function(function_id as usize)?; Ok(CPUMessage::FunctionInfo { @@ -160,7 +161,7 @@ impl PPVM { start_address: function.start_address, }) } - vihaco_cpu::Instruction::Print => { + vihaco_cpu::RuntimeInstruction::Print => { let value = *self.cpu.stack_top()?; match value { vihaco::Value::String(addr) => { @@ -310,14 +311,19 @@ impl PPVM { pub fn load( &mut self, - module: &vihaco::module::Module, + module: &vihaco::module::LocalModule< + PPVMInstruction, + vihaco::Value, + vihaco::Type, + PPVMDeviceInfo, + >, ) -> eyre::Result<()> { self.loader.module = module.clone(); Ok(()) } pub fn step_once(&mut self) -> eyre::Result { - let inst = self.peek_instruction()?.clone(); + let inst = self.loader.peek_instruction()?.clone(); let effects = self.execute_effects(inst)?; self.continue_effects(effects) } @@ -330,7 +336,7 @@ impl PPVM { /// The next instruction to execute, or `None` once execution has run off /// the end of the code. Intended for debuggers/inspection. pub fn current_instruction(&self) -> Option { - self.peek_instruction().ok().cloned() + self.loader.peek_instruction().ok().cloned() } /// Append one REPL command's lowered VM ops and run just that block against @@ -358,7 +364,7 @@ impl PPVM { /// `n_qubits` is zero (a device must have at least one qubit). pub fn with_qubits(n_qubits: usize) -> eyre::Result { let mut machine = Self::default(); - let mut module = vihaco::module::Module::< + let mut module = vihaco::module::LocalModule::< PPVMInstruction, Value, vihaco::Type, @@ -390,12 +396,14 @@ impl PPVM { let mut instrs = Vec::with_capacity(qubits.len() + params.len() + 1); for &q in qubits { - instrs.push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + instrs.push(PPVMInstruction::Cpu(vihaco_cpu::RuntimeInstruction::Const( + Type::U64, Value::U64(q as u64), ))); } for &p in params { - instrs.push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + instrs.push(PPVMInstruction::Cpu(vihaco_cpu::RuntimeInstruction::Const( + Type::F64, Value::F64(p), ))); } @@ -414,22 +422,23 @@ impl PPVM { result } - fn execute_effects(&mut self, inst: Instruction) -> eyre::Result> { + fn execute_effects(&mut self, inst: PPVMInstruction) -> eyre::Result> { log::debug!("exec inst: {:?}, stack: {:?}", inst, self.cpu.stack()); match inst { PPVMInstruction::Cpu(cpu_inst) => { let msg = self.resolve_cpu(&cpu_inst)?; self.cpu.set_current_pc(self.loader.pc()); let stdout_effect = match (&cpu_inst, &msg) { - (vihaco_cpu::Instruction::Print, vihaco_cpu::CPUMessage::Print(text)) => { - Some(PPVMEffect::Stdout(StdoutEffect(text.clone()))) - } + ( + vihaco_cpu::RuntimeInstruction::Print, + vihaco_cpu::CPUMessage::Print(text), + ) => Some(PPVMEffect::Stdout(StdoutEffect(text.clone()))), _ => None, }; let outcome = vihaco::expect_exactly_one_effect( vihaco::GeneratedComponent::execute_generated(&mut self.cpu, cpu_inst, msg)?, )?; - // Advance past a breakpoint as well, so the debugger that paused + // Advance past a cpu::cpu.breakpoint as well, so the debugger that paused // on it doesn't re-hit the same instruction on the next step. if matches!(outcome, StepOutcome::Continue | StepOutcome::Breakpoint) { if let Some(target) = self.cpu.take_pending_pc() { @@ -627,30 +636,37 @@ fn parse_observable_terms(info: &PPVMDeviceInfo) -> Result> { #[cfg(test)] mod tests { - use vihaco::{Type, Value, module::Module}; + use vihaco::{Type, Value, module::LocalModule}; use super::*; #[test] fn test_run_ppvm() -> eyre::Result<()> { - let mut module: Module = Module::default(); + let mut module: LocalModule = + LocalModule::default(); module.extra.n_qubits = 2; /* - const.u64 0 - circuit.h + cpu::cpu.const u64, 0 + circuit::circuit.h */ - let zero = PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const(Value::U64(0))); - let one = PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const(Value::U64(1))); + let zero = PPVMInstruction::Cpu(vihaco_cpu::RuntimeInstruction::Const( + Type::U64, + Value::U64(0), + )); + let one = PPVMInstruction::Cpu(vihaco_cpu::RuntimeInstruction::Const( + Type::U64, + Value::U64(1), + )); module.code.push(zero.clone()); module .code .push(PPVMInstruction::Circuit(CircuitInstruction::H)); /* - const.u64 0 - circuit.t + cpu::cpu.const u64, 0 + circuit::circuit.t */ module.code.push(zero.clone()); @@ -659,9 +675,9 @@ mod tests { .push(PPVMInstruction::Circuit(CircuitInstruction::T)); /* - const.u64 0 - const.u64 1 - circuit.cnot + cpu::cpu.const u64, 0 + cpu::cpu.const u64, 1 + circuit::circuit.cnot */ module.code.push(zero.clone()); module.code.push(one.clone()); @@ -721,19 +737,21 @@ mod tests { // // fn @main() { ...5-qubit GHZ + 5 measurements... } - let mut module: Module = Module::default(); + let mut module: LocalModule = + LocalModule::default(); module.extra.n_qubits = 5; module.extra.coefficient_threshold = 1e-10; // 5-qubit GHZ: H on q0, then CNOT(q_i, q_{i+1}) for i = 0..4. /* - const.u64 0 - circuit.h + cpu::cpu.const u64, 0 + circuit::circuit.h */ module .code - .push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + .push(PPVMInstruction::Cpu(vihaco_cpu::RuntimeInstruction::Const( + Type::U64, Value::U64(0), ))); module @@ -742,18 +760,20 @@ mod tests { for i in 0..4u64 { /* - const.u64 i - const.u64 i+1 - circuit.cnot + cpu::cpu.const u64, i + cpu::cpu.const u64, i+1 + circuit::circuit.cnot */ module .code - .push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + .push(PPVMInstruction::Cpu(vihaco_cpu::RuntimeInstruction::Const( + Type::U64, Value::U64(i), ))); module .code - .push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + .push(PPVMInstruction::Cpu(vihaco_cpu::RuntimeInstruction::Const( + Type::U64, Value::U64(i + 1), ))); module @@ -764,12 +784,13 @@ mod tests { // Measure all 5 qubits. for q in 0..5u64 { /* - const.u64 q - circuit.measure + cpu::cpu.const u64, q + circuit::circuit.measure */ module .code - .push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + .push(PPVMInstruction::Cpu(vihaco_cpu::RuntimeInstruction::Const( + Type::U64, Value::U64(q), ))); module @@ -797,7 +818,8 @@ mod tests { // A 1-qubit device with no code; the REPL builds up instructions // incrementally, one command at a time, rather than loading a program. - let mut module: Module = Module::default(); + let mut module: LocalModule = + LocalModule::default(); module.extra.n_qubits = 1; let mut machine = PPVM::default(); @@ -806,7 +828,10 @@ mod tests { // First command: X on q0 (|0> -> |1>). let x = [ - PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const(Value::U64(0))), + PPVMInstruction::Cpu(vihaco_cpu::RuntimeInstruction::Const( + Type::U64, + Value::U64(0), + )), PPVMInstruction::Circuit(CircuitInstruction::X), ]; machine.execute_single_instruction(&x)?; @@ -816,7 +841,10 @@ mod tests { // Second command: measure q0. The X from the first command must persist, // so the outcome is deterministically |1>. let measure = [ - PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const(Value::U64(0))), + PPVMInstruction::Cpu(vihaco_cpu::RuntimeInstruction::Const( + Type::U64, + Value::U64(0), + )), PPVMInstruction::Circuit(CircuitInstruction::Measure), ]; machine.execute_single_instruction(&measure)?; @@ -836,14 +864,15 @@ mod tests { // NOTE: an out-of-range qubit index (>= n_qubits) currently *panics* in // the tableau rather than erroring, so the REPL command layer must // bounds-check qubit indices before calling `execute`. - let mut module: Module = Module::default(); + let mut module: LocalModule = + LocalModule::default(); module.extra.n_qubits = 1; let mut machine = PPVM::default(); machine.load(&module)?; machine.init()?; - // `circuit.h` with nothing on the stack: `pop_u64` fails. + // `circuit::circuit.h` with nothing on the stack: `pop_u64` fails. let missing_operand = [PPVMInstruction::Circuit(CircuitInstruction::H)]; assert!( machine @@ -855,7 +884,7 @@ mod tests { #[test] fn state_string_renders_a_small_device() -> eyre::Result<()> { - let source = "device circuit.n_qubits 2;\nfn @main() { ret }\n"; + let source = "device circuit.n_qubits 2;\nfn @main() { cpu::cpu.ret 0 }\n"; let mut machine = PPVM::default(); machine.load_program(source)?; machine.init()?; @@ -876,7 +905,8 @@ mod tests { // floats) and popped in reverse. So every two-qubit circuit must read q0 as // the first operand pushed, consistently, with or without trailing // floats. (CNOT already obeyed this; the float-carrying arms did not.) - let mut module: Module = Module::default(); + let mut module: LocalModule = + LocalModule::default(); module.extra.n_qubits = 8; let mut machine = PPVM::default(); machine.load(&module)?; @@ -950,15 +980,15 @@ mod tests { let source = "device circuit.n_qubits 2;\n\ device circuit.coefficient_threshold 1e-8;\n\ fn @main() {\n\ - const.u64 0\n\ - circuit.h\n\ - ret\n\ + cpu::cpu.const u64, 0\n\ + circuit::circuit.h\n\ + cpu::cpu.ret 0\n\ }\n"; let mut machine = PPVM::default(); machine.load_program(source)?; assert_eq!(machine.loader.module.extra.n_qubits, 2); assert_eq!(machine.loader.module.extra.coefficient_threshold, 1e-8); - // const.u64 0 / circuit.h / ret = 3 + // cpu::cpu.const u64, 0 / circuit::circuit.h / ret = 3 assert_eq!(machine.loader.module.code.len(), 3); Ok(()) } @@ -967,16 +997,16 @@ mod tests { fn run_program_executes_bell_circuit() -> eyre::Result<()> { let source = "device circuit.n_qubits 2;\n\ fn @main() {\n\ - const.u64 0\n\ - circuit.h\n\ - const.u64 0\n\ - const.u64 1\n\ - circuit.cnot\n\ - const.u64 0\n\ - circuit.measure\n\ - const.u64 1\n\ - circuit.measure\n\ - ret\n\ + cpu::cpu.const u64, 0\n\ + circuit::circuit.h\n\ + cpu::cpu.const u64, 0\n\ + cpu::cpu.const u64, 1\n\ + circuit::circuit.cnot\n\ + cpu::cpu.const u64, 0\n\ + circuit::circuit.measure\n\ + cpu::cpu.const u64, 1\n\ + circuit::circuit.measure\n\ + cpu::cpu.ret 0\n\ }\n"; let mut machine = PPVM::default(); machine.run_program(source)?; @@ -991,16 +1021,16 @@ mod tests { let source = "device circuit.n_qubits 2;\n\ fn @main() {\n\ - const.u64 0\n\ - circuit.h\n\ - const.u64 0\n\ - const.u64 1\n\ - circuit.cnot\n\ - const.u64 0\n\ - circuit.measure\n\ - const.u64 1\n\ - circuit.measure\n\ - ret\n\ + cpu::cpu.const u64, 0\n\ + circuit::circuit.h\n\ + cpu::cpu.const u64, 0\n\ + cpu::cpu.const u64, 1\n\ + circuit::circuit.cnot\n\ + cpu::cpu.const u64, 0\n\ + circuit::circuit.measure\n\ + cpu::cpu.const u64, 1\n\ + circuit::circuit.measure\n\ + cpu::cpu.ret 0\n\ }\n"; let mut machine = PPVM::default(); machine.run_program(source)?; @@ -1019,7 +1049,7 @@ mod tests { #[test] fn init_fails_when_n_qubits_undeclared() -> eyre::Result<()> { - let source = "fn @main() { ret }\n"; + let source = "fn @main() { cpu::cpu.ret 0 }\n"; let mut machine = PPVM::default(); machine.load_program(source)?; let err = machine.init().unwrap_err(); @@ -1031,13 +1061,14 @@ mod tests { fn run_program_reports_parse_errors() { let source = "device circuit.n_qubits 2;\n\ fn @main() {\n\ - circuit.not_a_real_gate\n\ - ret\n\ + circuit::circuit.not_a_real_gate\n\ + cpu::cpu.ret 0\n\ }\n"; let mut machine = PPVM::default(); let err = machine.run_program(source).unwrap_err(); assert!( err.to_string().contains("parsing failed") + || err.to_string().contains("parsing functions failed") || err.to_string().contains("unhandled raw form"), "err: {err}" ); @@ -1045,25 +1076,25 @@ mod tests { // ─── Breakpoints ────────────────────────────────────────────────────── - /// Bell circuit with a `breakpoint` between the two measurements. + /// Bell circuit with a `cpu::cpu.breakpoint` between the two measurements. const BREAKPOINT_PROGRAM: &str = "device circuit.n_qubits 2;\n\ fn @main() {\n\ - const.u64 0\n\ - circuit.h\n\ - const.u64 0\n\ - const.u64 1\n\ - circuit.cnot\n\ - const.u64 0\n\ - circuit.measure\n\ - breakpoint\n\ - const.u64 1\n\ - circuit.measure\n\ - ret\n\ + cpu::cpu.const u64, 0\n\ + circuit::circuit.h\n\ + cpu::cpu.const u64, 0\n\ + cpu::cpu.const u64, 1\n\ + circuit::circuit.cnot\n\ + cpu::cpu.const u64, 0\n\ + circuit::circuit.measure\n\ + cpu::cpu.breakpoint\n\ + cpu::cpu.const u64, 1\n\ + circuit::circuit.measure\n\ + cpu::cpu.ret 0\n\ }\n"; #[test] fn run_ignores_breakpoints() -> eyre::Result<()> { - // A batch run must execute straight through the breakpoint and record + // A batch run must execute straight through the cpu::cpu.breakpoint and record // both measurements, exactly as if it weren't there. let mut machine = PPVM::default(); machine.run_program(BREAKPOINT_PROGRAM)?; @@ -1077,7 +1108,7 @@ mod tests { machine.load_program(BREAKPOINT_PROGRAM)?; machine.init()?; - // Step until the breakpoint pauses us. + // Step until the cpu::cpu.breakpoint pauses us. let mut outcome = StepOutcome::Continue; for _ in 0..machine.loader.module.code.len() { outcome = machine.step_once()?; @@ -1085,16 +1116,20 @@ mod tests { break; } } - assert_eq!(outcome, StepOutcome::Breakpoint, "breakpoint should pause"); + assert_eq!( + outcome, + StepOutcome::Breakpoint, + "cpu::cpu.breakpoint should pause" + ); let pc_at_break = machine.current_pc(); // Stepping again must make progress (advance the pc) rather than - // re-hitting the same breakpoint instruction. + // re-hitting the same cpu::cpu.breakpoint instruction. let next = machine.step_once()?; assert_ne!( next, StepOutcome::Breakpoint, - "must move past the breakpoint" + "must move past the cpu::cpu.breakpoint" ); assert!(machine.current_pc() > pc_at_break, "pc must advance"); @@ -1103,10 +1138,11 @@ mod tests { #[test] fn paulisum_truncate_runs_without_error() -> eyre::Result<()> { - // Smoke test: a `circuit.truncate` reaches the PauliSum executor's + // Smoke test: a `circuit::circuit.truncate` reaches the PauliSum executor's // Truncate arm and calls `state.truncate()`. Task 8 makes the // observable mandatory for PauliSum init, so seed `Z` here. - let mut module: Module = Module::default(); + let mut module: LocalModule = + LocalModule::default(); module.extra.n_qubits = 1; module.extra.backend = BackendKind::PauliSum; module.extra.observable = Some("Z".to_string()); @@ -1126,7 +1162,8 @@ mod tests { // End-to-end Trace pipeline: with the observable `Z` seeded (Task 8), // tracing the `Z0` pattern picks up that one term with coefficient // 1.0, so the trace should be exactly 1.0. - let mut module: Module = Module::default(); + let mut module: LocalModule = + LocalModule::default(); module.extra.n_qubits = 1; module.extra.backend = BackendKind::PauliSum; module.extra.observable = Some("Z".to_string()); @@ -1135,7 +1172,8 @@ mod tests { module .code - .push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + .push(PPVMInstruction::Cpu(vihaco_cpu::RuntimeInstruction::Const( + Type::String, Value::String(0), ))); module @@ -1158,7 +1196,8 @@ mod tests { // Task 11: a sum-valued observable seeds every term. With // `"ZZ + 0.5*XX"` the state holds `1.0 * ZZ + 0.5 * XX`; tracing // `[XZ]0[XZ]1` matches both words and returns 1.0 + 0.5 = 1.5. - let mut module: Module = Module::default(); + let mut module: LocalModule = + LocalModule::default(); module.extra.n_qubits = 2; module.extra.backend = BackendKind::PauliSum; module.extra.observable = Some("ZZ + 0.5*XX".to_string()); @@ -1166,7 +1205,8 @@ mod tests { module .code - .push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + .push(PPVMInstruction::Cpu(vihaco_cpu::RuntimeInstruction::Const( + Type::String, Value::String(0), ))); module @@ -1187,7 +1227,8 @@ mod tests { #[test] fn paulisum_init_rejects_missing_observable() { // Task 8 requires `device circuit.observable` for PauliSum / Lossy. - let mut module: Module = Module::default(); + let mut module: LocalModule = + LocalModule::default(); module.extra.n_qubits = 1; module.extra.backend = BackendKind::PauliSum; @@ -1202,7 +1243,8 @@ mod tests { #[test] fn paulisum_init_rejects_mismatched_observable_length() { - let mut module: Module = Module::default(); + let mut module: LocalModule = + LocalModule::default(); module.extra.n_qubits = 2; module.extra.backend = BackendKind::PauliSum; // Three letters but only two qubits — should error. @@ -1219,10 +1261,11 @@ mod tests { #[test] fn tableau_truncate_is_silent_no_op() -> eyre::Result<()> { - // Task 9: `circuit.truncate` on the default Tableau backend should run + // Task 9: `circuit::circuit.truncate` on the default Tableau backend should run // without error — the tableau prunes via coefficient_threshold during // every gate, so the explicit Truncate instruction has nothing to do. - let mut module: Module = Module::default(); + let mut module: LocalModule = + LocalModule::default(); module.extra.n_qubits = 1; // backend defaults to Tableau; no observable needed. module @@ -1243,14 +1286,14 @@ mod tests { fn apply_circuit_instruction_preserves_pc_and_code_len() -> eyre::Result<()> { use crate::measurements::MeasurementOutcome; - // breakpoint; then measure q0. Step to the breakpoint, inject X, resume. + // cpu::cpu.breakpoint; then measure q0. Step to the cpu::cpu.breakpoint, inject X, resume. let src = "device circuit.n_qubits 1;\n\ - fn @main() { breakpoint\n const.u64 0\n circuit.measure\n ret }\n"; + fn @main() { cpu::cpu.breakpoint\n cpu::cpu.const u64, 0\n circuit::circuit.measure\n cpu::cpu.ret 0 }\n"; let mut m = PPVM::default(); m.load_program(src)?; m.init()?; - // Run until the breakpoint pauses us. + // Run until the cpu::cpu.breakpoint pauses us. loop { if m.step_once()? == StepOutcome::Breakpoint { break; @@ -1289,7 +1332,7 @@ mod tests { // A program stepped to a known pc; an injected gate that errors mid-block // must NOT corrupt the code vector or the program counter. let src = "device circuit.n_qubits 1;\n\ - fn @main() { breakpoint\n const.u64 0\n circuit.measure\n ret }\n"; + fn @main() { cpu::cpu.breakpoint\n cpu::cpu.const u64, 0\n circuit::circuit.measure\n cpu::cpu.ret 0 }\n"; let mut m = PPVM::default(); m.load_program(src)?; m.init()?; @@ -1324,7 +1367,7 @@ mod tests { #[test] fn apply_circuit_instruction_measurement_does_not_grow_the_stack() -> eyre::Result<()> { - // `circuit.measure` pushes its outcome onto the CPU operand stack for + // `circuit::circuit.measure` pushes its outcome onto the CPU operand stack for // bytecode to consume. An injected measurement has no such consumer, so // that push must be rolled back: otherwise a paused program resumes with // a stray operand, and a REPL session's stack grows without bound. @@ -1343,16 +1386,18 @@ mod tests { #[test] fn tableau_trace_emits_expectation_on_zero_state() { - // Task 16: `circuit.trace` on the Tableau backend now computes + // Task 16: `circuit::circuit.trace` on the Tableau backend now computes // Σ_{P matches pat} ⟨ψ|P|ψ⟩ via `GeneralizedTableau::trace`. On the // freshly-initialized |0⟩ state, pattern `Z0` matches the single // Pauli Z and ⟨0|Z|0⟩ = 1, so the trace_record gets one entry: 1.0. - let mut module: Module = Module::default(); + let mut module: LocalModule = + LocalModule::default(); module.extra.n_qubits = 1; module.strings.push("Z0".to_string()); module .code - .push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + .push(PPVMInstruction::Cpu(vihaco_cpu::RuntimeInstruction::Const( + Type::String, Value::String(0), ))); module @@ -1363,7 +1408,7 @@ mod tests { machine.load(&module).unwrap(); machine.init().unwrap(); machine.step_once().unwrap(); // const.string - machine.step_once().unwrap(); // circuit.trace + machine.step_once().unwrap(); // circuit::circuit.trace let trace = machine.trace_record(); assert_eq!(trace.len(), 1); assert!( @@ -1380,14 +1425,16 @@ mod tests { // Seed the observable `Z` (PauliSum backend), then apply H(0), which // conjugates Z -> X in the Heisenberg picture and changes the state. // reset() must rebuild the state from the seeded observable. - let mut module: Module = Module::default(); + let mut module: LocalModule = + LocalModule::default(); module.extra.n_qubits = 1; module.extra.backend = BackendKind::PauliSum; module.extra.observable = Some("Z".to_string()); module .code - .push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + .push(PPVMInstruction::Cpu(vihaco_cpu::RuntimeInstruction::Const( + Type::U64, Value::U64(0), ))); module @@ -1423,14 +1470,16 @@ mod tests { // Same as `paulisum_reset_restores_seeded_observable`, but through the // LossyPauliSum dispatch path. - let mut module: Module = Module::default(); + let mut module: LocalModule = + LocalModule::default(); module.extra.n_qubits = 1; module.extra.backend = BackendKind::LossyPauliSum; module.extra.observable = Some("Z".to_string()); module .code - .push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + .push(PPVMInstruction::Cpu(vihaco_cpu::RuntimeInstruction::Const( + Type::U64, Value::U64(0), ))); module diff --git a/crates/ppvm-vihaco/src/device_info.rs b/crates/ppvm-vihaco/src/device_info.rs index 77e60ac79..8e675b99d 100644 --- a/crates/ppvm-vihaco/src/device_info.rs +++ b/crates/ppvm-vihaco/src/device_info.rs @@ -10,12 +10,11 @@ pub const PPVM_MAGIC: u32 = 0x5050564D; /// Which execution backend the circuit runs on. Selected via the /// `device circuit.backend` header; defaults to `Tableau` so existing /// programs that don't declare a backend keep working. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, vihaco_parser::Parse)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum BackendKind { #[default] Tableau, PauliSum, - #[token = "lossy_paulisum"] LossyPauliSum, } diff --git a/crates/ppvm-vihaco/src/lib.rs b/crates/ppvm-vihaco/src/lib.rs index 3bad3bdca..2d8f4e5e7 100644 --- a/crates/ppvm-vihaco/src/lib.rs +++ b/crates/ppvm-vihaco/src/lib.rs @@ -15,16 +15,15 @@ mod syntax; /// crate directly. pub use vihaco_circuit_isa::CircuitInstruction; -use chumsky::Parser; use vihaco::syntax::{ParsedModule, Resolve}; -use vihaco::{Type, Value, module::Module}; -use vihaco_parser_core::Parse; +use vihaco::{Type, Value, module::LocalModule}; +use crate::composite::ppvm_module as ppvm; use crate::composite::{PPVM, PPVMDeviceInfo, PPVMInstruction}; -use crate::syntax::{PPVMHeader, PPVMResolver}; +use crate::syntax::{PPVMHeader, PPVMResolver, parse_functions, parse_headers}; /// A fully resolved PPVM module, ready to load into a [`PPVM`]. -pub type PPVMModule = Module; +pub type PPVMModule = LocalModule; /// Read a file and produce a loadable module, auto-detecting the format: a /// leading PPVM magic is parsed as `.ssb` bytecode, otherwise as `.sst` source. @@ -52,16 +51,18 @@ pub fn run_program(program: &str) -> eyre::Result { } /// Parse `.sst` source into the unresolved AST. -pub fn parse_program(source: &str) -> eyre::Result> { - ParsedModule::::parser() - .parse(source) - .into_result() - .map_err(|errs| eyre::eyre!("parsing failed: {errs:?}")) +pub fn parse_program( + source: &str, +) -> eyre::Result>> +{ + let (header, body) = parse_headers(source)?; + Ok(ParsedModule { + header, + functions: parse_functions(&body)?, + }) } -pub fn compile_program( - source: &str, -) -> eyre::Result> { +pub fn compile_program(source: &str) -> eyre::Result { PPVMResolver::new().resolve_module(parse_program(source)?) } @@ -91,7 +92,7 @@ mod tests { #[test] fn dump_program_writes_loadable_bytecode() { let src = "device circuit.n_qubits 1;\n\ - fn @main() { const.u64 0\n circuit.measure\n ret }\n"; + fn @main() { cpu::cpu.const u64, 0\n circuit::circuit.measure\n cpu::cpu.ret 0 }\n"; let path = std::env::temp_dir().join("ppvm_dump_program_test.ssb"); dump_program(src, path.to_str().unwrap()).unwrap(); diff --git a/crates/ppvm-vihaco/src/shots.rs b/crates/ppvm-vihaco/src/shots.rs index 80945f9e1..a0580b6f7 100644 --- a/crates/ppvm-vihaco/src/shots.rs +++ b/crates/ppvm-vihaco/src/shots.rs @@ -127,11 +127,10 @@ mod tests { use crate::measurements::MeasurementOutcome; /// Measures q0 in |0>: every shot is deterministically `0`. - const DETERMINISTIC: &str = - "device circuit.n_qubits 1;\nfn @main() { const.u64 0\n circuit.measure\n ret }\n"; + const DETERMINISTIC: &str = "device circuit.n_qubits 1;\nfn @main() { cpu::cpu.const u64, 0\n circuit::circuit.measure\n cpu::cpu.ret 0 }\n"; /// Prepares |+> with H, then measures q0: each shot is a random 0/1. - const RANDOM: &str = "device circuit.n_qubits 1;\nfn @main() { const.u64 0\n circuit.h\n const.u64 0\n circuit.measure\n ret }\n"; + const RANDOM: &str = "device circuit.n_qubits 1;\nfn @main() { cpu::cpu.const u64, 0\n circuit::circuit.h\n cpu::cpu.const u64, 0\n circuit::circuit.measure\n cpu::cpu.ret 0 }\n"; fn module(src: &str) -> PPVMModule { compile_program(src).unwrap() diff --git a/crates/ppvm-vihaco/src/syntax.rs b/crates/ppvm-vihaco/src/syntax.rs index e11865068..3b4bc35c4 100644 --- a/crates/ppvm-vihaco/src/syntax.rs +++ b/crates/ppvm-vihaco/src/syntax.rs @@ -3,37 +3,51 @@ use std::collections::HashMap; -use chumsky::{Parser, error::Simple, extra}; -use vihaco::{ - Type, Value, - module::Module, - syntax::{BodyItem, RawForm, RawOperand, Resolve}, -}; -use vihaco_circuit_isa::CircuitInstruction; -use vihaco_parser_core::Parse; - +use chumsky::{IterParser, Parser}; +use vihaco::module::{FunctionInfo, LabelInfo, LocalModule, Parameter, Signature}; +use vihaco::syntax::{Param, ParsedFunction, ParsedModule, Resolve, skip}; +use vihaco::{Parse, Type, Value}; +use vihaco_circuit_isa::{CircuitInstruction, CircuitSurfaceInstruction}; +use vihaco_cpu::{RuntimeInstruction as CpuRuntime, SurfaceInstruction as CpuSurface}; +use vihaco_cpu::{SurfaceType, SurfaceValue}; +use vihaco_parser::{BareToken, Ident, QuotedString}; + +use crate::composite::ppvm_module as ppvm; use crate::composite::{BackendKind, PPVMDeviceInfo, PPVMInstruction}; -#[derive(Debug, Clone, PartialEq, vihaco_parser::Parse)] -#[head = "device "] +#[derive(Debug, Clone, PartialEq, vihaco_parser_derive::Parse)] +#[syntax_class(value)] +pub enum BackendKindSyntax { + #[pattern = "`tableau`"] + Tableau, + #[pattern = "`paulisum`"] + PauliSum, + #[pattern = "`lossy_paulisum`"] + LossyPauliSum, +} + +impl From for BackendKind { + fn from(value: BackendKindSyntax) -> Self { + match value { + BackendKindSyntax::Tableau => Self::Tableau, + BackendKindSyntax::PauliSum => Self::PauliSum, + BackendKindSyntax::LossyPauliSum => Self::LossyPauliSum, + } + } +} + +#[derive(Debug, Clone, PartialEq, vihaco_parser_derive::Parse)] +#[syntax_class(metadata, head = "device")] pub enum PPVMHeader { - #[token = "circuit.n_qubits"] - #[delimiters(open = "", close = "", separator = "")] + #[pattern = "circuit.n_qubits $0"] NumQubits(usize), - - #[token = "circuit.coefficient_threshold"] - #[delimiters(open = "", close = "", separator = "")] + #[pattern = "circuit.coefficient_threshold $0"] CoefficientThreshold(f64), - #[token = "circuit.backend"] - #[delimiters(open = "", close = "", separator = "")] - Backend(BackendKind), - - #[token = "circuit.observable"] - #[delimiters(open = "", close = "", separator = "")] - Observable(#[parse_with = "vihaco_parser_core::ident"] String), - - #[token = "circuit.max_pauli_weight"] - #[delimiters(open = "", close = "", separator = "")] + #[pattern = "circuit.backend $0"] + Backend(BackendKindSyntax), + #[pattern = "circuit.observable $0"] + Observable(Ident), + #[pattern = "circuit.max_pauli_weight $0"] MaxPauliWeight(usize), } @@ -49,676 +63,292 @@ impl PPVMResolver { fn apply_header(info: &mut PPVMDeviceInfo, header: PPVMHeader) -> eyre::Result<()> { match header { - PPVMHeader::NumQubits(n) => { - info.n_qubits = n; - } - PPVMHeader::CoefficientThreshold(t) => { - info.coefficient_threshold = t; - } - PPVMHeader::Backend(b) => { - info.backend = b; - } - PPVMHeader::Observable(s) => { - info.observable = Some(s); - } - PPVMHeader::MaxPauliWeight(w) => { - info.max_pauli_weight = Some(w); - } + PPVMHeader::NumQubits(n) => info.n_qubits = n, + PPVMHeader::CoefficientThreshold(t) => info.coefficient_threshold = t, + PPVMHeader::Backend(b) => info.backend = b.into(), + PPVMHeader::Observable(s) => info.observable = Some(s.as_str().to_owned()), + PPVMHeader::MaxPauliWeight(w) => info.max_pauli_weight = Some(w), } Ok(()) } - fn lower_raw(&mut self, raw: RawForm) -> eyre::Result> { - match raw.mnemonic.as_str() { - "ret" => { - let keep = match raw.operands.as_slice() { - [] => 0u32, - [RawOperand::UInt(n)] => u32::try_from(*n) - .map_err(|_| eyre::eyre!("`ret` keep count {n} does not fit in u32"))?, - other => { - return Err(eyre::eyre!( - "`ret` takes 0 or 1 unsigned int operands, got {other:?}" - )); - } - }; - Ok(vec![vihaco_cpu::Instruction::Return(keep).into()]) - } - "const.str" => { - let lit = match raw.operands.as_slice() { - [RawOperand::StringLit(s)] => s.clone(), - other => { - return Err(eyre::eyre!( - "`const.str` takes one string literal, got {other:?}" - )); - } - }; - let addr = u32::try_from(self.strings.len()).map_err(|_| { - eyre::eyre!("string table overflowed u32 at `const.str` lowering") - })?; - self.strings.push(lit); - Ok(vec![ - vihaco_cpu::Instruction::Const(vihaco::Value::String(addr)).into(), - ]) - } - other => Err(eyre::eyre!( - "PPVMResolver: unhandled raw form `{other}` (operands: {:?})", - raw.operands - )), + fn intern(&mut self, value: &str) -> u32 { + if let Some(index) = self.strings.iter().position(|item| item == value) { + return index as u32; } - } -} - -impl Resolve for PPVMResolver { - type Module = Module; - fn resolve_module( - &mut self, - parsed: vihaco::syntax::ParsedModule, - ) -> eyre::Result { - let mut info = PPVMDeviceInfo::default(); - for header in parsed.headers { - Self::apply_header(&mut info, header)?; + let index = self.strings.len() as u32; + self.strings.push(value.to_owned()); + index + } + + fn runtime_type(ty: SurfaceType) -> Type { + match ty { + SurfaceType::Undefined => Type::Undefined, + SurfaceType::String => Type::String, + SurfaceType::Bool => Type::Bool, + SurfaceType::I64 => Type::I64, + SurfaceType::U32 => Type::U32, + SurfaceType::U64 => Type::U64, + SurfaceType::F64 => Type::F64, + SurfaceType::FunctionRef => Type::FunctionRef, + SurfaceType::HeapRef => Type::HeapRef, } + } - let mut code: Vec = Vec::new(); - let mut labels: HashMap = HashMap::new(); - let mut branch_patches: Vec<(usize, BranchPatch)> = Vec::new(); - let mut call_patches: Vec<(usize, CallPatch)> = Vec::new(); - for function in parsed.functions { - if labels - .insert(function.name.clone(), code.len() as u32) - .is_some() - { - return Err(eyre::eyre!("duplicate function name `@{}`", function.name)); - } - for item in function.body { - match item { - BodyItem::Direct(inst) => code.push(inst), - BodyItem::Raw(raw) => { - if let Some(name) = raw_as_label(&raw) { - if labels.insert(name.clone(), code.len() as u32).is_some() { - return Err(eyre::eyre!("duplicate label `@{name}`")); - } - continue; - } - if let Some(patch) = raw_as_branch(&raw) { - let idx = code.len(); - code.push(patch.placeholder()); - branch_patches.push((idx, patch)); - continue; - } - if let Some(patch) = raw_as_call(&raw)? { - let idx = code.len(); - code.push(patch.placeholder()); - call_patches.push((idx, patch)); - continue; - } - code.extend(self.lower_raw(raw)?); - } - } + fn lower_value(&mut self, ty: SurfaceType, value: SurfaceValue) -> eyre::Result { + let text = match value { + SurfaceValue::Quoted(QuotedString(value)) => { + return Ok(Value::String(self.intern(&value))); } - } - for (idx, patch) in branch_patches { - patch.apply(&mut code, idx, &labels)?; - } - for (idx, patch) in call_patches { - patch.apply(&mut code, idx, &labels)?; - } - - let strings = std::mem::take(&mut self.strings); - let module = Module { - code, - strings, - extra: info, - ..Default::default() + SurfaceValue::Bare(BareToken(value)) => value, }; - Ok(module) + Ok(match ty { + SurfaceType::Undefined => Value::Undefined, + SurfaceType::String => Value::String(self.intern(&text)), + SurfaceType::Bool => Value::Bool(text.parse()?), + SurfaceType::I64 => Value::I64(text.parse()?), + SurfaceType::U32 => Value::U32(text.parse()?), + SurfaceType::U64 => Value::U64(text.parse()?), + SurfaceType::F64 => Value::F64(text.parse()?), + SurfaceType::FunctionRef => Value::FunctionRef(text.parse()?), + SurfaceType::HeapRef => Value::HeapRef(text.parse()?), + }) } -} - -type Err<'src> = extra::Err>; -impl<'src> Parse<'src> for PPVMInstruction { - fn parser() -> impl Parser<'src, &'src str, Self, Err<'src>> { - use chumsky::prelude::*; - - let cpu = ::parser().map(PPVMInstruction::Cpu); - - // Reuse the derived parser for all CircuitInstruction variants, - // gated behind the `circuit.` prefix (covers gates, noise channels, - // measure/reset, trace, and truncate — i.e. everything circuit-side). - let circuit = just("circuit") - .then(just('.')) - .ignore_then(::parser()) - .map(PPVMInstruction::Circuit); - - // Try `circuit.` first so CPU doesn't see "circuit" as an identifier. - choice((circuit, cpu)) + fn lower_cpu( + &mut self, + instruction: CpuSurface, + labels: &HashMap, + functions: &HashMap, + ) -> eyre::Result> { + use CpuRuntime as R; + use CpuSurface as S; + let runtime = match instruction { + S::Span(a, b, c) => R::Span(a, b, c), + S::Label(_) => return Ok(None), + S::FunctionStart => R::FunctionStart, + S::FunctionEnd => R::FunctionEnd, + S::Breakpoint => R::Breakpoint, + S::Branch(name) => R::Branch( + *labels + .get(name.as_str()) + .ok_or_else(|| eyre::eyre!("undefined label `@{}`", name.as_str()))?, + ), + S::ConditionalBranch(a, b) => R::ConditionalBranch( + *labels + .get(a.as_str()) + .ok_or_else(|| eyre::eyre!("undefined label `@{}`", a.as_str()))?, + *labels + .get(b.as_str()) + .ok_or_else(|| eyre::eyre!("undefined label `@{}`", b.as_str()))?, + ), + S::Return(n) => R::Return(n), + S::IndirectCall => R::IndirectCall, + S::Call(arity, name) => R::Call( + arity, + *functions + .get(name.as_str()) + .ok_or_else(|| eyre::eyre!("undefined function `@{}`", name.as_str()))?, + ), + S::Halt => R::Halt, + S::Print => R::Print, + S::Load(ty, slot) => R::Load(Self::runtime_type(ty), slot), + S::Store(ty, slot) => R::Store(Self::runtime_type(ty), slot), + S::Dup => R::Dup, + S::HeapAlloc(n) => R::HeapAlloc(n), + S::GetItem => R::GetItem, + S::HeapDealloc => R::HeapDealloc, + S::Const(ty, value) => { + let value = self.lower_value(ty, value)?; + R::Const(Self::runtime_type(ty), value) + } + S::Add(ty) => R::Add(Self::runtime_type(ty)), + S::Sub(ty) => R::Sub(Self::runtime_type(ty)), + S::Mul(ty) => R::Mul(Self::runtime_type(ty)), + S::Div(ty) => R::Div(Self::runtime_type(ty)), + S::Rem(ty) => R::Rem(Self::runtime_type(ty)), + S::Neg(ty) => R::Neg(Self::runtime_type(ty)), + S::Shl(ty) => R::Shl(Self::runtime_type(ty)), + S::Shr(ty) => R::Shr(Self::runtime_type(ty)), + S::Rol(ty) => R::Rol(Self::runtime_type(ty)), + S::Ror(ty) => R::Ror(Self::runtime_type(ty)), + S::BitAnd(ty) => R::BitAnd(Self::runtime_type(ty)), + S::BitOr(ty) => R::BitOr(Self::runtime_type(ty)), + S::BitXor(ty) => R::BitXor(Self::runtime_type(ty)), + S::Not => R::Not, + S::And => R::And, + S::Or => R::Or, + S::Xor => R::Xor, + S::Eq(ty) => R::Eq(Self::runtime_type(ty)), + S::Ne(ty) => R::Ne(Self::runtime_type(ty)), + S::Lt(ty) => R::Lt(Self::runtime_type(ty)), + S::Gt(ty) => R::Gt(Self::runtime_type(ty)), + S::Le(ty) => R::Le(Self::runtime_type(ty)), + S::Ge(ty) => R::Ge(Self::runtime_type(ty)), + }; + Ok(Some(runtime)) + } + + fn lower_circuit(instruction: CircuitSurfaceInstruction) -> CircuitInstruction { + use CircuitInstruction as R; + use CircuitSurfaceInstruction as S; + match instruction { + S::TwoQubitPauliError => R::TwoQubitPauliError, + S::Truncate => R::Truncate, + S::Trace => R::Trace, + S::X => R::X, + S::Y => R::Y, + S::Z => R::Z, + S::H => R::H, + S::SqrtXAdj => R::SqrtXAdj, + S::SqrtX => R::SqrtX, + S::SqrtYAdj => R::SqrtYAdj, + S::SqrtY => R::SqrtY, + S::SAdj => R::SAdj, + S::S => R::S, + S::CNOT => R::CNOT, + S::CZ => R::CZ, + S::TAdj => R::TAdj, + S::T => R::T, + S::RXX => R::RXX, + S::RYY => R::RYY, + S::RZZ => R::RZZ, + S::RX => R::RX, + S::RY => R::RY, + S::RZ => R::RZ, + S::U3 => R::U3, + S::Measure => R::Measure, + S::Reset => R::Reset, + S::R => R::R, + S::Loss => R::Loss, + S::CorrelatedLoss => R::CorrelatedLoss, + S::PauliError => R::PauliError, + S::Depolarize2 => R::Depolarize2, + S::Depolarize => R::Depolarize, + } } } -// ---- Everything below is 1:1 copy from Acamar with Acamar -> PPVM renaming ---- - -/// A deferred branch whose target(s) couldn't be resolved at lowering time -/// because the label may appear later in the function body. Patched in a -/// second pass once all labels are known. -#[derive(Debug)] -enum BranchPatch { - /// `br @target` — fills the `u32` in `cpu::Instruction::Branch`. - Unconditional(String), - /// `br @t, @f` / `cond_br @t, @f` — fills both `u32`s in - /// `cpu::Instruction::ConditionalBranch`. - Conditional(String, String), -} - -/// `@name:` → `Some("name")`. Body parser already emits `@entry:` as a single -/// raw mnemonic with no operands, so the check is purely on the mnemonic -/// shape. -fn raw_as_label(raw: &RawForm) -> Option { - if !raw.operands.is_empty() { - return None; - } - let m = raw.mnemonic.as_str(); - let stripped = m.strip_prefix('@')?.strip_suffix(':')?; - if stripped.is_empty() { - return None; - } - Some(stripped.to_string()) -} +impl Resolve> for PPVMResolver { + type Module = LocalModule; -/// `br @t` / `br @t, @f` / `cond_br @t, @f`. -fn raw_as_branch(raw: &RawForm) -> Option { - let symbols: Vec<&str> = raw - .operands - .iter() - .map(|op| match op { - RawOperand::Symbol(s) => Some(s.as_str()), - _ => None, - }) - .collect::>>()?; + fn resolve_module( + &mut self, + parsed: ParsedModule>, + ) -> eyre::Result { + let mut module = LocalModule::default(); + for header in parsed.header { + Self::apply_header(&mut module.extra, header)?; + } - match (raw.mnemonic.as_str(), symbols.as_slice()) { - ("br", [t]) => Some(BranchPatch::Unconditional((*t).to_string())), - ("br", [t, f]) | ("cond_br", [t, f]) => { - Some(BranchPatch::Conditional((*t).to_string(), (*f).to_string())) + let mut functions = HashMap::new(); + let mut function_address = 0u32; + for function in &parsed.functions { + functions.insert(function.name.as_str().to_owned(), function_address); + function_address += function + .body + .iter() + .filter(|instruction| { + !matches!( + instruction, + ppvm::syntax::Instruction::Cpu(CpuSurface::Label(_)) + ) + }) + .count() as u32; } - _ => None, - } -} -impl BranchPatch { - fn placeholder(&self) -> PPVMInstruction { - match self { - BranchPatch::Unconditional(_) => vihaco_cpu::Instruction::Branch(u32::MAX).into(), - BranchPatch::Conditional(_, _) => { - vihaco_cpu::Instruction::ConditionalBranch(u32::MAX, u32::MAX).into() + let mut labels = HashMap::new(); + let mut address = 0u32; + for function in &parsed.functions { + for instruction in &function.body { + if let ppvm::syntax::Instruction::Cpu(CpuSurface::Label(name)) = instruction { + labels.insert(name.as_str().to_owned(), address); + module.labels.push(LabelInfo { + address, + name: self.intern(name.as_str()), + }); + } else { + address += 1; + } } } - } - fn apply( - self, - code: &mut [PPVMInstruction], - idx: usize, - labels: &HashMap, - ) -> eyre::Result<()> { - let lookup = |name: &str| { - labels - .get(name) - .copied() - .ok_or_else(|| eyre::eyre!("undefined label `@{name}`")) - }; - let resolved = match self { - BranchPatch::Unconditional(t) => vihaco_cpu::Instruction::Branch(lookup(&t)?).into(), - BranchPatch::Conditional(t, f) => { - vihaco_cpu::Instruction::ConditionalBranch(lookup(&t)?, lookup(&f)?).into() + for function in parsed.functions { + let start_address = module.code.len() as u32; + for instruction in function.body { + let runtime = match instruction { + ppvm::syntax::Instruction::Cpu(cpu) => self + .lower_cpu(cpu, &labels, &functions)? + .map(PPVMInstruction::Cpu), + ppvm::syntax::Instruction::Circuit(circuit) => { + Some(PPVMInstruction::Circuit(Self::lower_circuit(circuit))) + } + }; + if let Some(runtime) = runtime { + module.code.push(runtime); + } } - }; - code[idx] = resolved; - Ok(()) - } -} - -/// `call , @target` — symbolic target resolved in a second pass against -/// the same label table that holds branch targets and function entry points. -#[derive(Debug)] -struct CallPatch { - arity: u32, - target: String, -} - -/// `call , @target` → `Some(CallPatch)`. Returns `Ok(None)` for any -/// other mnemonic so the resolver can fall through to `lower_raw`. -fn raw_as_call(raw: &RawForm) -> eyre::Result> { - if raw.mnemonic != "call" { - return Ok(None); - } - match raw.operands.as_slice() { - [RawOperand::UInt(arity), RawOperand::Symbol(target)] => { - let arity = u32::try_from(*arity) - .map_err(|_| eyre::eyre!("`call` arity {arity} does not fit in u32"))?; - Ok(Some(CallPatch { - arity, - target: target.clone(), - })) + let end_address = module.code.len() as u32; + module.functions.push(FunctionInfo { + name: self.intern(function.name.as_str()), + signature: Signature { + params: function + .params + .into_iter() + .map(|Param { name, ty }| Parameter { + name: self.intern(name.as_str()), + ty: Self::runtime_type(ty), + }) + .collect(), + ret: function + .return_ty + .map(Self::runtime_type) + .into_iter() + .collect(), + }, + local_count: 0, + start_address, + end_address, + file: 0, + }); } - other => Err(eyre::eyre!( - "`call` expects `, @`, got operands {other:?}" - )), - } -} - -impl CallPatch { - fn placeholder(&self) -> PPVMInstruction { - vihaco_cpu::Instruction::Call(self.arity, u32::MAX).into() - } - - fn apply( - self, - code: &mut [PPVMInstruction], - idx: usize, - labels: &HashMap, - ) -> eyre::Result<()> { - let target = labels - .get(&self.target) - .copied() - .ok_or_else(|| eyre::eyre!("undefined function `@{}`", self.target))?; - code[idx] = vihaco_cpu::Instruction::Call(self.arity, target).into(); - Ok(()) + module.main_function = functions.get("main").copied(); + module.strings = std::mem::take(&mut self.strings); + Ok(module) } } -#[cfg(test)] -mod tests { - use super::*; - use vihaco::syntax::ParsedModule; - - fn parse_module(source: &str) -> ParsedModule { - ParsedModule::::parser() - .parse(source) - .into_result() - .unwrap_or_else(|e| panic!("parse failed: {e:?}")) - } - - fn raw(mnemonic: &str, operands: Vec) -> RawForm { - RawForm { - mnemonic: mnemonic.to_string(), - operands, +pub fn parse_headers(source: &str) -> eyre::Result<(Vec, String)> { + let mut headers = Vec::new(); + let mut body = String::new(); + for line in source.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("device ") { + let header = trimmed.strip_suffix(';').unwrap_or(trimmed); + let parsed = PPVMHeader::parser() + .parse(header) + .into_result() + .map_err(|errors| eyre::eyre!("invalid device header `{header}`: {errors:?}"))?; + headers.push(parsed); + } else { + body.push_str(line); + body.push('\n'); } } + Ok((headers, body)) +} - // ─── Header parsing ─────────────────────────────────────────────────── - - #[test] - fn header_parses_n_qubits() { - let got = ::parser() - .parse("device circuit.n_qubits 5") - .into_result() - .unwrap_or_else(|e| panic!("parse failed: {e:?}")); - assert_eq!(got, PPVMHeader::NumQubits(5)); - } - - #[test] - fn header_parses_coefficient_threshold() { - let got = ::parser() - .parse("device circuit.coefficient_threshold 1e-10") - .into_result() - .unwrap_or_else(|e| panic!("parse failed: {e:?}")); - assert_eq!(got, PPVMHeader::CoefficientThreshold(1e-10)); - } - - #[test] - fn header_n_qubits_rejects_extra_operand() { - // The variant has exactly one field, so the parser consumes one - // integer. Wrapped in a full module, a second integer must trip the - // module-level parser. - let result = ParsedModule::::parser() - .parse( - "device circuit.n_qubits 5 6;\n\ - fn @main() { ret }\n", - ) - .into_result(); - assert!(result.is_err(), "expected parse error, got {result:?}"); - } - - #[test] - fn header_coefficient_threshold_rejects_extra_operand() { - let result = ParsedModule::::parser() - .parse( - "device circuit.coefficient_threshold 1e-10 0.5;\n\ - fn @main() { ret }\n", - ) - .into_result(); - assert!(result.is_err(), "expected parse error, got {result:?}"); - } - - #[test] - fn header_parses_backend_tableau() { - let got = ::parser() - .parse("device circuit.backend tableau") - .into_result() - .unwrap_or_else(|e| panic!("parse failed: {e:?}")); - assert_eq!(got, PPVMHeader::Backend(BackendKind::Tableau)); - } - - #[test] - fn header_parses_backend_paulisum() { - let got = ::parser() - .parse("device circuit.backend paulisum") - .into_result() - .unwrap_or_else(|e| panic!("parse failed: {e:?}")); - assert_eq!(got, PPVMHeader::Backend(BackendKind::PauliSum)); - } - - #[test] - fn header_parses_backend_lossy_paulisum() { - let got = ::parser() - .parse("device circuit.backend lossy_paulisum") - .into_result() - .unwrap_or_else(|e| panic!("parse failed: {e:?}")); - assert_eq!(got, PPVMHeader::Backend(BackendKind::LossyPauliSum)); - } - - #[test] - fn header_parses_observable_single_pauli_word() { - let got = ::parser() - .parse("device circuit.observable ZZIIII") - .into_result() - .unwrap_or_else(|e| panic!("parse failed: {e:?}")); - assert_eq!(got, PPVMHeader::Observable("ZZIIII".to_string())); - } - - #[test] - fn header_parses_max_pauli_weight() { - let got = ::parser() - .parse("device circuit.max_pauli_weight 8") - .into_result() - .unwrap_or_else(|e| panic!("parse failed: {e:?}")); - assert_eq!(got, PPVMHeader::MaxPauliWeight(8)); - } - - #[test] - fn apply_header_sets_n_qubits() { - let mut info = PPVMDeviceInfo::default(); - PPVMResolver::apply_header(&mut info, PPVMHeader::NumQubits(7)).unwrap(); - assert_eq!(info.n_qubits, 7); - } - - #[test] - fn apply_header_sets_coefficient_threshold() { - let mut info = PPVMDeviceInfo::default(); - PPVMResolver::apply_header(&mut info, PPVMHeader::CoefficientThreshold(5e-6)).unwrap(); - assert_eq!(info.coefficient_threshold, 5e-6); - } - - #[test] - fn apply_header_sets_backend() { - let mut info = PPVMDeviceInfo::default(); - PPVMResolver::apply_header(&mut info, PPVMHeader::Backend(BackendKind::PauliSum)).unwrap(); - assert_eq!(info.backend, BackendKind::PauliSum); - } - - #[test] - fn apply_header_sets_observable() { - let mut info = PPVMDeviceInfo::default(); - PPVMResolver::apply_header(&mut info, PPVMHeader::Observable("ZZ".to_string())).unwrap(); - assert_eq!(info.observable.as_deref(), Some("ZZ")); - } - - #[test] - fn apply_header_sets_max_pauli_weight() { - let mut info = PPVMDeviceInfo::default(); - PPVMResolver::apply_header(&mut info, PPVMHeader::MaxPauliWeight(4)).unwrap(); - assert_eq!(info.max_pauli_weight, Some(4)); - } - - #[test] - fn device_info_defaults_match_tableau_no_observable_no_truncation() { - let info = PPVMDeviceInfo::default(); - assert_eq!(info.backend, BackendKind::Tableau); - assert_eq!(info.observable, None); - assert_eq!(info.max_pauli_weight, None); - } - - // ─── PPVMInstruction parser dispatch ────────────────────────────────── - - #[test] - fn ppvm_instruction_parses_cpu_const() { - let got = ::parser() - .parse("const.u64 7") - .into_result() - .unwrap(); - assert!(matches!( - got, - PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const(Value::U64(7))) - )); - } - - #[test] - fn ppvm_instruction_parses_gate_h() { - let got = ::parser() - .parse("circuit.h") - .into_result() - .unwrap(); - assert!(matches!( - got, - PPVMInstruction::Circuit(CircuitInstruction::H) - )); - } - - #[test] - fn ppvm_instruction_parses_gate_cnot() { - let got = ::parser() - .parse("circuit.cnot") - .into_result() - .unwrap(); - assert!(matches!( - got, - PPVMInstruction::Circuit(CircuitInstruction::CNOT) - )); - } - - #[test] - fn ppvm_instruction_parses_gate_measure() { - let got = ::parser() - .parse("circuit.measure") - .into_result() - .unwrap(); - assert!(matches!( - got, - PPVMInstruction::Circuit(CircuitInstruction::Measure) - )); - } - - #[test] - fn ppvm_instruction_parses_gate_rx() { - let got = ::parser() - .parse("circuit.rx") - .into_result() - .unwrap(); - assert!(matches!( - got, - PPVMInstruction::Circuit(CircuitInstruction::RX) - )); - } - - #[test] - fn ppvm_instruction_rejects_bare_circuit_token_without_circuit_prefix() { - // `h` on its own must not parse as Circuit(H) — only `circuit.h` does. - // Without `circuit `, the CPU parser is tried, which should reject - // `h` (not a CPU mnemonic). - let result = ::parser() - .parse("h") - .into_result(); - assert!(result.is_err(), "expected parse error, got {result:?}"); - } - - // ─── lower_raw ──────────────────────────────────────────────────────── - - #[test] - fn lower_raw_ret_emits_return_zero() { - let mut r = PPVMResolver::new(); - let out = r.lower_raw(raw("ret", vec![])).unwrap(); - assert_eq!(out.len(), 1); - assert!(matches!( - out[0], - PPVMInstruction::Cpu(vihaco_cpu::Instruction::Return(0)) - )); - } - - #[test] - fn lower_raw_ret_with_uint_operand_emits_return_n() { - let mut r = PPVMResolver::new(); - let out = r.lower_raw(raw("ret", vec![RawOperand::UInt(2)])).unwrap(); - assert_eq!(out.len(), 1); - assert!(matches!( - out[0], - PPVMInstruction::Cpu(vihaco_cpu::Instruction::Return(2)) - )); - } - - #[test] - fn lower_raw_ret_with_non_uint_operand_errors() { - let mut r = PPVMResolver::new(); - let err = r - .lower_raw(raw("ret", vec![RawOperand::Symbol("foo".into())])) - .unwrap_err(); - assert!( - err.to_string().contains("`ret` takes 0 or 1 unsigned int"), - "err: {err}" - ); - } - - #[test] - fn lower_raw_unknown_mnemonic_errors() { - let mut r = PPVMResolver::new(); - let err = r.lower_raw(raw("nope", vec![])).unwrap_err(); - assert!(err.to_string().contains("unhandled raw form"), "err: {err}"); - } - - // ─── End-to-end resolver behaviour ──────────────────────────────────── - - #[test] - fn resolver_populates_device_info_from_headers() { - let parsed = parse_module( - "device circuit.n_qubits 3;\n\ - device circuit.coefficient_threshold 1e-8;\n\ - fn @main() { ret }\n", - ); - let m = PPVMResolver::new().resolve_module(parsed).unwrap(); - assert_eq!(m.extra.n_qubits, 3); - assert_eq!(m.extra.coefficient_threshold, 1e-8); - } - - #[test] - fn resolver_populates_paulisum_headers() { - let parsed = parse_module( - "device circuit.n_qubits 4;\n\ - device circuit.backend paulisum;\n\ - device circuit.observable ZZII;\n\ - device circuit.max_pauli_weight 8;\n\ - fn @main() { ret }\n", - ); - let m = PPVMResolver::new().resolve_module(parsed).unwrap(); - assert_eq!(m.extra.n_qubits, 4); - assert_eq!(m.extra.backend, BackendKind::PauliSum); - assert_eq!(m.extra.observable.as_deref(), Some("ZZII")); - assert_eq!(m.extra.max_pauli_weight, Some(8)); - } - - #[test] - fn resolver_lowers_simple_bell_body() { - // Smoke test the whole pipeline on a tiny bell-like body. - let parsed = parse_module( - "device circuit.n_qubits 2;\n\ - fn @main() {\n\ - const.u64 0\n\ - circuit.h\n\ - const.u64 0\n\ - const.u64 1\n\ - circuit.cnot\n\ - ret\n\ - }\n", - ); - let m = PPVMResolver::new().resolve_module(parsed).unwrap(); - // const.u64 0 / circuit.h / const.u64 0 / const.u64 1 / circuit.cnot / ret - assert_eq!(m.code.len(), 6); - assert!(matches!( - m.code[1], - PPVMInstruction::Circuit(CircuitInstruction::H) - )); - assert!(matches!( - m.code[4], - PPVMInstruction::Circuit(CircuitInstruction::CNOT) - )); - assert!(matches!( - m.code[5], - PPVMInstruction::Cpu(vihaco_cpu::Instruction::Return(0)) - )); - } - - #[test] - fn resolver_resolves_forward_branch_targets() { - let parsed = parse_module( - "fn @main() {\n\ - @loop:\n\ - br @done\n\ - @done:\n\ - ret\n\ - }\n", - ); - let m = PPVMResolver::new().resolve_module(parsed).unwrap(); - assert!(matches!( - m.code[0], - PPVMInstruction::Cpu(vihaco_cpu::Instruction::Branch(1)) - )); - } - - #[test] - fn resolver_resolves_conditional_branch_with_two_targets() { - let parsed = parse_module( - "fn @main() {\n\ - @head:\n\ - br @head, @exit\n\ - @exit:\n\ - ret\n\ - }\n", - ); - let m = PPVMResolver::new().resolve_module(parsed).unwrap(); - assert!(matches!( - m.code[0], - PPVMInstruction::Cpu(vihaco_cpu::Instruction::ConditionalBranch(0, 1)) - )); - } - - #[test] - fn resolver_rejects_undefined_branch_target() { - let parsed = parse_module( - "fn @main() {\n\ - br @missing\n\ - ret\n\ - }\n", - ); - let err = PPVMResolver::new().resolve_module(parsed).unwrap_err(); - assert!( - err.to_string().contains("undefined label `@missing`"), - "err: {err}" - ); - } - - #[test] - fn resolver_rejects_duplicate_label() { - let parsed = parse_module( - "fn @main() {\n\ - @same:\n\ - ret\n\ - @same:\n\ - ret\n\ - }\n", - ); - let err = PPVMResolver::new().resolve_module(parsed).unwrap_err(); - assert!( - err.to_string().contains("duplicate label `@same`"), - "err: {err}" - ); - } +pub fn parse_functions( + source: &str, +) -> eyre::Result>> { + skip() + .ignore_then( + ParsedFunction::::parser() + .repeated() + .collect::>(), + ) + .then_ignore(skip()) + .parse(source) + .into_result() + .map_err(|errors| eyre::eyre!("parsing functions failed: {errors:?}")) } diff --git a/crates/ppvm-vihaco/tests/bell.sst b/crates/ppvm-vihaco/tests/bell.sst index cdf760418..7b7e3119f 100644 --- a/crates/ppvm-vihaco/tests/bell.sst +++ b/crates/ppvm-vihaco/tests/bell.sst @@ -1,18 +1,18 @@ device circuit.n_qubits 2; fn @main() { - const.u64 0 - circuit.h + cpu::cpu.const u64, 0 + circuit::circuit.h - const.u64 0 - const.u64 1 - circuit.cnot + cpu::cpu.const u64, 0 + cpu::cpu.const u64, 1 + circuit::circuit.cnot - const.u64 0 - circuit.measure + cpu::cpu.const u64, 0 + circuit::circuit.measure - const.u64 1 - circuit.measure + cpu::cpu.const u64, 1 + circuit::circuit.measure - ret + cpu::cpu.ret 0 } diff --git a/crates/ppvm-vihaco/tests/branch_on_outcome.sst b/crates/ppvm-vihaco/tests/branch_on_outcome.sst index d86de1896..43b8190e5 100644 --- a/crates/ppvm-vihaco/tests/branch_on_outcome.sst +++ b/crates/ppvm-vihaco/tests/branch_on_outcome.sst @@ -1,29 +1,29 @@ device circuit.n_qubits 2; fn @main() { - const.u64 0 - circuit.h + cpu::cpu.const u64, 0 + circuit::circuit.h - const.u64 0 - circuit.measure + cpu::cpu.const u64, 0 + circuit::circuit.measure // Stack: [outcome]. No loss gate, so outcome is 0 or 1. Compare to 1 // to derive a bool for cond_br. - const.u32 1 - eq.u32 + cpu::cpu.const u32, 1 + cpu::cpu.eq u32 - cond_br @one, @zero + cpu::cpu.cond_br @one, @zero - @one: - const.u64 1 - circuit.x - br @measure_q1 + cpu::cpu.label @one + cpu::cpu.const u64, 1 + circuit::circuit.x + cpu::cpu.br @measure_q1 - @zero: - br @measure_q1 + cpu::cpu.label @zero + cpu::cpu.br @measure_q1 - @measure_q1: - const.u64 1 - circuit.measure - ret + cpu::cpu.label @measure_q1 + cpu::cpu.const u64, 1 + circuit::circuit.measure + cpu::cpu.ret 0 } diff --git a/crates/ppvm-vihaco/tests/branch_on_outcome_x.sst b/crates/ppvm-vihaco/tests/branch_on_outcome_x.sst index 501797c4f..f877999a0 100644 --- a/crates/ppvm-vihaco/tests/branch_on_outcome_x.sst +++ b/crates/ppvm-vihaco/tests/branch_on_outcome_x.sst @@ -2,29 +2,29 @@ device circuit.n_qubits 2; fn @main() { // X on q0 -> |1>, measure -> outcome is deterministically 1. - const.u64 0 - circuit.x + cpu::cpu.const u64, 0 + circuit::circuit.x - const.u64 0 - circuit.measure + cpu::cpu.const u64, 0 + circuit::circuit.measure // Stack: [outcome]. No loss gate, so outcome is 0 or 1. Compare to 1 // to derive a bool for cond_br. - const.u32 1 - eq.u32 + cpu::cpu.const u32, 1 + cpu::cpu.eq u32 - cond_br @one, @zero + cpu::cpu.cond_br @one, @zero - @one: - const.u64 1 - circuit.x - br @measure_q1 + cpu::cpu.label @one + cpu::cpu.const u64, 1 + circuit::circuit.x + cpu::cpu.br @measure_q1 - @zero: - br @measure_q1 + cpu::cpu.label @zero + cpu::cpu.br @measure_q1 - @measure_q1: - const.u64 1 - circuit.measure - ret + cpu::cpu.label @measure_q1 + cpu::cpu.const u64, 1 + circuit::circuit.measure + cpu::cpu.ret 0 } diff --git a/crates/ppvm-vihaco/tests/function_call.sst b/crates/ppvm-vihaco/tests/function_call.sst index c001a5160..1b549f4de 100644 --- a/crates/ppvm-vihaco/tests/function_call.sst +++ b/crates/ppvm-vihaco/tests/function_call.sst @@ -4,16 +4,16 @@ fn @main() { // Jump into the helper, which finishes the program with `halt`. // Using `halt` instead of `ret` from the callee avoids depending on // vihaco-cpu restoring a return PC, which it doesn't track today. - call 0, @run_circuit - ret + cpu::cpu.call 0, run_circuit + cpu::cpu.ret 0 } fn @run_circuit() { - const.u64 1 - circuit.h + cpu::cpu.const u64, 1 + circuit::circuit.h - const.u64 1 - circuit.measure + cpu::cpu.const u64, 1 + circuit::circuit.measure - ret 1 + cpu::cpu.ret 1 } diff --git a/crates/ppvm-vihaco/tests/function_call_branch_both.sst b/crates/ppvm-vihaco/tests/function_call_branch_both.sst index 46b538f12..f16e00afe 100644 --- a/crates/ppvm-vihaco/tests/function_call_branch_both.sst +++ b/crates/ppvm-vihaco/tests/function_call_branch_both.sst @@ -12,52 +12,52 @@ device circuit.n_qubits 2; // P(kept ∧ outcome = 0) = 0.25 → q1 stays in |0> → m1 = 0 // → P(m1 = 1) = 0.75. fn @main() { - const.u64 0 - circuit.h + cpu::cpu.const u64, 0 + circuit::circuit.h - const.u64 0 - const.f64 0.5 - circuit.loss + cpu::cpu.const u64, 0 + cpu::cpu.const f64, 0.5 + circuit::circuit.loss - call 0, @measure_q0 + cpu::cpu.call 0, measure_q0 // Stack: [outcome]. Branch on Lost (outcome == 2) first. - dup - const.u32 2 - eq.u32 - cond_br @lost, @kept + cpu::cpu.dup + cpu::cpu.const u32, 2 + cpu::cpu.eq u32 + cpu::cpu.cond_br @lost, @kept - @lost: + cpu::cpu.label @lost // is_lost = true: outcome is meaningless. Flip q1 to mark "lost" // path. The leftover `outcome` value on the stack is harmless because // we halt at @final without reading it. - const.u64 1 - circuit.x - br @final + cpu::cpu.const u64, 1 + circuit::circuit.x + cpu::cpu.br @final - @kept: + cpu::cpu.label @kept // Stack: [outcome (0 or 1)]. Compare to 1 to derive a bool. - const.u32 1 - eq.u32 - cond_br @outcome_one, @outcome_zero - - @outcome_one: - const.u64 1 - circuit.x - br @final - - @outcome_zero: - br @final - - @final: - const.u64 1 - circuit.measure - halt + cpu::cpu.const u32, 1 + cpu::cpu.eq u32 + cpu::cpu.cond_br @outcome_one, @outcome_zero + + cpu::cpu.label @outcome_one + cpu::cpu.const u64, 1 + circuit::circuit.x + cpu::cpu.br @final + + cpu::cpu.label @outcome_zero + cpu::cpu.br @final + + cpu::cpu.label @final + cpu::cpu.const u64, 1 + circuit::circuit.measure + cpu::cpu.halt } fn @measure_q0() { - const.u64 0 - circuit.measure + cpu::cpu.const u64, 0 + circuit::circuit.measure // Stack: [outcome] - ret 1 + cpu::cpu.ret 1 } diff --git a/crates/ppvm-vihaco/tests/function_call_ret.sst b/crates/ppvm-vihaco/tests/function_call_ret.sst index 15d995737..98e96f7eb 100644 --- a/crates/ppvm-vihaco/tests/function_call_ret.sst +++ b/crates/ppvm-vihaco/tests/function_call_ret.sst @@ -3,37 +3,37 @@ device circuit.n_qubits 2; fn @main() { // Put q0 into |+>. - const.u64 0 - circuit.h + cpu::cpu.const u64, 0 + circuit::circuit.h // Measure q1 via a helper that returns the outcome on top of the stack. - call 0, @measure_q1 + cpu::cpu.call 0, measure_q1 // Stack: [outcome]. Compare to 1 to derive a bool for cond_br. - const.u32 1 - eq.u32 - cond_br @one, @zero + cpu::cpu.const u32, 1 + cpu::cpu.eq u32 + cpu::cpu.cond_br @one, @zero - @one: + cpu::cpu.label @one // outcome was 1: apply X to q0 as a correction. - const.u64 0 - circuit.x - br @done + cpu::cpu.const u64, 0 + circuit::circuit.x + cpu::cpu.br @done - @zero: - br @done + cpu::cpu.label @zero + cpu::cpu.br @done - @done: - ret + cpu::cpu.label @done + cpu::cpu.ret 0 } fn @measure_q1() -> u32 { - const.u64 1 - circuit.h + cpu::cpu.const u64, 1 + circuit::circuit.h - const.u64 1 - circuit.measure + cpu::cpu.const u64, 1 + circuit::circuit.measure // Stack: [outcome] - ret 1 + cpu::cpu.ret 1 } diff --git a/crates/ppvm-vihaco/tests/hello_circuit.sst b/crates/ppvm-vihaco/tests/hello_circuit.sst index 21f8b619b..dd1717db7 100644 --- a/crates/ppvm-vihaco/tests/hello_circuit.sst +++ b/crates/ppvm-vihaco/tests/hello_circuit.sst @@ -1,16 +1,16 @@ device circuit.n_qubits 2; fn @main() { - const.u64 0 - circuit.h + cpu::cpu.const u64, 0 + circuit::circuit.h - const.u64 0 - const.u64 1 - circuit.cnot + cpu::cpu.const u64, 0 + cpu::cpu.const u64, 1 + circuit::circuit.cnot - const.u64 0 - const.f64 0.1 - circuit.rx + cpu::cpu.const u64, 0 + cpu::cpu.const f64, 0.1 + circuit::circuit.rx - ret + cpu::cpu.ret 0 } diff --git a/crates/ppvm-vihaco/tests/lossy_paulisum_loss_trace.sst b/crates/ppvm-vihaco/tests/lossy_paulisum_loss_trace.sst index 3f42aef14..2b5f7557e 100644 --- a/crates/ppvm-vihaco/tests/lossy_paulisum_loss_trace.sst +++ b/crates/ppvm-vihaco/tests/lossy_paulisum_loss_trace.sst @@ -12,17 +12,17 @@ device circuit.observable ZZ; // reference with the same Config/strategy, so we don't have to commit to // the exact loss-channel value — only that .sst and direct API agree. fn @main() { - const.u64 0 - const.u64 1 - circuit.cnot + cpu::cpu.const u64, 0 + cpu::cpu.const u64, 1 + circuit::circuit.cnot - const.u64 0 - const.f64 0.3 - circuit.loss + cpu::cpu.const u64, 0 + cpu::cpu.const f64, 0.3 + circuit::circuit.loss - circuit.truncate + circuit::circuit.truncate - const.str "Z?*" - circuit.trace - ret + cpu::cpu.const str, "Z?*" + circuit::circuit.trace + cpu::cpu.ret 0 } diff --git a/crates/ppvm-vihaco/tests/paulisum_bell_trace.sst b/crates/ppvm-vihaco/tests/paulisum_bell_trace.sst index e36fe339f..42246add1 100644 --- a/crates/ppvm-vihaco/tests/paulisum_bell_trace.sst +++ b/crates/ppvm-vihaco/tests/paulisum_bell_trace.sst @@ -4,15 +4,15 @@ device circuit.observable ZZ; fn @main() { // Textbook H(0); CNOT(0,1) — emit reversed for Heisenberg propagation. - const.u64 0 - const.u64 1 - circuit.cnot + cpu::cpu.const u64, 0 + cpu::cpu.const u64, 1 + circuit::circuit.cnot - const.u64 0 - circuit.h + cpu::cpu.const u64, 0 + circuit::circuit.h // Trace against |00>: match Z-or-identity on every qubit. - const.str "Z?*" - circuit.trace - ret + cpu::cpu.const str, "Z?*" + circuit::circuit.trace + cpu::cpu.ret 0 } diff --git a/crates/ppvm-vihaco/tests/paulisum_ghz_xxx_trace.sst b/crates/ppvm-vihaco/tests/paulisum_ghz_xxx_trace.sst index 53cb37c41..7e2683493 100644 --- a/crates/ppvm-vihaco/tests/paulisum_ghz_xxx_trace.sst +++ b/crates/ppvm-vihaco/tests/paulisum_ghz_xxx_trace.sst @@ -10,18 +10,18 @@ fn @main() { // --CNOT(0,1)--> XII // --H(0)------> ZII // Trace against Z/I-only Paulis picks up the coefficient of ZII = 1.0. - const.u64 1 - const.u64 2 - circuit.cnot + cpu::cpu.const u64, 1 + cpu::cpu.const u64, 2 + circuit::circuit.cnot - const.u64 0 - const.u64 1 - circuit.cnot + cpu::cpu.const u64, 0 + cpu::cpu.const u64, 1 + circuit::circuit.cnot - const.u64 0 - circuit.h + cpu::cpu.const u64, 0 + circuit::circuit.h - const.str "Z?*" - circuit.trace - ret + cpu::cpu.const str, "Z?*" + circuit::circuit.trace + cpu::cpu.ret 0 } diff --git a/crates/ppvm-vihaco/tests/paulisum_measure_error.sst b/crates/ppvm-vihaco/tests/paulisum_measure_error.sst index 9d5f88991..a6ebe6990 100644 --- a/crates/ppvm-vihaco/tests/paulisum_measure_error.sst +++ b/crates/ppvm-vihaco/tests/paulisum_measure_error.sst @@ -3,7 +3,7 @@ device circuit.backend paulisum; device circuit.observable Z; fn @main() { - const.u64 0 - circuit.measure - ret + cpu::cpu.const u64, 0 + circuit::circuit.measure + cpu::cpu.ret 0 } diff --git a/crates/ppvm-vihaco/tests/paulisum_multi_term_trace.sst b/crates/ppvm-vihaco/tests/paulisum_multi_term_trace.sst index 7de4f4f99..40fb69e01 100644 --- a/crates/ppvm-vihaco/tests/paulisum_multi_term_trace.sst +++ b/crates/ppvm-vihaco/tests/paulisum_multi_term_trace.sst @@ -5,7 +5,7 @@ device circuit.observable 1.0*ZZ+0.5*XX; fn @main() { // No gates — the multi-term observable seeds the state directly. // Pattern `[XZ]?*` matches both ZZ (coef 1.0) and XX (coef 0.5). - const.str "[XZ]?*" - circuit.trace - ret + cpu::cpu.const str, "[XZ]?*" + circuit::circuit.trace + cpu::cpu.ret 0 } diff --git a/crates/ppvm-vihaco/tests/paulisum_ry_z_trace.sst b/crates/ppvm-vihaco/tests/paulisum_ry_z_trace.sst index 449d92a79..69bf559eb 100644 --- a/crates/ppvm-vihaco/tests/paulisum_ry_z_trace.sst +++ b/crates/ppvm-vihaco/tests/paulisum_ry_z_trace.sst @@ -7,11 +7,11 @@ fn @main() { // RY(θ)† Z RY(θ) = cos(θ)·Z + sin(θ)·X. A single gate means no // reversal is needed beyond that. Trace against Z/I-only Paulis picks // up the cos(θ) coefficient on Z; the sin(θ)·X term contributes 0. - const.u64 0 - const.f64 0.7 - circuit.ry + cpu::cpu.const u64, 0 + cpu::cpu.const f64, 0.7 + circuit::circuit.ry - const.str "Z?*" - circuit.trace - ret + cpu::cpu.const str, "Z?*" + circuit::circuit.trace + cpu::cpu.ret 0 } diff --git a/crates/ppvm-vihaco/tests/paulisum_trotter_truncate.sst b/crates/ppvm-vihaco/tests/paulisum_trotter_truncate.sst index a94ee86a7..7c4f7d5da 100644 --- a/crates/ppvm-vihaco/tests/paulisum_trotter_truncate.sst +++ b/crates/ppvm-vihaco/tests/paulisum_trotter_truncate.sst @@ -7,31 +7,31 @@ fn @main() { // Two Trotter layers of RXX(0.1) RZZ(0.05), with explicit truncate // between layers. RXX branches Z->Y so the sum grows; truncate prunes // small-coefficient terms via `coefficient_threshold`. - const.u64 0 - const.u64 1 - const.f64 0.1 - circuit.rxx + cpu::cpu.const u64, 0 + cpu::cpu.const u64, 1 + cpu::cpu.const f64, 0.1 + circuit::circuit.rxx - const.u64 0 - const.u64 1 - const.f64 0.05 - circuit.rzz + cpu::cpu.const u64, 0 + cpu::cpu.const u64, 1 + cpu::cpu.const f64, 0.05 + circuit::circuit.rzz - circuit.truncate + circuit::circuit.truncate - const.u64 0 - const.u64 1 - const.f64 0.1 - circuit.rxx + cpu::cpu.const u64, 0 + cpu::cpu.const u64, 1 + cpu::cpu.const f64, 0.1 + circuit::circuit.rxx - const.u64 0 - const.u64 1 - const.f64 0.05 - circuit.rzz + cpu::cpu.const u64, 0 + cpu::cpu.const u64, 1 + cpu::cpu.const f64, 0.05 + circuit::circuit.rzz - circuit.truncate + circuit::circuit.truncate - const.str "Z?*" - circuit.trace - ret + cpu::cpu.const str, "Z?*" + circuit::circuit.trace + cpu::cpu.ret 0 } diff --git a/crates/ppvm-vihaco/tests/rotxy.sst b/crates/ppvm-vihaco/tests/rotxy.sst index 99e6c50ed..54acb6255 100644 --- a/crates/ppvm-vihaco/tests/rotxy.sst +++ b/crates/ppvm-vihaco/tests/rotxy.sst @@ -3,13 +3,13 @@ device circuit.n_qubits 1; fn @main() { // R(axis_angle = π/2, θ = π) == RY(π), so |0> is sent to |1>. // Stack order for `circuit.r`: qubit, then axis_angle, then theta. - const.u64 0 - const.f64 1.5707963267948966 - const.f64 3.141592653589793 - circuit.r + cpu::cpu.const u64, 0 + cpu::cpu.const f64, 1.5707963267948966 + cpu::cpu.const f64, 3.141592653589793 + circuit::circuit.r - const.u64 0 - circuit.measure + cpu::cpu.const u64, 0 + circuit::circuit.measure - ret + cpu::cpu.ret 0 } diff --git a/crates/ppvm-vihaco/tests/sst_fixtures.rs b/crates/ppvm-vihaco/tests/sst_fixtures.rs index a66d9445d..63f377e9e 100644 --- a/crates/ppvm-vihaco/tests/sst_fixtures.rs +++ b/crates/ppvm-vihaco/tests/sst_fixtures.rs @@ -46,7 +46,7 @@ fn hello_circuit_sst_parses_and_runs() { #[test] fn rotxy_sst_runs_and_flips_qubit() { // `rotxy.sst` applies R(axis_angle=π/2, θ=π) = RY(π) to q0, deterministically - // sending |0> → |1>, then measures it. Exercises the `circuit.r` path end to + // sending |0> → |1>, then measures it. Exercises the `circuit::circuit.r` path end to // end: parse → resolve (pop θ, axis_angle, qubit) → execute via `tab.r`. let machine = ppvm_vihaco::run_file("tests/rotxy.sst").unwrap_or_else(|e| panic!("run rotxy.sst: {e:?}")); @@ -108,7 +108,7 @@ fn function_call_returns() { #[test] fn branch_on_outcome_deterministic_x_path() { // `branch_on_outcome_x.sst` applies X to q0 instead of H, so the outcome - // is deterministically 1. The cond_br must therefore take the @one path, + // is deterministically 1. The cpu::cpu.cond_br must therefore take the @one path, // which flips q1 before measuring it, yielding m1 = 1 as well. let machine = ppvm_vihaco::run_file("tests/branch_on_outcome_x.sst") .unwrap_or_else(|e| panic!("run branch_on_outcome_x.sst: {e:?}")); @@ -203,7 +203,7 @@ fn function_call_branch_on_both_returned_values() { #[test] fn paulisum_bell_zz_trace_through_sst() { // Bell-state ⟨ZZ⟩ via PauliSum. Textbook circuit H(0); CNOT(0,1) is - // emitted reversed for Heisenberg propagation: `circuit.cnot; circuit.h`. + // emitted reversed for Heisenberg propagation: `circuit::circuit.cnot; circuit::circuit.h`. // Conjugating ZZ by CNOT(0,1) gives Z_1 (= IZ); H on q0 leaves IZ // untouched. Tracing against |00> matches IZ (pattern `Z?*`) and // returns +1.0 — matching ⟨Φ+|ZZ|Φ+⟩ = 1. @@ -238,7 +238,7 @@ fn paulisum_multi_term_observable_trace_through_sst() { #[test] fn paulisum_trotter_matches_pure_rust_reference() { // Two Trotter layers of RXX(0.1) + RZZ(0.05), interleaved with explicit - // `circuit.truncate`. The .sst-driven path should agree bit-for-bit with a + // `circuit::circuit.truncate`. The .sst-driven path should agree bit-for-bit with a // pure Rust PauliSum running the same gates: `indexmap::ByteFxHashF64` // gives deterministic iteration order (Decision 7), so truncation order // and float accumulation are stable across both paths. diff --git a/crates/ppvm-vihaco/tests/tableau_bell_trace.sst b/crates/ppvm-vihaco/tests/tableau_bell_trace.sst index d188046f3..795928243 100644 --- a/crates/ppvm-vihaco/tests/tableau_bell_trace.sst +++ b/crates/ppvm-vihaco/tests/tableau_bell_trace.sst @@ -2,17 +2,17 @@ device circuit.n_qubits 2; fn @main() { // Forward Bell prep: H(0); CNOT(0, 1) → |Φ+⟩. - const.u64 0 - circuit.h + cpu::cpu.const u64, 0 + circuit::circuit.h - const.u64 0 - const.u64 1 - circuit.cnot + cpu::cpu.const u64, 0 + cpu::cpu.const u64, 1 + circuit::circuit.cnot // Tableau-side trace: Σ_{P matches pat} ⟨ψ|P|ψ⟩. Positional `Z0Z1` matches // exactly the ZZ word, so this returns ⟨Φ+|ZZ|Φ+⟩ = 1.0 — the same value // the PauliSum backend produces by Heisenberg-propagating ZZ backward. - const.str "Z0Z1" - circuit.trace - ret + cpu::cpu.const str, "Z0Z1" + circuit::circuit.trace + cpu::cpu.ret 0 } diff --git a/crates/ppvm-vihaco/tests/tableau_ghz_xxx_trace.sst b/crates/ppvm-vihaco/tests/tableau_ghz_xxx_trace.sst index 5dd1c6054..a2edcb1dd 100644 --- a/crates/ppvm-vihaco/tests/tableau_ghz_xxx_trace.sst +++ b/crates/ppvm-vihaco/tests/tableau_ghz_xxx_trace.sst @@ -2,19 +2,19 @@ device circuit.n_qubits 3; fn @main() { // Forward GHZ prep: H(0); CNOT(0, 1); CNOT(1, 2) → (|000⟩+|111⟩)/√2. - const.u64 0 - circuit.h + cpu::cpu.const u64, 0 + circuit::circuit.h - const.u64 0 - const.u64 1 - circuit.cnot + cpu::cpu.const u64, 0 + cpu::cpu.const u64, 1 + circuit::circuit.cnot - const.u64 1 - const.u64 2 - circuit.cnot + cpu::cpu.const u64, 1 + cpu::cpu.const u64, 2 + circuit::circuit.cnot // `X{3}` matches exactly the Pauli word XXX. ⟨GHZ|XXX|GHZ⟩ = 1. - const.str "X{3}" - circuit.trace - ret + cpu::cpu.const str, "X{3}" + circuit::circuit.trace + cpu::cpu.ret 0 } diff --git a/crates/ppvm-vihaco/tests/tableau_ry_z_trace.sst b/crates/ppvm-vihaco/tests/tableau_ry_z_trace.sst index b50d74cdf..913543115 100644 --- a/crates/ppvm-vihaco/tests/tableau_ry_z_trace.sst +++ b/crates/ppvm-vihaco/tests/tableau_ry_z_trace.sst @@ -3,11 +3,11 @@ device circuit.n_qubits 1; fn @main() { // RY(θ)|0⟩ = cos(θ/2)|0⟩ + sin(θ/2)|1⟩, so ⟨ψ|Z|ψ⟩ = cos(θ). // Hard-coded θ = 0.7 → cos(0.7) ≈ 0.7648421872844885. - const.u64 0 - const.f64 0.7 - circuit.ry + cpu::cpu.const u64, 0 + cpu::cpu.const f64, 0.7 + circuit::circuit.ry - const.str "Z{1}" - circuit.trace - ret + cpu::cpu.const str, "Z{1}" + circuit::circuit.trace + cpu::cpu.ret 0 } diff --git a/crates/stim-parser/src/pipeline/lower.rs b/crates/stim-parser/src/pipeline/lower.rs index 042334246..91ac505d6 100644 --- a/crates/stim-parser/src/pipeline/lower.rs +++ b/crates/stim-parser/src/pipeline/lower.rs @@ -436,10 +436,8 @@ fn qubit_targets( } fn pair_targets(targets: &[usize]) -> Vec<(usize, usize)> { - targets - .chunks_exact(2) - .map(|pair| (pair[0], pair[1])) - .collect() + let (pairs, _) = targets.as_chunks::<2>(); + pairs.iter().map(|[a, b]| (*a, *b)).collect() } /// Validate that a tag carries exactly the `required` named parameters — no diff --git a/crates/stim-parser/tests/syntax.rs b/crates/stim-parser/tests/syntax.rs index b48722fff..3bddf0b9d 100644 --- a/crates/stim-parser/tests/syntax.rs +++ b/crates/stim-parser/tests/syntax.rs @@ -95,7 +95,7 @@ fn parse_repeat_then_following_instruction() { fn parse_repeat_one_line() { let p = parse("REPEAT 5 { H 0 }").unwrap(); let Instruction::Repeat { count, body, .. } = &p.instructions[0] else { - panic!("expected Repeat, got {:?}", &p.instructions[0]); + panic!("expected Repeat, got {:?}", p.instructions[0]); }; assert_eq!(*count, 5); assert_eq!(body.len(), 1); diff --git a/crates/vihaco-circuit-isa/Cargo.toml b/crates/vihaco-circuit-isa/Cargo.toml index 6c5e01887..d8bbd39bd 100644 --- a/crates/vihaco-circuit-isa/Cargo.toml +++ b/crates/vihaco-circuit-isa/Cargo.toml @@ -7,9 +7,8 @@ edition = "2024" chumsky = "0.10" eyre = "0.6.12" smallvec = "1.15.1" -vihaco = "0.1.1" -vihaco-parser = "0.1.1" -vihaco-parser-core = "0.1.1" +vihaco = "0.4.0" +vihaco-parser = "0.4.0" [package.metadata.cargo-machete] -ignored = ["eyre"] # transitive dependency in vihaco, somehow not handled correctly by machete +ignored = ["eyre", "vihaco-parser"] # transitive dependency in vihaco, somehow not handled correctly by machete diff --git a/crates/vihaco-circuit-isa/src/lib.rs b/crates/vihaco-circuit-isa/src/lib.rs index 71f196e3a..08f8fd637 100644 --- a/crates/vihaco-circuit-isa/src/lib.rs +++ b/crates/vihaco-circuit-isa/src/lib.rs @@ -2,85 +2,66 @@ // SPDX-License-Identifier: Apache-2.0 use smallvec::SmallVec; -use vihaco::Instruction; use vihaco::Message; -use vihaco_parser::Parse; - -/// The parse trait wired up by `#[derive(Parse)]`, re-exported so downstream -/// crates can call [`CircuitInstruction::parser`] without depending on -/// `vihaco-parser-core` directly to bring the trait into scope. -pub use vihaco_parser_core::Parse as ParseInstruction; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Instruction, Parse)] -pub enum CircuitInstruction { - // NOTE: longer tokens need to go first - TwoQubitPauliError, // needs to go before T - Truncate, // needs to go before T - Trace, // needs to go before T - - // Single-Qubit Clifford gates - X, - Y, - Z, - H, - - #[token = "sqrt_x_adj"] - SqrtXAdj, - - #[token = "sqrt_x"] - SqrtX, - - #[token = "sqrt_y_adj"] - SqrtYAdj, - - #[token = "sqrt_y"] - SqrtY, - - #[token = "s_adj"] - SAdj, - S, - - // Controlled gates - CNOT, - CZ, - - // T gate - TAdj, - T, - - // Two-qubit rotations - RXX, - RYY, - RZZ, - - // Single-qubit rotations - RX, - RY, - RZ, - - // U3 - U3, - - // Measurement & Reset - Measure, - Reset, - - // RXY - R, - - // Loss - Loss, - CorrelatedLoss, - - // Noise - PauliError, - Depolarize2, - Depolarize, + +vihaco::component! { + #[derive(Debug, Default)] + pub component Circuit {} + + instruction { + TwoQubitPauliError, + Truncate, + Trace, + X, + Y, + Z, + H, + + #[pattern = "'sqrt_x_adj"] + SqrtXAdj, + + #[pattern = "'sqrt_x"] + SqrtX, + + #[pattern = "'sqrt_y_adj"] + SqrtYAdj, + + #[pattern = "'sqrt_y"] + SqrtY, + + #[pattern = "'s_adj"] + SAdj, + S, + CNOT, + CZ, + TAdj, + T, + RXX, + RYY, + RZZ, + RX, + RY, + RZ, + U3, + Measure, + Reset, + R, + Loss, + CorrelatedLoss, + PauliError, + Depolarize2, + Depolarize, + } } -impl std::fmt::Display for CircuitInstruction { +pub use circuit::{runtime, syntax}; +pub use runtime::Instruction as CircuitInstruction; + +pub type CircuitSurfaceInstruction = syntax::Instruction; + +impl std::fmt::Display for runtime::Instruction { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - use CircuitInstruction::*; + use runtime::Instruction::*; match self { TwoQubitPauliError => write!(f, "TwoQubitPauliError"), Truncate => write!(f, "Truncate"), @@ -167,52 +148,15 @@ pub struct CircuitEffect { #[cfg(test)] mod tests { - use super::CircuitInstruction::*; + use super::syntax::Instruction::*; use super::*; use chumsky::Parser as _; - use vihaco::instruction::{FromBytes, OpCode, WriteBytes}; - /// Every variant, in declaration order. Anything iterating over the full - /// instruction set (round-trips, opcode uniqueness) goes through this so a - /// newly added variant is automatically covered. - const ALL: &[CircuitInstruction] = &[ - TwoQubitPauliError, - Truncate, - Trace, - X, - Y, - Z, - H, - SqrtXAdj, - SqrtX, - SqrtYAdj, - SqrtY, - SAdj, - S, - CNOT, - CZ, - TAdj, - T, - RXX, - RYY, - RZZ, - RX, - RY, - RZ, - U3, - Measure, - Reset, - R, - Loss, - CorrelatedLoss, - PauliError, - Depolarize2, - Depolarize, - ]; - - fn parse(src: &str) -> CircuitInstruction { - CircuitInstruction::parser() - .parse(src) + use vihaco::Parse; + fn parse(src: &str) -> CircuitSurfaceInstruction { + let src = format!("circuit.{src}"); + CircuitSurfaceInstruction::parser() + .parse(src.as_str()) .into_result() .unwrap_or_else(|e| panic!("parse of `{src}` failed: {e:?}")) } @@ -267,77 +211,30 @@ mod tests { #[test] fn rejects_unknown_token() { - assert!(CircuitInstruction::parser().parse("nope").has_errors()); - } - - #[test] - fn rejects_pascal_case_token() { - // The parse token is lowercase; the Display form must not parse back. - assert!(CircuitInstruction::parser().parse("CNOT").has_errors()); - } - - // ─── Display: PascalCase variant names ──────────────────────────────── - - #[test] - fn display_uses_pascal_case_names() { - assert_eq!(H.to_string(), "H"); - assert_eq!(CNOT.to_string(), "CNOT"); - assert_eq!(TwoQubitPauliError.to_string(), "TwoQubitPauliError"); - assert_eq!(Trace.to_string(), "Trace"); - assert_eq!(Truncate.to_string(), "Truncate"); - // Custom-token variants display their Rust name, not the parse token. - assert_eq!(SqrtXAdj.to_string(), "SqrtXAdj"); - assert_eq!(SAdj.to_string(), "SAdj"); - } - - // ─── Instruction codec (derived OpCode / WriteBytes / FromBytes) ────── - - #[test] - fn opcodes_are_unit_width() { - // All variants are field-less, so each encodes to a single byte. - assert_eq!(CircuitInstruction::width(), 1); - } - - #[test] - fn opcodes_are_unique() { - let mut seen = std::collections::HashSet::new(); - for inst in ALL { + assert!( + CircuitSurfaceInstruction::parser() + .parse("circuit.nope") + .has_errors() + ); + + for token in ["sadj", "sqrtx", "sqrtxadj", "sqrty", "sqrtyadj"] { + let source = format!("circuit.{token}"); assert!( - seen.insert(inst.opcode()), - "duplicate opcode {} for {inst:?}", - inst.opcode() + CircuitSurfaceInstruction::parser() + .parse(&source) + .has_errors(), + "legacy compact token should be rejected: {token}" ); } - assert_eq!(seen.len(), ALL.len()); - } - - #[test] - fn opcodes_match_declaration_order() { - // Opcodes default to the variant index, which is the on-disk contract - // for bytecode. Reordering variants silently breaks old bytecode, so - // pin the assignment here. - for (index, inst) in ALL.iter().enumerate() { - assert_eq!(inst.opcode() as usize, index, "{inst:?}"); - } } #[test] - fn write_then_read_round_trips_every_variant() { - for inst in ALL { - let mut buf = Vec::new(); - inst.write_bytes(&mut buf).unwrap(); - assert_eq!(buf, [inst.opcode()], "{inst:?} should encode to one byte"); - - let mut cursor = std::io::Cursor::new(buf); - let back = CircuitInstruction::from_bytes(&mut cursor).unwrap(); - assert_eq!(back, *inst); - } - } - - #[test] - fn from_bytes_rejects_unknown_opcode() { - let mut cursor = std::io::Cursor::new([0xFFu8]); - let err = CircuitInstruction::from_bytes(&mut cursor).unwrap_err(); - assert!(err.to_string().contains("invalid opcode"), "err: {err}"); + fn rejects_pascal_case_token() { + // The parse token is lowercase; the Display form must not parse back. + assert!( + CircuitSurfaceInstruction::parser() + .parse("circuit.CNOT") + .has_errors() + ); } } diff --git a/docs/changelog/vihaco-0.4.md b/docs/changelog/vihaco-0.4.md new file mode 100644 index 000000000..e94fd5ecc --- /dev/null +++ b/docs/changelog/vihaco-0.4.md @@ -0,0 +1,369 @@ +# PPVM vihaco 0.4.0 changes + +This changelog describes the completed migration of `ppvm-vihaco` and its +supporting ISA crate from vihaco 0.1.1 to vihaco 0.4.0. It is an informative +reference for users and contributors who need to understand what changed, +especially in `.sst` instruction syntax. + +PPVM now uses the vihaco 0.4 model: + +```text +source instruction -> surface instruction -> runtime instruction +``` + +The lowering step resolves names, types, literals, and addresses while +preserving one source instruction per runtime instruction. Syntax sugar that +expands one source instruction into several runtime instructions is not part +of the language. + +## Migration summary + +The migration is implemented across the PPVM runtime, circuit ISA, parser, +bytecode codec, CLI/TUI call sites, and `.sst` fixtures. The implementation +uses v0.4 surface and runtime instruction types, generated component +instruction sets, explicit one-to-one lowering, and bytecode v2 metadata for +functions, labels, signatures, and the entry point. + +The full workspace test suite is the semantic verification gate. It covers +source execution, bytecode round trips, function calls, branching, +measurements, all PPVM backends, traces, loss, truncation, and source/bytecode +parity. + +The implementation was guided by Stellarscope PR #75, commit `b9e24258`. + +## Semantic preservation + +This was an API and syntax migration, not a simulator-behavior migration. +The implementation preserves PPVM semantics. In particular, it preserves: + +- Heisenberg/Pauli propagation direction or gate ordering; +- operand stack ordering, including the order in which qubit addresses and + floating-point parameters are popped; +- measurement and reset behavior; +- tableau, PauliSum, and LossyPauliSum backend results; +- truncation thresholds, maximum Pauli weights, and loss-channel behavior; +- branch and conditional-branch behavior after label resolution; +- function-call behavior, return-value counts, and function boundaries; +- string-table values used by `trace`, `print`, and other string-consuming + instructions; +- seeded randomness and shot-level reproducibility; +- bytecode round-trip behavior and equivalence between source and bytecode + execution. + +Source/runtime instruction conversion was verified in two ways: + +1. **Implementation review:** the surface-to-runtime lowering preserves + operand values, operand order, instruction order, control-flow targets, + and backend dispatch. Any source-only construct, such as a label, is + explicitly identified as metadata rather than silently dropped. +2. **Existing behavioral tests:** the tests already in this repository remain + the semantic oracle. Their source spellings and expected enum names were + migrated without weakening or replacing their behavioral assertions. + +The completed implementation passes the existing tests for +GHZ/Bell circuits, measurements, branching, function calls, traces, +PauliSum/LossyPauliSum behavior, truncation, reset, bytecode serialization, +and source/bytecode parity. Parser tests additionally assert the one-to-one +source-to-runtime mapping, while the existing execution tests assert that the +mapping preserves behavior. + +## Dependency changes + +### Workspace and PPVM dependencies + +PPVM now uses the published 0.4.0 crates consistently: + +- `vihaco = "0.4.0"` +- `vihaco-cpu = "0.4.0"` +- `vihaco-parser = "0.4.0"` +- `vihaco-parser-derive = "0.4.0"` + +The migration removed direct dependencies on: + +- `vihaco-parser-core` +- `vihaco-derive` +- the old 0.1.1 vihaco crates + +`vihaco-circuit-isa` was migrated as well. The workspace now has one vihaco +version, and its instruction type is usable as a v0.4 component instruction +set. + +### Parser imports and derives + +Old parser-core imports were replaced: + +```rust +use vihaco_parser_core::Parse; +``` + +with: + +```rust +use vihaco_parser::Parse; +``` + +`vihaco_parser_derive::Parse` is used for derives required by the 0.4 API. +The 0.4 derive uses `#[pattern = ...]` and syntax-class metadata; the old +`#[head]`, `#[token]`, and `#[delimiters]` form is no longer the general +instruction-definition mechanism. + +## Component and instruction-set changes + +### Circuit component + +The circuit instruction set is defined with `vihaco::component!`. Its generated +module exposes separate surface and runtime types: + +```rust +vihaco::component! { + pub component Circuit { + // state fields + } + + instruction { + X, + H, + // ... + } +} + +pub use circuit::{runtime, syntax}; +``` + +The generated runtime instruction type is used by `#[dispatch]` and by the +composite. The generated syntax instruction type is used by the parser. This +implements `HasInstructionSet`, which the v0.4 `#[composite]` macro requires. + +The hand-written `vihaco_circuit_isa::CircuitInstruction` was replaced by the +generated v0.4 instruction type. `CircuitMessage` and `CircuitEffect` remain +application-level payload types. + +### CPU instruction types + +vihaco 0.4 distinguishes: + +- `vihaco_cpu::SurfaceInstruction`: parsed source instructions; +- `vihaco_cpu::RuntimeInstruction`: executable instructions; +- `vihaco_cpu::SurfaceType` and `SurfaceValue`: source-level typed operands. + +PPVM parses into surface instructions and lowers them to +`RuntimeInstruction`, resolving labels, function names, types, and +string-table indices along the way. + +### Composite instruction type + +The composite-generated instruction enum is the runtime instruction type +generated by the composite, for example: + +```rust +pub type PPVMInstruction = ppvm::runtime::Instruction; +``` + +Conversions now exist from the runtime CPU and runtime circuit instruction +types into that enum. The loader uses the v0.4 `ProgramImage` and +`LocalModule`, replacing the removed `Module`/old loader API combination. + +## Parser and resolver changes + +The old `ParsedModule`/`BodyItem`/`RawForm` pipeline was replaced by the v0.4 +pipeline: + +```rust +ParsedModule> +``` + +and: + +```rust +impl Resolve for PPVMResolver { + type Module = LocalModule; +} +``` + +The resolver now performs these operations: + +1. Applies device headers to `PPVMDeviceInfo`. +2. Assigns function IDs and addresses. +3. Records source labels as metadata and resolves branch/call targets. +4. Converts CPU surface instructions to runtime instructions. +5. Converts circuit surface instructions to circuit runtime instructions. +6. Interns string constants into the module string table. +7. Populates functions, labels, `main_function`, code, strings, and device info. + +There is no generic fallback for arbitrary `RawForm` values. Unknown +instructions and malformed operands fail during surface parsing. + +## Instruction syntax changes + +The v0.4 composite syntax qualifies an instruction with both the composite +field and the component dialect: + +```text +::. +``` + +For PPVM this means CPU instructions use `cpu::cpu.*`, and circuit +instructions use `circuit::circuit.*`. + +### CPU instructions + +| Old PPVM spelling | v0.4 spelling | Runtime result | +| --- | --- | --- | +| `const.u64 0` | `cpu::cpu.const u64, 0` | `RuntimeInstruction::Const(Type::U64, Value::U64(0))` | +| `const.u32 1` | `cpu::cpu.const u32, 1` | `RuntimeInstruction::Const(Type::U32, Value::U32(1))` | +| `const.i64 -1` | `cpu::cpu.const i64, -1` | typed `Const` | +| `const.f64 0.5` | `cpu::cpu.const f64, 0.5` | typed `Const` | +| `const.bool true` | `cpu::cpu.const bool, true` | typed `Const` | +| `const.str "Z?*"` | `cpu::cpu.const str, "Z?*"` | one typed `Const`, with string interning | +| `ret` | `cpu::cpu.ret 0` | `RuntimeInstruction::Return(0)` | +| `ret 1` | `cpu::cpu.ret 1` | `RuntimeInstruction::Return(1)` | +| `br @done` | `cpu::cpu.br @done` | one patched `Branch` | +| `cond_br @yes, @no` | `cpu::cpu.cond_br @yes, @no` | one patched `ConditionalBranch` | +| `call 0, @measure` | `cpu::cpu.call 0, measure` | one patched `Call` | +| `@label:` | `cpu::cpu.label @label` | source label metadata; no runtime instruction | +| `breakpoint` | `cpu::cpu.breakpoint` | one `Breakpoint` | +| `halt` | `cpu::cpu.halt` | one `Halt` | +| `print` | `cpu::cpu.print` | one `Print` | +| `heap_alloc 4` | `cpu::cpu.heap_alloc 4` | one `HeapAlloc` | +| `load ...` | `cpu::cpu.load ...` | one typed `Load` | +| `store ...` | `cpu::cpu.store ...` | one typed `Store` | +| arithmetic/logical CPU op | `cpu::cpu. ...` | one runtime CPU instruction | + +The exact operand spelling for typed CPU instructions follows the v0.4 CPU +surface grammar. In particular, types and values are separate source +operands: `const f64, 0.5`, not `const.f64 0.5`. + +`const.str` is not a multi-instruction expansion. It is a source-level typed +constant whose string value is interned while producing one runtime `Const`. +The old spelling was removed because it encoded the type in the mnemonic. + +### Circuit instructions + +Circuit instructions do not take qubit or numeric operands in their mnemonic. +PPVM pushes operands with CPU constants, then executes one circuit instruction +that consumes the required values from the CPU stack. + +| Old PPVM spelling | v0.4 spelling | Values consumed from stack | +| --- | --- | --- | +| `circuit.x` | `circuit::circuit.x` | qubit | +| `circuit.y` | `circuit::circuit.y` | qubit | +| `circuit.z` | `circuit::circuit.z` | qubit | +| `circuit.h` | `circuit::circuit.h` | qubit | +| `circuit.s` / `circuit.s_adj` | `circuit::circuit.s` / `circuit::circuit.s_adj` | qubit | +| `circuit.sqrt_x` / `circuit.sqrt_x_adj` | `circuit::circuit.sqrt_x` / `circuit::circuit.sqrt_x_adj` | qubit | +| `circuit.sqrt_y` / `circuit.sqrt_y_adj` | `circuit::circuit.sqrt_y` / `circuit::circuit.sqrt_y_adj` | qubit | +| `circuit.t` / `circuit.t_adj` | `circuit::circuit.t` / `circuit::circuit.t_adj` | qubit | +| `circuit.cnot` | `circuit::circuit.cnot` | two qubits | +| `circuit.cz` | `circuit::circuit.cz` | two qubits | +| `circuit.rx`, `ry`, `rz` | `circuit::circuit.rx`, `ry`, `rz` | qubit, float | +| `circuit.rxx`, `ryy`, `rzz` | `circuit::circuit.rxx`, `ryy`, `rzz` | two qubits, float | +| `circuit.r` | `circuit::circuit.r` | qubit, two floats | +| `circuit.u3` | `circuit::circuit.u3` | qubit, three floats | +| `circuit.measure` | `circuit::circuit.measure` | qubit | +| `circuit.reset` | `circuit::circuit.reset` | qubit | +| `circuit.depolarize` | `circuit::circuit.depolarize` | qubit, float | +| `circuit.depolarize2` | `circuit::circuit.depolarize2` | two qubits, float | +| `circuit.paulierror` | `circuit::circuit.pauli_error` | qubit, three probabilities | +| `circuit.two_qubit_pauli_error` | `circuit::circuit.two_qubit_pauli_error` | two qubits, fifteen probabilities | +| `circuit.loss` | `circuit::circuit.loss` | qubit, float | +| `circuit.correlated_loss` | `circuit::circuit.correlated_loss` | two qubits, three probabilities | +| `circuit.trace` | `circuit::circuit.trace` | pattern string | +| `circuit.truncate` | `circuit::circuit.truncate` | none | + +The generated component defines the names explicitly where needed. Each +listed source instruction maps to exactly one generated circuit runtime +instruction. + +### Device headers + +The PPVM device headers remain conceptually the same and are now parsed with +the v0.4 header syntax: + +```text +device circuit.n_qubits 2; +device circuit.backend paulisum; +device circuit.observable ZZ; +device circuit.coefficient_threshold 1e-10; +device circuit.max_pauli_weight 8; +``` + +They are metadata, not runtime instructions, and their meaning is unchanged. + +## One-to-one lowering behavior + +The implementation follows these rules: + +- A typed `const` produces one runtime `Const`. +- `ret`, branch, conditional branch, and call each produce one runtime CPU + instruction. +- A circuit mnemonic produces one runtime circuit instruction. +- A source label records a label address but produces no executable runtime + instruction. Labels are metadata, not instructions. +- Symbolic branch and call targets may be resolved in a separate pass, but + resolution replaces operands on the same instruction rather than emitting + additional instructions. +- No `play`, `poly`, `digi`, combined gate, or measurement shorthand expands + into a constant plus another instruction. +- Stack setup is written explicitly in the source. For example, a + measurement requires an explicit `cpu::cpu.const u64, 0` followed by + `circuit::circuit.measure`. + +## Bytecode and runtime changes + +Bytecode serialization and tests were updated for v0.4 runtime instructions: + +- the codec uses `RuntimeInstruction`, not the removed + `vihaco_cpu::Instruction`; +- typed `Const` instructions include their `Type` argument; +- modules use `LocalModule` and the v0.4 module fields; +- the existing PPVM string table and device-info serialization is preserved; +- labels and function metadata are serialized so source and bytecode execution have + the same resolved control-flow and call behavior. PPVM bytecode v2 stores + function signatures, function ranges, labels, and `main_function`; +- round-trip tests cover every runtime instruction family used by PPVM. + +## Call-site and fixture changes + +All embedded programs and `.sst` fixtures were updated, including: + +- `crates/ppvm-vihaco/tests/*.sst`; +- `crates/ppvm-vihaco/src` test programs; +- `crates/ppvm-cli/examples/*.sst`; +- CLI and TUI breakpoint/trace programs; +- README and documentation examples. + +For every fixture, verification covers both: + +1. the new source parses into the intended surface instruction; and +2. resolution emits exactly one corresponding runtime instruction per source + instruction, excluding source-only labels. + +The fixtures run through the existing execution assertions, which compare +measurements, traces, states, branch outcomes, and bytecode results with the +pre-migration behavior. A fixture that parses but changes a result fails the +migration's semantic-preservation requirement. + +## Verification + +The completed migration was verified with: + +```bash +cargo fmt --all -- --check +cargo check -p ppvm-vihaco +cargo test -p vihaco-circuit-isa +cargo test -p ppvm-vihaco +cargo test -p ppvm-cli +cargo test --workspace +``` + +The workspace test suite passes, including PPVM source/bytecode fixtures, +CLI/TUI tests, circuit ISA tests, and the existing simulator behavior tests. + +The dependency tree was also checked to contain only the v0.4 vihaco family: + +```bash +cargo tree -i vihaco@0.1.1 +``` + +That command reports no PPVM-related dependency path for the migrated +`vihaco-circuit-isa`. diff --git a/julia-benchmarks/Manifest.toml b/julia-benchmarks/Manifest.toml index 3c050de35..63e0085e2 100644 --- a/julia-benchmarks/Manifest.toml +++ b/julia-benchmarks/Manifest.toml @@ -1,8 +1,8 @@ # This file is machine-generated - editing it directly is not advised -julia_version = "1.12.1" +julia_version = "1.12.5" manifest_format = "2.0" -project_hash = "2774cd547152d7fbb5c2095d2e68e179a1b41160" +project_hash = "52f0dd51f0e051696bfc24b6c265049f3b4a890d" [[deps.AbstractFFTs]] deps = ["LinearAlgebra"] @@ -208,6 +208,11 @@ git-tree-sha1 = "37ea44092930b1811e666c3bc38065d7d87fcc74" uuid = "5ae59095-9a9b-59fe-a467-6f913c188581" version = "0.13.1" +[[deps.Combinatorics]] +git-tree-sha1 = "c761b00e7755700f9cdf5b02039939d1359330e1" +uuid = "861a8166-3701-5b0c-9a16-15d98fcdc6aa" +version = "1.1.0" + [[deps.Compat]] deps = ["TOML", "UUIDs"] git-tree-sha1 = "9d8a54ce4b17aa5bdce0ea5c34bc5e7c340d16ad" @@ -267,6 +272,12 @@ git-tree-sha1 = "9e2f36d3c96a820c678f2f1f1782582fcf685bae" uuid = "8bb1440f-4735-579b-a4ab-409b98df4dab" version = "1.9.1" +[[deps.Dictionaries]] +deps = ["Indexing", "Random", "Serialization"] +git-tree-sha1 = "a55766a9c8f66cf19ffcdbdb1444e249bb4ace33" +uuid = "85a47980-9c8c-11e8-2b9f-f7ca1fa99fb4" +version = "0.4.6" + [[deps.Distances]] deps = ["LinearAlgebra", "Statistics", "StatsAPI"] git-tree-sha1 = "c7e3a542b999843086e2f29dac96a618c105be1d" @@ -307,7 +318,7 @@ version = "0.9.5" [[deps.Downloads]] deps = ["ArgTools", "FileWatching", "LibCURL", "NetworkOptions"] uuid = "f43a241f-c20a-4ad4-852c-f6b1247861c6" -version = "1.6.0" +version = "1.7.0" [[deps.EpollShim_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] @@ -478,6 +489,11 @@ git-tree-sha1 = "68c173f4f449de5b438ee67ed0c9c748dc31a2ec" uuid = "34004b35-14d8-5ef3-9330-4cdb6864b03a" version = "0.3.28" +[[deps.Indexing]] +git-tree-sha1 = "ce1566720fd6b19ff3411404d4b977acd4814f9f" +uuid = "313cdc1a-70c2-5d6a-ae34-0150d3930a38" +version = "1.1.1" + [[deps.Inflate]] git-tree-sha1 = "d1b1b796e47d94588b3757fe84fbf65a5ec4a80d" uuid = "d25df0c9-e2be-5dd7-82c8-3ad0b3e990b9" @@ -617,7 +633,7 @@ version = "0.6.4" [[deps.LibCURL_jll]] deps = ["Artifacts", "LibSSH2_jll", "Libdl", "OpenSSL_jll", "Zlib_jll", "nghttp2_jll"] uuid = "deac9b47-8bc7-5906-a0fe-35ac56dc84c0" -version = "8.11.1+1" +version = "8.15.0+0" [[deps.LibGit2]] deps = ["LibGit2_jll", "NetworkOptions", "Printf", "SHA"] @@ -744,7 +760,7 @@ version = "1.11.0" [[deps.MozillaCACerts_jll]] uuid = "14a3606d-f60d-562e-9121-12d972cd8159" -version = "2025.5.20" +version = "2025.11.4" [[deps.MuladdMacro]] git-tree-sha1 = "cac9cc5499c25554cba55cd3c30543cff5ca4fab" @@ -812,7 +828,7 @@ version = "1.6.1" [[deps.OpenSSL_jll]] deps = ["Artifacts", "Libdl"] uuid = "458c3c95-2e84-50aa-8efc-19380b2a3a95" -version = "3.5.1+0" +version = "3.5.4+0" [[deps.OpenSpecFun_jll]] deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl"] @@ -866,15 +882,31 @@ version = "2.8.5" [[deps.PauliPropagation]] deps = ["AcceleratedKernels", "BitIntegers", "Bits", "JSON", "LinearAlgebra", "PlotGraphviz", "ProfileCanvas", "Random", "StatsBase", "Test", "UUIDs"] -git-tree-sha1 = "757b43af3c247d9fad953dd056d3df76fe6e6a08" +git-tree-sha1 = "25a5e6eebc97b9680e16ab7e80dfb1969d828e24" uuid = "293282d5-3c99-4fb6-92d0-fd3280a19750" -version = "0.7.3" +version = "0.8.1" [deps.PauliPropagation.extensions] PauliPropagationCUDA = "CUDA" + PauliPropagationYao = "YaoBlocks" [deps.PauliPropagation.weakdeps] CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba" + YaoBlocks = "418bc28f-b43b-5e0b-a6e7-61bbc1a2c1df" + +[[deps.PauliStrings]] +deps = ["BitIntegers", "Combinatorics", "Dictionaries", "LinearAlgebra", "ProgressBars", "Random", "SparseArrays"] +git-tree-sha1 = "80ecc963d62d97541258fbc8fcdf2fc95a2e70f7" +uuid = "f07625cc-80e4-4099-a362-36a3484d9bcc" +version = "1.10.1" + + [deps.PauliStrings.extensions] + MathLinkPauliStringsExt = "MathLink" + SymbolicsPauliStringsExt = "Symbolics" + + [deps.PauliStrings.weakdeps] + MathLink = "18c93696-a329-5786-9845-8443133fa0b4" + Symbolics = "0c5d862f-8b57-4792-8d23-62f2024744c7" [[deps.Pixman_jll]] deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "LLVMOpenMP_jll", "Libdl"] @@ -885,7 +917,7 @@ version = "0.46.4+0" [[deps.Pkg]] deps = ["Artifacts", "Dates", "Downloads", "FileWatching", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "Random", "SHA", "TOML", "Tar", "UUIDs", "p7zip_jll"] uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" -version = "1.12.0" +version = "1.12.1" weakdeps = ["REPL"] [deps.Pkg.extensions] @@ -963,6 +995,12 @@ git-tree-sha1 = "990016fb1508b0726a70039f39569720d054c78d" uuid = "efd6af41-a80b-495e-886c-e51b0c7d77a3" version = "0.1.7" +[[deps.ProgressBars]] +deps = ["Printf"] +git-tree-sha1 = "b437cdb0385ed38312d91d9c00c20f3798b30256" +uuid = "49802e3a-d2f1-5c88-81d8-b72133a6f568" +version = "1.5.1" + [[deps.PtrArrays]] git-tree-sha1 = "4fbbafbc6251b883f4d2705356f3641f3652a7fe" uuid = "43287f4e-b6f4-7ad1-bb20-aadabca52c3d" @@ -1580,9 +1618,9 @@ uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d" version = "1.64.0+1" [[deps.p7zip_jll]] -deps = ["Artifacts", "Libdl"] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"] uuid = "3f19e933-33d8-53b3-aaab-bd5110c3b7a0" -version = "17.5.0+2" +version = "17.7.0+0" [[deps.x264_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] diff --git a/julia-benchmarks/Project.toml b/julia-benchmarks/Project.toml index 2a0f4ed61..befd11060 100644 --- a/julia-benchmarks/Project.toml +++ b/julia-benchmarks/Project.toml @@ -2,4 +2,5 @@ BenchmarkPlots = "ab8c0f59-4072-4e0d-8f91-a91e1495eb26" BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf" PauliPropagation = "293282d5-3c99-4fb6-92d0-fd3280a19750" +PauliStrings = "f07625cc-80e4-4099-a362-36a3484d9bcc" StatsPlots = "f3b207a7-027a-5e70-b257-86293d7955fd" diff --git a/julia-benchmarks/benches/xbench_pp.jl b/julia-benchmarks/benches/xbench_pp.jl new file mode 100644 index 000000000..1735f1ff5 --- /dev/null +++ b/julia-benchmarks/benches/xbench_pp.jl @@ -0,0 +1,125 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +# +# The PauliPropagation.jl side of the cross-library Pauli-propagation +# benchmark. See `benchmarks/cross-library/README.md` for the shared circuit +# definitions, the parameter contract, and the CSV schema — every runner reads +# the same environment variables and prints the same columns. +# +# MODEL=tfim QUBITS=8,16,24 STEPS=10 DT=0.1 JCOUP=1.0 HFIELD=1.0 ATOL=1e-6 \ +# julia --project=@. -t1 benches/xbench_pp.jl + +using PauliPropagation +import PauliPropagation.Performance +using Printf + +const MODEL = get(ENV, "MODEL", "tfim") +const STEPS = parse(Int, get(ENV, "STEPS", "10")) +const DT = parse(Float64, get(ENV, "DT", "0.1")) +const JCOUP = parse(Float64, get(ENV, "JCOUP", "1.0")) +const HFIELD = parse(Float64, get(ENV, "HFIELD", "1.0")) +const ATOL = parse(Float64, get(ENV, "ATOL", "1e-6")) +const ITERS = parse(Int, get(ENV, "ITERS", "3")) + +const THETA_BOND = 2 * JCOUP * DT +const THETA_SITE = 2 * HFIELD * DT + +"""The seed observable: `Σ_i Z_i` for TFIM, `Z_1` for Heisenberg.""" +function seed_operator(n::Int) + if MODEL == "tfim" + return VectorPauliSum(n, [PauliString(n, [:Z], [i]).term for i in 1:n], ones(n)) + end + return VectorPauliSum(PauliString(n, [:Z], [1])) +end + +"""One first-order Trotter step, gates in the order the shared spec fixes.""" +function trotter_step!(cache, n::Int) + if MODEL == "tfim" + for i in 1:n + Performance.propagate!(PauliRotation([:X], [i], THETA_SITE), cache; min_abs_coeff = ATOL) + end + for i in 1:(n - 1) + Performance.propagate!( + PauliRotation([:Z, :Z], [i, i + 1], THETA_BOND), cache; min_abs_coeff = ATOL + ) + end + else + for i in 1:(n - 1) + for axes in ([:X, :X], [:Y, :Y], [:Z, :Z]) + Performance.propagate!( + PauliRotation(axes, [i, i + 1], THETA_BOND), cache; min_abs_coeff = ATOL + ) + end + end + for i in 1:n + Performance.propagate!(PauliRotation([:Z], [i], THETA_SITE), cache; min_abs_coeff = ATOL) + end + end + return cache +end + +function run_model(n::Int) + cache = PropagationCache(seed_operator(n)) + for _ in 1:STEPS + trotter_step!(cache, n) + end + return PauliPropagation.extractsum!(cache) +end + +"""`⟨0…0|O|0…0⟩` for TFIM; the `Z_1` autocorrelator for Heisenberg.""" +function readout(state, n::Int) + if MODEL == "tfim" + return real(overlapwithzero(state)) + else + c = getcoeff(state, PauliString(n, [:Z], [1])) + return real(c) + end +end + +""" +Print the propagated support as `word coefficient`, largest first, site 1 +leftmost — the same format the Rust and Python runners emit under `DUMP=1` so +the driver can diff all four term-for-term. +""" +function dump_support(n::Int) + state = run_model(n) + terms = Dict{String,Float64}() + for (ps, c) in state + w = join([['I', 'X', 'Y', 'Z'][getpauli(ps, i) + 1] for i in 1:n]) + terms[w] = get(terms, w, 0.0) + real(c) + end + println("# $(length(terms)) terms") + for w in sort(collect(keys(terms)); by = w -> (-abs(terms[w]), w)) + println("$w $(Printf.@sprintf("%+.12e", terms[w]))") + end +end + +function main() + qubits = parse.(Int, split(get(ENV, "QUBITS", "8,12,16,20,24,28,32"), ",")) + if haskey(ENV, "DUMP") + dump_support(first(qubits)) + return + end + println( + stderr, + "PauliPropagation.jl $MODEL: steps=$STEPS dt=$DT J=$JCOUP h=$HFIELD atol=$ATOL " * + "iters=$ITERS threads=$(Threads.nthreads())", + ) + println("model,library,qubits,steps,dt,atol,time_s,terms,observable") + for n in qubits + # Warm up so the reported time excludes JIT for this width's type + # specialization. + st = run_model(n) + nterms = length(st) + obs = readout(st, n) + best = Inf + for _ in 1:ITERS + best = min(best, @elapsed run_model(n)) + end + println("$MODEL,pauli-propagation-jl,$n,$STEPS,$DT,$ATOL,$(round(best, sigdigits=7)),$nterms,$obs") + println(stderr, " n=$n $(round(best, digits=4))s $nterms terms obs=$obs") + flush(stdout) + end +end + +main() diff --git a/julia-benchmarks/benches/xbench_ps.jl b/julia-benchmarks/benches/xbench_ps.jl new file mode 100644 index 000000000..4a51d7380 --- /dev/null +++ b/julia-benchmarks/benches/xbench_ps.jl @@ -0,0 +1,163 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +# +# The PauliStrings.jl side of the cross-library Pauli-propagation benchmark. +# See `benchmarks/cross-library/README.md` for the shared circuit definitions, +# the parameter contract, and the CSV schema. +# +# PauliStrings.jl's own front door for this is `evolve(H, O, tspan; +# method=Trotter())`, which builds the gate list from the Hamiltonian's internal +# string order. We build the `TrotterGate` list by hand instead, so the gate +# sequence is the one the shared spec fixes and the propagated operator is +# comparable term-for-term with the other three engines. +# +# MODEL=tfim QUBITS=8,16,24 STEPS=10 DT=0.1 JCOUP=1.0 HFIELD=1.0 ATOL=1e-6 \ +# julia --project=@. -t1 benches/xbench_ps.jl + +using PauliStrings +using Printf + +const MODEL = get(ENV, "MODEL", "tfim") +const STEPS = parse(Int, get(ENV, "STEPS", "10")) +const DT = parse(Float64, get(ENV, "DT", "0.1")) +const JCOUP = parse(Float64, get(ENV, "JCOUP", "1.0")) +const HFIELD = parse(Float64, get(ENV, "HFIELD", "1.0")) +const ATOL = parse(Float64, get(ENV, "ATOL", "1e-6")) +const ITERS = parse(Int, get(ENV, "ITERS", "3")) + +const THETA_BOND = 2 * JCOUP * DT +const THETA_SITE = 2 * HFIELD * DT + +"""The `PauliString` generator for a one- or two-site Pauli term.""" +function generator(n::Int, ops::Vector{String}, sites::Vector{Int}) + o = Operator(n) + if length(sites) == 1 + o += ops[1], sites[1] + else + o += ops[1], sites[1], ops[2], sites[2] + end + return o.strings[1] +end + +""" +The Trotter gate list in *application* order, per the shared spec. + +`trotter_step!` consumes gates in matrix-multiply order and applies them in +reverse, so the caller reverses this before handing it over. +""" +function gate_list(n::Int) + gates = PauliStrings.TrotterGate[] + if MODEL == "tfim" + for i in 1:n + push!(gates, PauliStrings.TrotterGate(generator(n, ["X"], [i]), THETA_SITE)) + end + for i in 1:(n - 1) + push!( + gates, + PauliStrings.TrotterGate(generator(n, ["Z", "Z"], [i, i + 1]), THETA_BOND), + ) + end + else + for i in 1:(n - 1) + for op in ("X", "Y", "Z") + push!( + gates, + PauliStrings.TrotterGate( + generator(n, [op, op], [i, i + 1]), THETA_BOND + ), + ) + end + end + for i in 1:n + push!(gates, PauliStrings.TrotterGate(generator(n, ["Z"], [i]), THETA_SITE)) + end + end + return gates +end + +"""The seed observable: `Σ_i Z_i` for TFIM, `Z_1` for Heisenberg.""" +function seed_operator(n::Int) + o = Operator(n) + if MODEL == "tfim" + for i in 1:n + o += "Z", i + end + else + o += "Z", 1 + end + return o +end + +function run_model(n::Int, gates) + o = seed_operator(n) + truncation(x) = cutoff(x, ATOL) + for _ in 1:STEPS + PauliStrings.trotter_step!(o, gates; truncation = truncation, truncate_every = 1) + end + return o +end + +"""`⟨0…0|O|0…0⟩` for TFIM; the `Z_1` autocorrelator for Heisenberg.""" +function readout(o::Operator, n::Int) + if MODEL == "tfim" + return real(expect(o, "0"^n)) + else + z1 = seed_operator(n) + return real(trace_product(z1, o; scale = 1)) + end +end + +""" +Print the propagated support as `word coefficient`, largest first, site 1 +leftmost — the same format the other three runners emit under `DUMP=1`. + +PauliStrings.jl carries `im^{#Y}` inside the stored coefficient (its `Matrix` +convention), so `op_to_strings` is the readout that puts the coefficients on the +same footing as the other engines' real ones. +""" +function dump_support(n::Int, gates) + o = run_model(n, gates) + coeffs, strings = op_to_strings(o) + terms = Dict{String,Float64}() + for (c, s) in zip(coeffs, strings) + w = replace(s, '1' => 'I') + terms[w] = get(terms, w, 0.0) + real(c) + end + println("# $(length(terms)) terms") + for w in sort(collect(keys(terms)); by = w -> (-abs(terms[w]), w)) + @printf("%s %+.12e\n", w, terms[w]) + end +end + +function main() + qubits = parse.(Int, split(get(ENV, "QUBITS", "8,12,16,20,24,28,32"), ",")) + if haskey(ENV, "DUMP") + n = first(qubits) + dump_support(n, reverse(gate_list(n))) + return + end + println( + stderr, + "PauliStrings.jl $MODEL: steps=$STEPS dt=$DT J=$JCOUP h=$HFIELD atol=$ATOL " * + "iters=$ITERS threads=$(Threads.nthreads())", + ) + println("model,library,qubits,steps,dt,atol,time_s,terms,observable") + for n in qubits + # `gate_list` is circuit construction, not propagation — built once and + # excluded from the timed region, as in every other runner. + gates = reverse(gate_list(n)) + # Warm up so the reported time excludes JIT. + o = run_model(n, gates) + nterms = length(o) + obs = readout(o, n) + best = Inf + for _ in 1:ITERS + best = min(best, @elapsed run_model(n, gates)) + end + println("$MODEL,pauli-strings-jl,$n,$STEPS,$DT,$ATOL,$(round(best, sigdigits=7)),$nterms,$obs") + println(stderr, " n=$n $(round(best, digits=4))s $nterms terms obs=$obs") + flush(stdout) + end +end + +main() diff --git a/mise.toml b/mise.toml index eff553079..16df94724 100644 --- a/mise.toml +++ b/mise.toml @@ -1,6 +1,8 @@ +min_version = { hard = "2026.8.10" } + [tools] -prek = "latest" +prek = "0.4.14" # License header checker/formatter (used by prek hawkeye hook) -"github:korandoru/hawkeye" = "latest" +"github:korandoru/hawkeye" = "6.5.1" # Unused-dependency checker "cargo:cargo-machete" = "0.9.2" diff --git a/ppvm-python/src/ppvm/squin_interpreter/impls/__init__.py b/ppvm-python/src/ppvm/squin_interpreter/impls/__init__.py index 6e5281663..ee03291a4 100644 --- a/ppvm-python/src/ppvm/squin_interpreter/impls/__init__.py +++ b/ppvm-python/src/ppvm/squin_interpreter/impls/__init__.py @@ -1,2 +1,4 @@ # SPDX-FileCopyrightText: 2026 The PPVM Authors # SPDX-License-Identifier: Apache-2.0 + +"""Implementations for the Squin interpreter.""" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 000000000..aa20bc990 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.98.0" +components = ["rustfmt", "clippy"]