diff --git a/.github/workflows/wai-quantum-service.yml b/.github/workflows/wai-quantum-service.yml new file mode 100644 index 000000000..8cabfeaf3 --- /dev/null +++ b/.github/workflows/wai-quantum-service.yml @@ -0,0 +1,116 @@ +name: wai-quantum service + +# The WAI quantum stack as a public job service (wai/wai-quantum-service): its +# tests, clippy with warnings as errors, and its smoke checks over HTTP against +# an instance started here, on Linux and on the macOS runner (the host the +# service is deployed to). + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + paths: + - 'wai/wai-quantum/**' + - 'wai/wai-quantum-service/**' + - 'rust-toolchain.toml' + - '.github/workflows/wai-quantum-service.yml' + pull_request: + paths: + - 'wai/wai-quantum/**' + - 'wai/wai-quantum-service/**' + - 'rust-toolchain.toml' + - '.github/workflows/wai-quantum-service.yml' + +jobs: + relevant: + name: does this change touch it + runs-on: linux-arm64 + outputs: + run: ${{ steps.gate.outputs.run }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - id: gate + run: | + set -euo pipefail + r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-quantum-service.yml \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}") + echo "this change touches wai-quantum-service: $r" + echo "run=$r" >> "$GITHUB_OUTPUT" + + service-linux: + name: service · linux + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: linux + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + - name: Install Rust 1.98.0 + shell: bash + run: | + export PATH="$HOME/.cargo/bin:$PATH" + command -v rustup >/dev/null 2>&1 || \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0 + rustup toolchain install 1.98.0 --profile minimal --component clippy + rustup default 1.98.0 + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: Tests and clippy + shell: bash + working-directory: wai/wai-quantum-service + run: | + set -euo pipefail + cargo test --locked --release 2>&1 | tee "$RUNNER_TEMP/test.log" + grep -qE '^test result: ok\. [1-9][0-9]* passed; 0 failed' "$RUNNER_TEMP/test.log" || { echo "::error::no test ran"; exit 1; } + cargo clippy --locked --release --all-targets -- -D warnings + - name: The smoke checks over HTTP + shell: bash + working-directory: wai/wai-quantum-service + run: | + set -euo pipefail + key="$RUNNER_TEMP/service.key"; rm -f "$key" + cargo build --locked --release + WAI_QSVC_KEY="$key" WAI_QSVC_ADDR=127.0.0.1:18241 WAI_QSVC_BUDGET_GB=8 WAI_QSVC_JOB_GB=4 WAI_QSVC_WORKERS=2 \ + ./target/release/wai-quantum-service serve 2> "$RUNNER_TEMP/service.log" & + pid=$! + trap 'kill $pid 2>/dev/null || true; cat "$RUNNER_TEMP/service.log"' EXIT + for _ in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:18241/v1/health && break; sleep 0.2; done + bash smoke.sh http://127.0.0.1:18241 + + service-macos: + name: service · macos + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: macos + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + # launchd's PATH omits cargo on the macOS host runner. + - name: Put cargo on PATH + shell: bash + run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: Tests + shell: bash + working-directory: wai/wai-quantum-service + run: | + set -euo pipefail + nice -n 10 cargo test --locked --release 2>&1 | tee "$RUNNER_TEMP/test.log" + grep -qE '^test result: ok\. [1-9][0-9]* passed; 0 failed' "$RUNNER_TEMP/test.log" || { echo "::error::no test ran"; exit 1; } + - name: The smoke checks over HTTP + shell: bash + working-directory: wai/wai-quantum-service + run: | + set -euo pipefail + key="$RUNNER_TEMP/service.key"; rm -f "$key" + cargo build --locked --release + WAI_QSVC_KEY="$key" WAI_QSVC_ADDR=127.0.0.1:18241 WAI_QSVC_BUDGET_GB=8 WAI_QSVC_JOB_GB=4 WAI_QSVC_WORKERS=2 \ + ./target/release/wai-quantum-service serve 2> "$RUNNER_TEMP/service.log" & + pid=$! + trap 'kill $pid 2>/dev/null || true; cat "$RUNNER_TEMP/service.log"' EXIT + for _ in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:18241/v1/health && break; sleep 0.2; done + bash smoke.sh http://127.0.0.1:18241 diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 7b85355ef..46fe58132 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,163 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### wai-quantum 0.6.3: the wide stabilizer tableau, matrix product states at any angle, and fewer passes for the state vector, noisy shots and sparse Pauli sums + +Every pinned digest is unchanged, and the state vector and the sparse-Pauli engine give the +same bits as before. + +- **`quantum_tableau`** (new feature, in `full`): Clifford circuits on up to 32768 qubits. + The Aaronson–Gottesman tableau is packed qubit by qubit, with 32-bit qubit indices, so a + gate costs a few word operations per 64 rows. + - **Outcomes:** `Tableau::support` computes the affine subspace the computational-basis + outcomes are uniform over, by Gaussian elimination on the stabilizers with their signs + tracked. A `Sampler` then draws each shot as a random combination of its basis, + projected onto the measured qubits. + - **Expectation values:** `Tableau::expectation` gives the exact ±1/0 of a Pauli string, + from the destabilizers. +- **`quantum_mpsim`** (new feature, in `full`): matrix-product-state simulation of circuits + with any angles, on up to 4096 qubits. + - The chain is kept in mixed-canonical form, the center moved by QR. A gate on distant + qubits is routed by swaps. + - A two-qubit gate is a two-site update. It keeps the leading eigenvectors `U` of `M·M†` + and makes `U†·M` the new center, so no singular value is divided by. A matrix taller + than wide is first factored `M = Q·R`. The update is truncated to the bond dimension and + a cutoff. + - The eigensolver is new in `linalg`: Householder reflections to a real tridiagonal form, + then QL with implicit shifts, in a fixed order with only `+ − × ÷ √`. Shares below 10⁻¹² + of an update's weight are its rounding and are always dropped. An update that loses + under 10⁻¹³ of its weight is not counted as a truncation. + - Every truncation is reported: the bonds used, the summed discarded weight, and the + fidelity estimate `Π(1 − εᵢ)`. + - Readouts: amplitudes, Pauli expectation values by transfer matrices, and shots by + sequential conditional sampling. + - **Measured:** + - With nothing truncated, every amplitude and expectation agrees with the dense state + vector to 1e-10, and sampled frequencies match the probabilities. Under truncation, + the estimate tracks the true fidelity. + - On a 16-core desktop processor, on nearest-neighbour brickworks, against the crate's + one-sided Jacobi split: 500 qubits at depth 10 take 0.75 s against 3.1 s, and depth + 20 at bond 64 takes 8.1 s against 35.9 s. + - At depth 20 the estimate reports what truncation cost: 0.0426 at bond 64 and 0.4712 + at bond 128, the same under both splits. +- **Measured** on a random Clifford circuit of 10n gates at 10,000 qubits: the gates in + 49 ms, the outcome subspace in 353 ms, 1000 full-width shots in 154 ms, one expectation + value in 27 ms. Over 40 random circuits on up to 10 qubits, the outcome set and its + uniform probabilities match the dense state vector's, as do 800 Pauli expectation values. +- **Tiled state-vector passes** (`quantum_sv`). `StateVector::run` now groups consecutive + gates into passes, as a `Schedule` sets (`run_scheduled`; `Schedule::UNTILED` gives a pass + per gate). Each tile holds the amplitudes that agree on every bit outside the pass's local + bits. A tile is copied into a buffer the size of a core's cache, every gate of the pass is + applied to it there, and it is copied back. + - A pass's dense gates act on its local bits. Diagonal gates may act on any bit. + - Gates keep their order, and each amplitude goes through the same expressions as before, + so the schedule never changes a bit. A test holds tiled and untiled states equal bit for + bit at several tile and run sizes, at one thread and many. + - A gate of three or more qubits on the top qubits used to fall back to one thread; it now + runs in tiles. Gates on two or more qubits whose lowest stride allows it update eight + groups side by side. + - **Measured** on a 16-core desktop processor, 26 qubits, double precision: + - a depth-20 brickwork of random rotations and fSim gates: 5.2 s → 2.3 s at fusion width 2; + - a QFT: 2.9 s → 1.6 s at width 3; + - a QAOA circuit: 2.9 s → 0.95 s. +- **Branching trajectories** (`quantum_noisy::branching_trajectories`). The shots of a noisy + circuit have the law of independent trajectories, but shots that took the same errors + share one state. + - At each noise event a group splits as independent shots would. The number that err is + binomial (drawn by geometric gaps, so the cost is the number of errors, not of shots), + and each erring shot chooses its Pauli uniformly. A damping event's jumps are binomial + in the group's own jump probability. The error-free prefix and the error-free majority + therefore run once. + - The largest branch carries on in place. Other branches run first in copies while fewer + than a stated number of states are alive; past that, a branch waits and is replayed. + Estimates, counts and histories depend on neither the thread count nor the state limit. + - **Checked:** expectations land within four standard errors of the exact density matrix + for three seeds, and counts with readout flips follow the exact distribution. + - **Measured**, 18 qubits and 300 shots of a brickwork circuit, against independent + trajectories: 28 times faster at two-qubit error 10⁻³, 5.6 times at 5·10⁻³, and 1.9 + times at 2·10⁻² with damping. +- **The sparse-Pauli engine on a worker pool** (`quantum_spd`). A large sum is updated by + workers started once per run, each taking pieces of the table as it frees up. + - A run of consecutive Clifford gates is applied in one pass, each term through the gates + in order. + - A rotation's new terms have distinct strings, so each existing coefficient receives at + most one addition. The rest are appended in table order, and dropped magnitudes are + summed in table order. + - The thread count therefore changes the time and never a bit: a test compares one, two, + three and eight threads bit for bit. `expectation_threaded` takes an explicit count. + - **Measured:** the 127-qubit kicked-Ising run (20 steps, threshold 10⁻⁴, 2.2 million terms + at its peak) takes 9.4 s, against 49 s on one thread, with the same value. + +- **The tensor-engine member's kernel compiles for a device.** + - **The kernel compiled for the simulator only.** The helper + (`quantum_fabric/tensor_engine_helper.py`) defined its kernel inside `main()` and + imported the kernel toolchain there. The simulator runs the kernel as Python, so it found + the names. Compiling for a device traces the kernel's source and looks its names up in + the kernel's module, so it found none and refused the first request (`failed to resolve + name`). On a device every test of the member failed at that request. The kernel now sits + at module level. `main()` still imports the toolchain after the target is chosen, but + binds it as module globals. + - **The compiler is found beside the interpreter.** Compiling for a device runs the + toolchain's compiler from `PATH`, and the interpreter the member is given need not be on + it. The helper now puts that interpreter's directory first on `PATH` for a device target. + - **Checked** off the device with the toolchain's own compiler: the kernel now compiles + for the device target. Execution there needs the device's runtime. The simulator path is + unchanged. + +### wai-quantum-service 0.1.0: the quantum stack as a public job service + +- **What it is** (`wai/wai-quantum-service`): an HTTP job service over wai-quantum. A job is + an OpenQASM 3 or 2 program and the outputs wanted: measurement counts, Pauli expectation + values, or amplitudes of named bit strings, optionally under a noise model. The service + answers with the method that ran, its accuracy, and a receipt. No account and no key. + The public instance is at `https://wai.transaction.science/quantum-jobs/v1`. +- **The OpenQASM front end** (`src/qasm.rs`) reads: + - registers, and broadcast over them; + - user `gate` definitions, inlined with their parameters; + - angle expressions; + - the standard gate libraries; + - every terminal-measurement form. + + Mid-circuit measurement, classical control and gate modifiers are refused, with their + line. It lowers one circuit three ways, each exact up to a global phase: to matrix gates + (the dense state vector, tensor networks, noise), to Pauli rotations (sparse Pauli + dynamics) and to the dyadic gate set (the bit-exact state vector, the stabilizer tableau). + The tests hold each lowering to the others on every gate. +- **Seven methods, and `auto`:** + - `exact` (bit-exact, dyadic angles, 26 qubits); + - `statevector` (exact, any angles, up to the memory a job has: 32 qubits); + - `stabilizer` (Clifford, 32768 qubits, on the wide tableau below: counts and exact + expectation values); + - `pauli` (bounded, 1024 qubits, each value with its truncation bound); + - `mps` (any angles, 4096 qubits, at bond dimension up to 256: exact when nothing is + truncated, otherwise heuristic with its fidelity estimate and a warning below 0.9); + - `tensor_network` (exact amplitudes, with the contraction's cost); + - `density_matrix` (exact noise, 12 qubits); + - `trajectories` (noise as an estimate with standard errors). + + `statevector` runs in tiled passes. `trajectories` runs every shot as a branching + trajectory and reports how many distinct error `histories` ran. `pauli` reports a + `ladder`: each value and bound at up to three thresholds a decade above the one asked + for (`"ladder": false` turns it off). `auto` picks the first that takes the job, and the plan says why, and why not the others. +- **A public service on a shared host:** + - every job is planned before it is queued, and one whose memory estimate is over the + per-job cap is refused at once; + - jobs run inside a total memory budget, and a large one waits until the host's free memory + covers it and a reserve; + - each job runs in its own child process, at a lower priority than the host's other work, + watched for its resident memory and its time, and stopped past either; + - clients get per-client active-job and submission limits, against a bounded queue. + + The limits are stated at `GET /v1`. +- **Receipts:** every finished result is sealed with the service's Ed25519 key over the + BLAKE3 digest of the request and the result; `POST /v1/verify` checks one. +- **Measured:** 29 tests on Linux and macOS, and `smoke.sh` against local instances and the + public one. That covers a 40-qubit GHZ by the stabilizer, receipts that verify while a + changed result does not, a 100-qubit circuit's expectation values by sparse Pauli dynamics + with their bounds and ladder, 2000 noisy shots of a 14-qubit circuit by branching + trajectories, refusals with reasons, the per-client limit, and a job stopped when it + outgrew its memory. CI: `wai-quantum-service` (Linux and the macOS runner). + ### `WIV1` and `WIR1` through the integer operator set's stage operators - **`ops` members with no graph.** `wai.video.int_motion` and @@ -324,25 +481,6 @@ wire format may change and such changes are recorded here. and its wire identifiers (`WEV1`, `WEP1`, `urn:wai:event:1`, `science.transaction.wai.event`, `X-WAI-EVENT`). -### wai-quantum (next release): the tensor-engine member's kernel compiles for a device - -Fix. Every pinned digest is unchanged. - -- **The kernel compiled for the simulator only.** The helper - (`quantum_fabric/tensor_engine_helper.py`) defined its kernel inside `main()` and - imported the kernel toolchain there. The simulator runs the kernel as Python, so it found - the names. Compiling for a device traces the kernel's source and looks its names up in - the kernel's module, so it found none and refused the first request (`failed to resolve - name`). On a device every test of the member failed at that request. The kernel now sits - at module level. `main()` still imports the toolchain after the target is chosen, but - binds it as module globals. -- **The compiler is found beside the interpreter.** Compiling for a device runs the - toolchain's compiler from `PATH`, and the interpreter the member is given need not be on - it. The helper now puts that interpreter's directory first on `PATH` for a device target. -- **Checked** off the device with the toolchain's own compiler: the kernel now compiles - for the device target. Execution there needs the device's runtime. The simulator path is - unchanged. - ### wai-quantum 0.6.2: the HIP member's matrix-instruction kernel, and the Metal member's tensor-operation kernel Additive. Every pinned digest is unchanged. diff --git a/wai/tools/quantum-meter/Cargo.lock b/wai/tools/quantum-meter/Cargo.lock index c68e813e5..002097d96 100644 --- a/wai/tools/quantum-meter/Cargo.lock +++ b/wai/tools/quantum-meter/Cargo.lock @@ -1017,7 +1017,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.2" +version = "0.6.3" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/tools/quantum-wgsl-check/Cargo.lock b/wai/tools/quantum-wgsl-check/Cargo.lock index b87c7e6a7..5d234c140 100644 --- a/wai/tools/quantum-wgsl-check/Cargo.lock +++ b/wai/tools/quantum-wgsl-check/Cargo.lock @@ -599,7 +599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.2" +version = "0.6.3" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-pack/Cargo.lock b/wai/wai-pack/Cargo.lock index 150bbcbbb..0e815b544 100644 --- a/wai/wai-pack/Cargo.lock +++ b/wai/wai-pack/Cargo.lock @@ -713,7 +713,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.6.2" +version = "0.6.3" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-qpu/Cargo.lock b/wai/wai-quantum-qpu/Cargo.lock index b701fef57..2b01b5768 100644 --- a/wai/wai-quantum-qpu/Cargo.lock +++ b/wai/wai-quantum-qpu/Cargo.lock @@ -422,7 +422,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.2" +version = "0.6.3" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-serve/Cargo.lock b/wai/wai-quantum-serve/Cargo.lock index a44393f33..a4e96f869 100644 --- a/wai/wai-quantum-serve/Cargo.lock +++ b/wai/wai-quantum-serve/Cargo.lock @@ -599,7 +599,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.6.2" +version = "0.6.3" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-service/Cargo.lock b/wai/wai-quantum-service/Cargo.lock new file mode 100644 index 000000000..8dc8c3c70 --- /dev/null +++ b/wai/wai-quantum-service/Cargo.lock @@ -0,0 +1,869 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wai-quantum" +version = "0.6.3" +dependencies = [ + "blake3", + "ed25519-dalek", + "serde_json", +] + +[[package]] +name = "wai-quantum-service" +version = "0.1.0" +dependencies = [ + "axum", + "blake3", + "getrandom 0.4.3", + "serde", + "serde_json", + "tokio", + "wai-quantum", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/wai/wai-quantum-service/Cargo.toml b/wai/wai-quantum-service/Cargo.toml new file mode 100644 index 000000000..0ffddfb82 --- /dev/null +++ b/wai/wai-quantum-service/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "wai-quantum-service" +version = "0.1.0" +edition = "2024" +rust-version = "1.97" +license = "Apache-2.0" +description = "The WAI quantum stack as a public job service: OpenQASM 3 circuits run by the strongest method each admits (bit-exact and exact state vectors, stabilizer tableaux, matrix product states, sparse Pauli dynamics), every result naming its method and its accuracy and sealed with the service's key; each job in its own process, inside a memory budget." +repository = "https://git.transaction.science/Transaction-Science/open-standards" +publish = false + +[dependencies] +axum = "0.8" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "time", "sync", "net", "io-util"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +blake3 = "1" +getrandom = "0.4" +wai-quantum = { path = "../wai-quantum", default-features = false, features = ["quantum", "quantum_toolchain", "quantum_receipt", "quantum_sv", "quantum_tn", "quantum_tableau", "quantum_mpsim", "quantum_spd", "quantum_noisy"] } + +[profile.release] +lto = "thin" diff --git a/wai/wai-quantum-service/README.md b/wai/wai-quantum-service/README.md new file mode 100644 index 000000000..22b81717d --- /dev/null +++ b/wai/wai-quantum-service/README.md @@ -0,0 +1,106 @@ +# wai-quantum-service + +The WAI quantum stack as a public job service. Send an OpenQASM program; get back measurement counts, Pauli expectation values or amplitudes. Each result names the method that produced it and how accurate it is, and carries a signature you can check. + +No account and no key. The public instance is at **https://wai.transaction.science/quantum-jobs/v1**. + +```sh +# A Bell pair, 1000 shots. +curl -s https://wai.transaction.science/quantum-jobs/v1/run \ + -H 'content-type: application/json' \ + -d '{"qasm": "OPENQASM 3.0; qubit[2] q; bit[2] c; h q[0]; cx q[0], q[1]; c = measure q;", "shots": 1000}' +``` + +## Jobs + +A job is JSON: + +| field | meaning | +|---|---| +| `qasm` | the program: OpenQASM 3, or OpenQASM 2 (required) | +| `method` | `auto` (the default) or a method below | +| `shots` | measurement shots (default 1024 when nothing else is asked for; at most 1,000,000) | +| `seed` | the sampling seed (default 1); the same job and seed give the same counts | +| `observables` | Pauli strings such as `"Z0 Z1"`, or `{"pauli": "X3 Y4", "coeff": 0.5}` | +| `amplitudes` | bit strings, one character per qubit, qubit `n − 1` first | +| `noise` | `{"depolarizing_1q", "depolarizing_2q", "amplitude_damping", "dephasing", "readout"}`, each a probability | +| `options` | per method: `threshold`, `max_weight` and `ladder` (pauli), `max_bond` and `cutoff` (mps), `fusion` (statevector), `trajectories`, `memory_gb` | + +Counts are keyed by the program's classical bits, the highest bit first. With no measurement in the program, every qubit is measured into the bit of its own index. + +## Methods + +| method | takes | answers | accuracy | +|---|---|---|---| +| `exact` | angles that are dyadic multiples of 2π, up to 26 qubits | counts, expectations, amplitudes, a state hash | bit-exact | +| `statevector` | any circuit, up to 32 qubits | counts, expectations, amplitudes | exact | +| `stabilizer` | Clifford circuits (rotations by whole quarter turns), up to 32768 qubits | counts, expectations | exact | +| `pauli` | any circuit, up to 1024 qubits | expectations | bounded | +| `mps` | any circuit, up to 4096 qubits, at bond dimension up to 256; best when entanglement stays low | counts, expectations, amplitudes | exact when nothing is truncated, otherwise heuristic | +| `tensor_network` | any circuit | amplitudes | exact | +| `density_matrix` | noisy circuits, up to 12 qubits | counts, expectations | exact | +| `trajectories` | noisy circuits, up to 32 qubits | counts, expectations | estimate | + +How the methods run: + +- **statevector** groups consecutive gates into passes over the state. Each pass applies its gates tile by tile, in a buffer that fits a core's cache. The grouping never changes a bit of the result. +- **pauli** reports each value with its truncation `bound`. Unless `ladder` is `false`, it also reports a `ladder`: the value and bound at up to three thresholds a decade above the one asked for, so you can see the value settle as the threshold drops. +- **trajectories** treats every shot as an independent noisy trajectory in law. Shots that took the same errors share one state, so the error-free part of a circuit, and the error-free majority of the shots, run once. `histories` in the result counts the distinct error histories that ran. + +`auto` picks the first method that takes the job. With noise, it tries `density_matrix` then `trajectories`. For counts, it tries `stabilizer`, `exact`, `statevector`, then `mps`. For expectations alone, it tries `stabilizer`, `exact`, `statevector`, `pauli`, then `mps`. For amplitudes alone, it tries `exact`, `statevector`, `tensor_network`, then `mps`. The job's `why` names the method chosen and the ones that could not take it. + +The accuracy classes: + +- **bit-exact**: the same bits on every machine. Amplitudes are held in 30-bit fixed point, and every output is an exact function of them. +- **exact**: no approximation beyond double-precision rounding. +- **bounded**: an approximation, with a rigorous bound on the error (`bound`) beside each value. +- **estimate**: a statistical estimate, with its standard error (`stderr`). +- **heuristic**: an approximation, reported with the standard estimate of its error but not a bound. A matrix product state that truncated reports its discarded weight and its fidelity estimate, and a `warning` when the estimate is below 0.9. + +Counts are always seeded samples. + +## Endpoints + +| | | +|---|---| +| `GET /v1` | the service: its methods, limits, load and receipt key | +| `POST /v1/run` | submit a job and wait up to 25 s: the finished job, or `202` and the job if it is still running | +| `POST /v1/jobs` | submit a job: `202` and the job, with its `id` | +| `GET /v1/jobs/{id}` | the job: its status, and its result when it is done | +| `DELETE /v1/jobs/{id}` | cancel a queued or running job | +| `POST /v1/verify` | `{"request": ..., "result": ...}`: whether the result's receipt seals that request and that result | + +Every refusal says why: `400` for a program or job that does not read, `422` for one larger than a job may be, `429` past the per-client limits, `503` when the queue is full. `GET /v1` states the limits. + +## Receipts + +Every finished result carries a `receipt`: an Ed25519 signature, by the key `GET /v1` publishes, over the BLAKE3 digest of the request as submitted and the result as returned. `POST /v1/verify` checks one, and so can any Ed25519 implementation (see `src/receipt.rs`). + +## Running it + +```sh +cargo build --release +WAI_QSVC_ADDR=127.0.0.1:8241 ./target/release/wai-quantum-service serve +bash smoke.sh http://127.0.0.1:8241 +``` + +Each job runs in a child process of the same binary (`worker`), at a lower scheduling priority than the host's other work, and is watched for its resident memory and its time. + +These variables set the limits: + +| variable | default | meaning | +|---|---|---| +| `WAI_QSVC_BUDGET_GB` | 96 | memory all running jobs share | +| `WAI_QSVC_JOB_GB` | 72 | memory one job may take | +| `WAI_QSVC_WORKERS` | 4 | jobs running at once | +| `WAI_QSVC_MIN_FREE_PCT` | 15 | share of the host's memory a starting job must leave free | +| `WAI_QSVC_JOB_SECS` | 900 | the longest a job may run | +| `WAI_QSVC_CLIENT_ACTIVE` | 3 | queued or running jobs one client may hold | +| `WAI_QSVC_CLIENT_BURST` | 30 | submissions one client may make per window | +| `WAI_QSVC_CLIENT_WINDOW_SECS` | 600 | the window | +| `WAI_QSVC_MAX_QUEUE` | 256 | jobs waiting at once | +| `WAI_QSVC_RETAIN_SECS` | 86400 | how long results are kept | +| `WAI_QSVC_TRUSTED_PROXIES` | `127.0.0.1,::1` | peers whose `X-Forwarded-For` names the client | +| `WAI_QSVC_KEY` | `~/.config/wai-quantum-service/signing.key` | the signing key, created on first start | + +`wai-quantum-service run job.json` runs one job in the current process and prints its plan and its result. diff --git a/wai/wai-quantum-service/smoke.sh b/wai/wai-quantum-service/smoke.sh new file mode 100644 index 000000000..db3734590 --- /dev/null +++ b/wai/wai-quantum-service/smoke.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# smoke.sh BASE_URL: the service's promises, checked over HTTP against a running +# instance (CI starts one locally; after a deploy, run it against production). +# +# bash smoke.sh http://127.0.0.1:8241 +# bash smoke.sh https:// +# +# Each check prints one line and the script fails on the first that does not +# hold. Needs curl and python3. +set -euo pipefail +U=${1:?usage: smoke.sh BASE_URL} +U=${U%/} +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +post() { curl -sS --max-time 120 -X POST "$U$1" -H 'content-type: application/json' --data-binary "@$2"; } +py() { python3 -I -c "$@"; } + +# The service describes itself, with its methods and its receipt key. +curl -sS --max-time 30 "$U/v1" > "$tmp/describe.json" +py 'import json,sys +d=json.load(open(sys.argv[1])) +assert d["service"]=="wai-quantum-service", d +ms={m["method"] for m in d["methods"]} +assert {"exact","statevector","stabilizer","pauli","tensor_network","density_matrix","trajectories"} <= ms, ms +assert len(d["receipt_key"])==64 +print("describe: ok,", len(ms), "methods, receipt key", d["receipt_key"][:16]+"…")' "$tmp/describe.json" + +# A 40-qubit GHZ state: Clifford, so the stabilizer method, and only the two +# all-equal outcomes. +py 'import json +n=40 +q="OPENQASM 3.0;\nqubit[%d] q;\nh q[0];\n" % n + "".join("cx q[%d], q[%d];\n" % (i,i+1) for i in range(n-1)) +print(json.dumps({"qasm": q, "shots": 1000, "seed": 7}))' > "$tmp/ghz.json" +post /v1/run "$tmp/ghz.json" > "$tmp/ghz.out" +py 'import json,sys +d=json.load(open(sys.argv[1])); r=d["result"] +assert d["status"]=="done" and r["method"]=="stabilizer", d +c=r["counts"]; assert set(c) <= {"0"*40, "1"*40} and sum(c.values())==1000, c +assert "receipt" in r +print("ghz-40: ok,", r["method"], c)' "$tmp/ghz.out" + +# Its receipt verifies, and a changed result does not. +py 'import json,sys +req=json.load(open(sys.argv[1])); res=json.load(open(sys.argv[2]))["result"] +json.dump({"request": req, "result": res}, open(sys.argv[3],"w")) +res["counts"]["0"*40] = res["counts"].get("0"*40, 0) + 1 +json.dump({"request": req, "result": res}, open(sys.argv[4],"w"))' "$tmp/ghz.json" "$tmp/ghz.out" "$tmp/v.json" "$tmp/t.json" +post /v1/verify "$tmp/v.json" | py 'import json,sys; d=json.load(sys.stdin); assert d["valid"] is True, d; print("receipt: verifies")' +post /v1/verify "$tmp/t.json" | py 'import json,sys; d=json.load(sys.stdin); assert d["valid"] is False, d; print("receipt: a changed result does not verify")' + +# Continuous angles on a small circuit: the dense state vector, and the +# probabilities sum to the shots. +py 'import json; print(json.dumps({"qasm": "qubit[3] q; rx(0.3) q[0]; ry(1.2) q[1]; cx q[0], q[2]; crz(0.7) q[1], q[2];", "shots": 500, "observables": ["Z0", "Z1 Z2"]}))' > "$tmp/rot.json" +post /v1/run "$tmp/rot.json" > "$tmp/rot.out" +py 'import json,sys,math +d=json.load(open(sys.argv[1])); r=d["result"] +assert d["status"]=="done" and r["method"]=="statevector", d +assert sum(r["counts"].values())==500 +z0=r["expectations"][0]["value"]; assert abs(z0-math.cos(0.3))<1e-12, z0 +print("rotations: ok, =", z0)' "$tmp/rot.out" + +# A 100-qubit circuit's expectation values: sparse Pauli dynamics, each value +# with its bound. +py 'import json +n=100 +q="qubit[%d] q;\n" % n + "".join("rx(0.3) q[%d];\n" % i for i in range(n)) + "".join("cx q[%d], q[%d];\n" % (i,i+1) for i in range(n-1)) +print(json.dumps({"qasm": q, "observables": ["Z0", "Z50 Z51"]}))' > "$tmp/wide.json" +post /v1/run "$tmp/wide.json" > "$tmp/wide.out" +py 'import json,sys,math +d=json.load(open(sys.argv[1])); r=d["result"] +assert d["status"]=="done" and r["method"]=="pauli", d +e=r["expectations"]; assert all("bound" in x for x in e), e +assert abs(e[0]["value"]-math.cos(0.3)) <= e[0]["bound"]+1e-9, e +print("pauli-100: ok,", [(x["observable"], round(x["value"],6), x["bound"]) for x in e])' "$tmp/wide.out" + +# The pauli answer carries its threshold ladder, and the smaller threshold +# never drops more. +py 'import json,sys +d=json.load(open(sys.argv[1])); e=d["result"]["expectations"][0] +l=e["ladder"]; b=[x["bound"] for x in l] +assert len(l)>=2 and l[-1]["value"]==e["value"] and all(y<=x+1e-12 for x,y in zip(b,b[1:])), e +print("pauli ladder: ok,", [(x["threshold"], round(x["value"],6)) for x in l])' "$tmp/wide.out" + +# Noisy shots: branching trajectories, every shot counted, and shots that +# share their errors sharing a state. +py 'import json +n=14 +q="qubit[%d] q;\nbit[%d] c;\n" % (n,n) + "".join("ry(0.4) q[%d];\n" % i for i in range(n)) + "".join("cz q[%d], q[%d];\n" % (i,i+1) for i in range(n-1)) + "c = measure q;\n" +print(json.dumps({"qasm": q, "method": "trajectories", "shots": 2000, "noise": {"depolarizing_1q": 0.0005, "depolarizing_2q": 0.002, "readout": 0.01}}))' > "$tmp/noisy.json" +post /v1/run "$tmp/noisy.json" > "$tmp/noisy.out" +py 'import json,sys +d=json.load(open(sys.argv[1])); assert d.get("status")=="done", d; r=d["result"] +assert r["method"]=="trajectories", d +assert sum(r["counts"].values())==2000, r +h=r["accuracy"]["histories"]; assert 1 <= h < 2000, r["accuracy"] +print("noisy-14: ok,", r["method"], h, "histories for 2000 shots")' "$tmp/noisy.out" + +# A refusal says why. +py 'import json; print(json.dumps({"qasm": "qubit[2] q; if (c) x q;"}))' > "$tmp/bad.json" +code=$(curl -sS -o "$tmp/bad.out" -w '%{http_code}' -X POST "$U/v1/jobs" -H 'content-type: application/json' --data-binary "@$tmp/bad.json") +[ "$code" = 400 ] || { echo "a bad program got HTTP $code"; exit 1; } +py 'import json,sys; d=json.load(open(sys.argv[1])); assert "not supported" in d["error"], d; print("refusal: ok,", d["error"])' "$tmp/bad.out" + +echo "smoke: every check held at $U" diff --git a/wai/wai-quantum-service/src/catalog.rs b/wai/wai-quantum-service/src/catalog.rs new file mode 100644 index 000000000..69d481221 --- /dev/null +++ b/wai/wai-quantum-service/src/catalog.rs @@ -0,0 +1,477 @@ +//! The methods, what each admits, and the planner that picks one. +//! +//! A job asks for some of: measurement counts (`shots`), Pauli expectation +//! values (`observables`), amplitudes of named bit strings (`amplitudes`), +//! under an optional noise model (`noise`). Each method answers some of these +//! for some circuits, with a stated accuracy: +//! +//! | method | circuits | answers | accuracy | +//! |---|---|---|---| +//! | `exact` | dyadic angles, ≤ 26 qubits | counts, expectations, amplitudes, state hash | bit-exact | +//! | `statevector` | any, up to the memory a job has (32 qubits) | counts, expectations, amplitudes | exact | +//! | `stabilizer` | Clifford, ≤ 32768 qubits | counts, expectations | exact | +//! | `pauli` | any, ≤ 1024 qubits | expectations | bounded | +//! | `mps` | any, ≤ 4096 qubits, best on low-entanglement circuits | counts, expectations, amplitudes | exact, or heuristic with its fidelity estimate | +//! | `tensor_network` | any | amplitudes | exact | +//! | `density_matrix` | any, noisy, ≤ 12 qubits | counts, expectations | exact | +//! | `trajectories` | any, noisy, ≤ 32 qubits | counts, expectations | estimate | +//! +//! `auto` picks, in order: with noise, `density_matrix` then `trajectories`; +//! for counts, `stabilizer`, `exact`, `statevector`, `mps`; for expectations +//! only, `stabilizer`, `exact`, `statevector`, `pauli`, `mps`; for amplitudes +//! only, `exact`, `statevector`, `tensor_network`, `mps`. A method that cannot take the circuit is +//! skipped, and the plan says why the one chosen was chosen. + +use crate::qasm::{self, Program}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +pub const MAX_SHOTS: u64 = 1_000_000; +pub const MAX_OBSERVABLES: usize = 256; +pub const MAX_AMPLITUDES: usize = 64; +/// The most outcomes a counts map lists; the rest are summed as omitted. +pub const MAX_OUTCOMES: usize = 65_536; + +pub const EXACT_MAX_QUBITS: u32 = 26; +pub const STABILIZER_MAX_QUBITS: u32 = wai_quantum::quantum_tableau::MAX_QUBITS; +pub const PAULI_MAX_QUBITS: u32 = 1024; +pub const DENSITY_MAX_QUBITS: u32 = 12; +pub const TRAJECTORY_MAX_QUBITS: u32 = 32; +pub const MPS_MAX_QUBITS: u32 = wai_quantum::quantum_mpsim::MAX_QUBITS; +/// The bond dimension an `mps` job gets unless it asks, and the most it may. +pub const MPS_DEFAULT_BOND: usize = 64; +pub const MPS_MAX_BOND: usize = 256; +/// The work an `mps` job may take, in the unit [`mps_work`] counts: about +/// ten minutes. +pub const MPS_MAX_WORK: u64 = 400_000_000_000; +/// The word operations a stabilizer job may take (its elimination and its +/// shots): about a minute. +pub const STABILIZER_MAX_WORK: u64 = 500_000_000_000; + +const MIB: u64 = 1 << 20; +const GIB: u64 = 1 << 30; + +/// What a job will run: the method, why, and what it is admitted with. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Plan { + pub method: String, + pub why: String, + /// The memory the job is admitted with (an upper estimate), bytes. + pub memory_bytes: u64, + /// An estimate of its work, in the method's own unit. + pub work: u64, + pub qubits: u32, +} + +/// The accuracy classes a result names. +pub fn accuracy_classes() -> Value { + json!({ + "bit-exact": "the same bits on every machine: amplitudes in 30-bit fixed point, every output an exact function of them; counts are seeded samples", + "exact": "no approximation beyond IEEE double-precision rounding; counts are seeded samples of the exact distribution", + "bounded": "an approximation with a rigorous bound on |exact − value| reported beside each value", + "estimate": "a statistical estimate, reported with its standard error", + "heuristic": "an approximation reported with the standard estimate of its error (a matrix product state's fidelity estimate), not a bound", + }) +} + +/// The methods, for the service description. `job_bytes` is the memory one +/// job may take. +pub fn describe(job_bytes: u64) -> Value { + json!([ + { "method": "exact", "accuracy": "bit-exact", "circuits": "every angle a dyadic multiple of 2π (2πm/2^k, k ≤ 32)", "max_qubits": EXACT_MAX_QUBITS, "answers": ["counts", "expectations", "amplitudes", "state_hash"] }, + { "method": "statevector", "accuracy": "exact", "circuits": "any", "max_qubits": statevector_max_qubits(job_bytes), "answers": ["counts", "expectations", "amplitudes"] }, + { "method": "stabilizer", "accuracy": "exact", "circuits": "Clifford (rotations by whole quarter turns, controlled phases by whole half turns)", "max_qubits": STABILIZER_MAX_QUBITS, "answers": ["counts", "expectations"] }, + { "method": "pauli", "accuracy": "bounded", "circuits": "any", "max_qubits": PAULI_MAX_QUBITS, "answers": ["expectations"], "options": { "threshold": "coefficients below it are dropped (default 1e-5); the bound covers what was dropped", "max_weight": "Pauli strings heavier than this are dropped", "ladder": "also report the value at up to three thresholds a decade apart above it, to show it settling (default true)" } }, + { "method": "mps", "accuracy": "exact when nothing is truncated, otherwise heuristic", "circuits": "any; best on low-entanglement circuits", "max_qubits": MPS_MAX_QUBITS, "answers": ["counts", "expectations", "amplitudes"], "options": { "max_bond": "the largest bond dimension kept (default 64, at most 256)", "cutoff": "singular values whose squared share is below it are dropped (default 1e-12)" } }, + { "method": "tensor_network", "accuracy": "exact", "circuits": "any", "max_qubits": qasm::MAX_QUBITS, "answers": ["amplitudes"] }, + { "method": "density_matrix", "accuracy": "exact", "circuits": "any, with noise", "max_qubits": DENSITY_MAX_QUBITS, "answers": ["counts", "expectations"] }, + { "method": "trajectories", "accuracy": "estimate", "circuits": "any, with noise", "max_qubits": TRAJECTORY_MAX_QUBITS.min(statevector_max_qubits(job_bytes)), "answers": ["counts", "expectations"], "options": { "trajectories": "how many (default: the shots, or 1000 for expectations alone); shots that take the same errors share one state" } }, + ]) +} + +/// The memory a dense state vector of `n` qubits takes with room to run. +pub fn statevector_bytes(n: u32) -> u64 { + amplitude_bytes(n) + 256 * MIB +} + +/// One state vector's amplitudes, with a margin. +pub fn amplitude_bytes(n: u32) -> u64 { + let amps = 16u64 << n.min(40); + amps + amps / 32 +} + +/// The most qubits the dense state vector holds in `job_bytes`. +pub fn statevector_max_qubits(job_bytes: u64) -> u32 { + (1..=wai_quantum::quantum_sv::MAX_QUBITS).rev().find(|&n| statevector_bytes(n) <= job_bytes).unwrap_or(0) +} + +/// An observable as asked: its text, its coefficient, and its Pauli factors +/// as `(qubit, 'X'|'Y'|'Z')`. +pub type Observable = (String, f64, Vec<(u32, char)>); + +/// A job as submitted, read and checked. +pub struct Request { + pub program: Program, + pub method: String, + pub shots: u64, + pub seed: u64, + pub observables: Vec, + pub amplitudes: Vec, + pub noise: Option, + pub options: Value, +} + +fn observable(v: &Value, n: u32) -> Result { + let (text, coeff) = match v { + Value::String(s) => (s.clone(), 1.0), + Value::Object(o) => ( + o.get("pauli").and_then(Value::as_str).ok_or("an observable object needs \"pauli\"")?.to_string(), + o.get("coeff").and_then(Value::as_f64).unwrap_or(1.0), + ), + _ => return Err("an observable is a string like \"Z0 Z1\" or {\"pauli\": ..., \"coeff\": ...}".into()), + }; + let mut string = Vec::new(); + for term in text.split_whitespace() { + let mut chars = term.chars(); + let p = chars.next().map(|c| c.to_ascii_uppercase()).ok_or("an empty Pauli factor")?; + if !matches!(p, 'X' | 'Y' | 'Z' | 'I') { + return Err(format!("{term}: a Pauli factor is X, Y, Z or I and a qubit, like Z3")); + } + let q: u32 = chars.as_str().parse().map_err(|_| format!("{term}: no qubit index"))?; + if q >= n { + return Err(format!("{term}: qubit {q} of a {n}-qubit program")); + } + if string.iter().any(|&(r, _)| r == q) { + return Err(format!("{text}: qubit {q} named twice")); + } + if p != 'I' { + string.push((q, p)); + } + } + Ok((text, coeff, string)) +} + +fn noise(v: &Value) -> Result { + let f = |k: &str| -> Result { + let x = v.get(k).and_then(Value::as_f64).unwrap_or(0.0); + if (0.0..=1.0).contains(&x) { Ok(x) } else { Err(format!("noise {k} is a probability, 0 to 1")) } + }; + Ok(wai_quantum::quantum_noisy::NoiseModel { + depol1: f("depolarizing_1q")?, + depol2: f("depolarizing_2q")?, + damping: f("amplitude_damping")?, + dephasing: f("dephasing")?, + readout: f("readout")?, + }) +} + +/// Reads a job's JSON. +pub fn read(v: &Value) -> Result { + let src = v.get("qasm").and_then(Value::as_str).ok_or("a job needs \"qasm\": an OpenQASM 3 program")?; + let program = qasm::parse(src)?; + let n = program.n_qubits; + let observables = match v.get("observables") { + None | Some(Value::Null) => Vec::new(), + Some(Value::Array(a)) if a.len() <= MAX_OBSERVABLES => a.iter().map(|o| observable(o, n)).collect::>()?, + Some(Value::Array(_)) => return Err(format!("at most {MAX_OBSERVABLES} observables")), + Some(_) => return Err("\"observables\" is a list".into()), + }; + let amplitudes: Vec = match v.get("amplitudes") { + None | Some(Value::Null) => Vec::new(), + Some(Value::Array(a)) if a.len() <= MAX_AMPLITUDES => a + .iter() + .map(|b| { + let s = b.as_str().ok_or("an amplitude is a bit string")?; + if s.len() != n as usize || !s.bytes().all(|c| c == b'0' || c == b'1') { + return Err(format!("{s}: an amplitude's bit string has one 0 or 1 per qubit ({n}), qubit {} first", n - 1)); + } + Ok(s.to_string()) + }) + .collect::>()?, + Some(_) => return Err(format!("\"amplitudes\" is a list of at most {MAX_AMPLITUDES} bit strings")), + }; + let default_shots = if observables.is_empty() && amplitudes.is_empty() { 1024 } else { 0 }; + let shots = v.get("shots").and_then(Value::as_u64).unwrap_or(default_shots); + if shots > MAX_SHOTS { + return Err(format!("{shots} shots; at most {MAX_SHOTS}")); + } + if shots == 0 && observables.is_empty() && amplitudes.is_empty() { + return Err("the job asks for nothing: give shots, observables or amplitudes".into()); + } + let noise = match v.get("noise") { + None | Some(Value::Null) => None, + Some(o @ Value::Object(_)) => Some(noise(o)?), + Some(_) => return Err("\"noise\" is an object".into()), + }; + Ok(Request { + program, + method: v.get("method").and_then(Value::as_str).unwrap_or("auto").to_string(), + shots, + seed: v.get("seed").and_then(Value::as_u64).unwrap_or(1), + observables, + amplitudes, + noise, + options: v.get("options").cloned().unwrap_or(json!({})), + }) +} + +/// The option `key` in GiB as bytes, within `cap`. +fn memory_option(r: &Request, default_gib: u64, cap: u64) -> u64 { + let gib = r.options.get("memory_gb").and_then(Value::as_u64).unwrap_or(default_gib); + (gib * GIB).min(cap) +} + +/// The bond dimension an `mps` job asks for, within [`MPS_MAX_BOND`]. +pub fn mps_bond(r: &Request) -> usize { + r.options.get("max_bond").and_then(Value::as_u64).map(|b| b as usize).unwrap_or(MPS_DEFAULT_BOND).clamp(1, MPS_MAX_BOND) +} + +/// An `mps` job's work: its two-site updates (each two-qubit gate and the +/// swaps that route it) at `(2χ)³` each, and its shots at `n·χ²` each. +pub fn mps_work(r: &Request, chi: u64) -> u64 { + let updates: u64 = r.program.ops.iter().map(|o| match o.qubits.as_slice() { + [a, b] => 2 * (a.abs_diff(*b) as u64).saturating_sub(1) + 1, + [a, b, c] => 15 * (1 + 2 * (a.abs_diff(*b).max(b.abs_diff(*c)).max(a.abs_diff(*c)) as u64)), + _ => 0, + }).sum(); + updates.saturating_mul((2 * chi).pow(3)).saturating_add(r.shots.saturating_mul(r.program.n_qubits as u64 * chi * chi * 2)) +} + +/// Whether `method` can take the job, and the plan if so; otherwise why not. +fn try_method(method: &str, r: &Request, dyadic: &Result, clifford: &Result, String>, cap: u64) -> Result { + let n = r.program.n_qubits; + let gates = r.program.ops.len() as u64; + let plan = |memory_bytes: u64, work: u64, why: String| Plan { method: method.into(), why, memory_bytes, work, qubits: n }; + let counts = r.shots > 0; + let wants_amplitudes = !r.amplitudes.is_empty(); + let wants_expectations = !r.observables.is_empty(); + if r.noise.is_some() && !matches!(method, "density_matrix" | "trajectories") { + return Err(format!("{method} does not model noise")); + } + match method { + "exact" => { + if let Err(e) = dyadic { + return Err(format!("exact needs dyadic angles: {e}")); + } + if n > EXACT_MAX_QUBITS { + return Err(format!("exact holds at most {EXACT_MAX_QUBITS} qubits")); + } + let bytes = (48u64 << n) + 128 * MIB; + Ok(plan(bytes, gates << n, format!("bit-exact state vector of {n} qubits"))) + } + "statevector" => { + let bytes = statevector_bytes(n); + if bytes > cap || n > wai_quantum::quantum_sv::MAX_QUBITS { + return Err(format!("statevector holds at most {} qubits in a job's memory", statevector_max_qubits(cap))); + } + Ok(plan(bytes + r.shots * 24, gates << n, format!("dense double-precision state vector of {n} qubits"))) + } + "stabilizer" => { + if let Err(e) = clifford { + return Err(format!("stabilizer needs a Clifford circuit: {e}")); + } + if n > STABILIZER_MAX_QUBITS { + return Err(format!("stabilizer holds at most {STABILIZER_MAX_QUBITS} qubits")); + } + if wants_amplitudes { + return Err("stabilizer answers counts and expectations".into()); + } + let (nn, measured) = (n as u64, r.program.measured.len() as u64); + let words = nn.div_ceil(64); + // The gates; the elimination for counts; each shot; each observable. + let work = gates * 2 * words + + if counts { nn * nn * words + r.shots * nn * measured.div_ceil(64) } else { 0 } + + r.observables.len() as u64 * 2 * nn * words; + if work > STABILIZER_MAX_WORK { + return Err(format!("this Clifford job is more stabilizer work than one job may take ({work} word operations, at most {STABILIZER_MAX_WORK}); ask for fewer shots")); + } + // The tableau, its rows and the outcome subspace, about n² bytes + // each; and the counts. + let memory = 3 * nn * nn / 2 + 64 * MIB + r.shots.min(1 << 20) * (r.program.n_bits as u64 / 8 + 64); + Ok(plan(memory, work, format!("Clifford circuit: the stabilizer tableau of {n} qubits"))) + } + "pauli" => { + if n > PAULI_MAX_QUBITS { + return Err(format!("pauli holds at most {PAULI_MAX_QUBITS} qubits")); + } + if counts || wants_amplitudes || !wants_expectations { + return Err("pauli answers expectation values only".into()); + } + Ok(plan(memory_option(r, 16, cap), gates * r.observables.len() as u64, format!("sparse Pauli dynamics over {n} qubits, with a bound on what truncation drops"))) + } + "mps" => { + if n > MPS_MAX_QUBITS { + return Err(format!("mps holds at most {MPS_MAX_QUBITS} qubits")); + } + let chi = mps_bond(r) as u64; + let work = mps_work(r, chi); + if work > MPS_MAX_WORK { + return Err(format!("this circuit at bond dimension {chi} is more work than one job may take; ask for a smaller max_bond or fewer shots")); + } + let memory = (n as u64) * chi * chi * 64 + (2 * chi) * (2 * chi) * 96 + 256 * MIB; + Ok(plan(memory, work, format!("matrix product state of {n} qubits at bond dimension {chi}"))) + } + "tensor_network" => { + if !wants_amplitudes || counts || wants_expectations { + return Err("tensor_network answers amplitudes only".into()); + } + Ok(plan(memory_option(r, 8, cap), gates * r.amplitudes.len() as u64, format!("tensor-network contraction of {} amplitude(s) over {n} qubits", r.amplitudes.len()))) + } + "density_matrix" => { + if r.noise.is_none() { + return Err("density_matrix is for noisy circuits (give \"noise\")".into()); + } + if n > DENSITY_MAX_QUBITS || wants_amplitudes { + return Err(format!("density_matrix holds at most {DENSITY_MAX_QUBITS} qubits and answers counts and expectations")); + } + Ok(plan((32u64 << (2 * n)) + 128 * MIB, gates << (2 * n), format!("density matrix of {n} qubits under the noise model"))) + } + "trajectories" => { + if r.noise.is_none() { + return Err("trajectories is for noisy circuits (give \"noise\")".into()); + } + if n > TRAJECTORY_MAX_QUBITS || wants_amplitudes { + return Err(format!("trajectories holds at most {TRAJECTORY_MAX_QUBITS} qubits and answers counts and expectations")); + } + if counts && n > 64 { + return Err("trajectory counts are for at most 64 qubits".into()); + } + if statevector_bytes(n) > cap { + return Err("trajectories hold a state vector each, more than a job's memory".into()); + } + let count = trajectory_count(r); + let states = trajectory_states(n, cap); + // Below 16 qubits up to eight trees run at once, each with its states. + let bytes = amplitude_bytes(n).saturating_mul(states as u64 * if n < 16 { 8 } else { 1 }) + 256 * MIB; + // Histories: the error-free one, and about one for each trajectory + // that errs, each running about the rest of the circuit. + let noise = r.noise.expect("checked above"); + let mut errors = 0.0; + for o in &r.program.ops { + let k = o.qubits.len() as f64; + errors += if o.qubits.len() == 2 { noise.depol2 } else { k * noise.depol1 }; + errors += k * (noise.dephasing + noise.damping / 2.0); + } + let histories = 1.0 + count as f64 * errors.min(1.0); + let work = ((gates << n) as f64 * histories) as u64; + Ok(plan(bytes, work, format!("{count} noisy trajectories of {n} qubits, shots that share their errors sharing a state"))) + } + other => Err(format!("unknown method {other}; GET /v1/methods lists them")), + } +} + +/// States a branching trajectory run may hold at once: as many as fit in +/// half of `cap`, at most 16 (from 16 qubits, where one tree runs; below, +/// each of up to eight trees at a time holds this many small states). A +/// branch past the limit is replayed rather than held. +pub fn trajectory_states(n: u32, cap: u64) -> usize { + let trees = if n < 16 { 8 } else { 1 }; + ((cap.saturating_sub(256 * MIB) / 2 / (amplitude_bytes(n) * trees).max(1)) as usize).clamp(1, 16) +} + +/// Trajectories: the option, or one a shot, or 1000 for expectations alone. +pub fn trajectory_count(r: &Request) -> u64 { + let default = if r.shots > 0 { r.shots } else { 1000 }; + r.options.get("trajectories").and_then(Value::as_u64).unwrap_or(default).clamp(1, 1_000_000) +} + +/// The plan for a job's JSON, `job_bytes` the memory one job may take. +pub fn plan(v: &Value, job_bytes: u64) -> Result { + let r = read(v)?; + let dyadic = r.program.dyadic(); + let clifford = r.program.clifford(); + if r.method != "auto" { + return try_method(&r.method, &r, &dyadic, &clifford, job_bytes); + } + let order: &[&str] = if r.noise.is_some() { + &["density_matrix", "trajectories"] + } else if r.shots > 0 { + &["stabilizer", "exact", "statevector", "mps"] + } else if !r.observables.is_empty() && r.amplitudes.is_empty() { + &["stabilizer", "exact", "statevector", "pauli", "mps"] + } else { + &["exact", "statevector", "tensor_network", "mps"] + }; + let mut refusals = Vec::new(); + for m in order { + match try_method(m, &r, &dyadic, &clifford, job_bytes) { + Ok(mut p) => { + if !refusals.is_empty() { + p.why = format!("{}; not {}", p.why, refusals.join("; ")); + } + return Ok(p); + } + Err(e) => refusals.push(format!("{m}: {e}")), + } + } + Err(format!("no method takes this job — {}", refusals.join("; "))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn job(qasm: &str, extra: Value) -> Value { + let mut v = json!({ "qasm": qasm }); + if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) { + o.extend(e.clone()); + } + v + } + + const CAP: u64 = 72 * GIB; + + #[test] + fn auto_picks_the_strongest_method_the_circuit_admits() { + let bell = "qubit[2] q; h q[0]; cx q[0], q[1];"; + assert_eq!(plan(&job(bell, json!({})), CAP).unwrap().method, "stabilizer"); + let t = "qubit[2] q; h q[0]; t q[0]; cx q[0], q[1];"; + assert_eq!(plan(&job(t, json!({})), CAP).unwrap().method, "exact"); + let rot = "qubit[2] q; rx(0.3) q[0]; cx q[0], q[1];"; + let p = plan(&job(rot, json!({})), CAP).unwrap(); + assert_eq!(p.method, "statevector"); + assert!(p.why.contains("exact: exact needs dyadic angles"), "{}", p.why); + let wide = "qubit[100] q; rx(0.3) q; cx q[0], q[1];"; + assert_eq!(plan(&job(wide, json!({ "observables": ["Z0 Z1", "X5"] })), CAP).unwrap().method, "pauli"); + // Sampling a wide non-Clifford circuit goes to the matrix product state. + assert_eq!(plan(&job(wide, json!({ "shots": 10 })), CAP).unwrap().method, "mps"); + assert_eq!(plan(&job(wide, json!({ "amplitudes": ["0".repeat(100)] })), CAP).unwrap().method, "tensor_network"); + assert_eq!(plan(&job(bell, json!({ "noise": { "depolarizing_2q": 0.01 } })), CAP).unwrap().method, "density_matrix"); + let mid = "qubit[20] q; h q; cx q[0], q[19]; rz(0.1) q[3];"; + assert_eq!(plan(&job(mid, json!({ "noise": { "depolarizing_1q": 0.001 } })), CAP).unwrap().method, "trajectories"); + } + + #[test] + fn a_named_method_is_held_to_what_it_admits() { + let rot = "qubit[2] q; rx(0.3) q[0];"; + let e = plan(&job(rot, json!({ "method": "stabilizer" })), CAP).unwrap_err(); + assert!(e.contains("Clifford"), "{e}"); + let e = plan(&job(rot, json!({ "method": "pauli" })), CAP).unwrap_err(); + assert!(e.contains("expectation values only"), "{e}"); + assert!(plan(&job(rot, json!({ "method": "nonsense" })), CAP).unwrap_err().contains("unknown method")); + } + + #[test] + fn the_statevector_limit_follows_the_job_memory() { + assert_eq!(statevector_max_qubits(72 * GIB), 32); + assert_eq!(statevector_max_qubits(GIB), 25); + let p = plan(&job("qubit[30] q; rx(0.1) q;", json!({ "method": "statevector" })), CAP).unwrap(); + assert!(p.memory_bytes >= 16u64 << 30, "{}", p.memory_bytes); + } + + #[test] + fn requests_are_checked() { + for (extra, needle) in [ + (json!({ "shots": 2_000_000 }), "at most"), + (json!({ "observables": ["Q0"] }), "Pauli factor"), + (json!({ "observables": ["Z5"] }), "qubit 5 of a 2-qubit"), + (json!({ "amplitudes": ["0"] }), "one 0 or 1 per qubit"), + (json!({ "shots": 0 }), "asks for nothing"), + (json!({ "noise": { "readout": 2.0 } }), "probability"), + ] { + let e = plan(&job("qubit[2] q; h q[0];", extra.clone()), CAP).unwrap_err(); + assert!(e.contains(needle), "{extra}: {e}"); + } + assert!(plan(&json!({}), CAP).unwrap_err().contains("qasm")); + } +} diff --git a/wai/wai-quantum-service/src/http.rs b/wai/wai-quantum-service/src/http.rs new file mode 100644 index 000000000..6482bdf6e --- /dev/null +++ b/wai/wai-quantum-service/src/http.rs @@ -0,0 +1,197 @@ +//! The HTTP API, version 1. +//! +//! ```text +//! GET /v1 the service: methods, limits, load, receipt key +//! GET /v1/health liveness +//! GET /v1/methods the methods and what each admits +//! POST /v1/jobs submit a job (JSON); 202 and the job +//! GET /v1/jobs/{id} the job: status, and its result when done +//! DELETE /v1/jobs/{id} cancel a queued or running job +//! POST /v1/run submit and wait (up to 25 s): the finished job, +//! or 202 and the job if it is still running +//! POST /v1/verify a receipt checked against its request and result +//! ``` +//! +//! A job is `{"qasm": "", "method": "auto", "shots": N, +//! "seed": S, "observables": ["Z0 Z1", ...], "options": {...}}`; see +//! [`crate::catalog`]. Every answer is JSON, and every refusal says why. + +use crate::catalog; +use crate::jobs::{Refused, Service, Status}; +use crate::receipt::{self, Signer}; +use axum::extract::{ConnectInfo, DefaultBodyLimit, Path, State}; +use axum::http::{HeaderMap, HeaderValue, Method, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde_json::{Value, json}; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; + +/// The largest request body, bytes. +pub const MAX_BODY: usize = 1 << 20; + +/// How long `POST /v1/run` waits before answering 202. +pub const RUN_WAIT: Duration = Duration::from_secs(25); + +pub struct App { + pub service: Arc, + pub signer: Arc, + /// Peers whose `X-Forwarded-For` names the client: the front proxy. + pub trusted: Vec, +} + +type Shared = Arc; + +fn reply(status: u16, body: Value) -> Response { + let mut r = (StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), Json(body)).into_response(); + r.headers_mut().insert("access-control-allow-origin", HeaderValue::from_static("*")); + r +} + +fn error(status: u16, why: &str) -> Response { + reply(status, json!({ "error": why })) +} + +/// The client a request is from: the first `X-Forwarded-For` address when the +/// peer is a trusted proxy, otherwise the peer. +fn client(app: &App, peer: SocketAddr, headers: &HeaderMap) -> String { + if app.trusted.contains(&peer.ip()) + && let Some(first) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()).and_then(|v| v.split(',').next()) + && let Ok(ip) = first.trim().parse::() + { + return ip.to_string(); + } + peer.ip().to_string() +} + +async fn describe(State(app): State) -> Response { + let c = &app.service.config; + let (queued, running, held) = app.service.load(); + reply( + 200, + json!({ + "service": "wai-quantum-service", + "version": env!("CARGO_PKG_VERSION"), + "methods": catalog::describe(c.job_mib << 20), + "accuracy_classes": catalog::accuracy_classes(), + "limits": { + "body_bytes": MAX_BODY, + "job_memory_bytes": c.job_mib << 20, + "job_seconds": c.job_timeout.as_secs(), + "active_jobs_per_client": c.per_client_active, + "submissions_per_window": c.per_client_burst, + "window_seconds": c.per_client_window.as_secs(), + "queue": c.max_queue, + "result_retention_seconds": c.retain.as_secs(), + }, + "load": { "queued": queued, "running": running, "memory_held_bytes": held << 20 }, + "uptime_seconds": app.service.started.elapsed().map(|d| d.as_secs()).unwrap_or(0), + "receipt_key": app.signer.public_key(), + "endpoints": ["GET /v1", "GET /v1/health", "GET /v1/methods", "POST /v1/jobs", "GET /v1/jobs/{id}", "DELETE /v1/jobs/{id}", "POST /v1/run", "POST /v1/verify"], + }), + ) +} + +async fn methods(State(app): State) -> Response { + reply(200, json!({ "methods": catalog::describe(app.service.config.job_mib << 20) })) +} + +async fn health() -> Response { + reply(200, json!({ "ok": true })) +} + +/// Plans and admits `request` for `who`: the job's id, or the refusal's +/// status and reason. +fn admit(app: &App, who: &str, request: Value) -> Result { + let plan = catalog::plan(&request, app.service.config.job_mib << 20).map_err(|e| (400, e))?; + app.service.submit(who, request, plan).map_err(|Refused(code, why)| (code, why)) +} + +async fn submit(State(app): State, ConnectInfo(peer): ConnectInfo, headers: HeaderMap, body: Result, axum::extract::rejection::JsonRejection>) -> Response { + let Json(request) = match body { + Ok(b) => b, + Err(e) => return error(400, &format!("the body is not a JSON job: {e}")), + }; + let who = client(&app, peer, &headers); + match admit(&app, &who, request) { + Ok(id) => reply(202, app.service.get(&id).unwrap_or(json!({ "id": id }))), + Err((code, why)) => error(code, &why), + } +} + +async fn run(State(app): State, ConnectInfo(peer): ConnectInfo, headers: HeaderMap, body: Result, axum::extract::rejection::JsonRejection>) -> Response { + let Json(request) = match body { + Ok(b) => b, + Err(e) => return error(400, &format!("the body is not a JSON job: {e}")), + }; + let who = client(&app, peer, &headers); + let id = match admit(&app, &who, request) { + Ok(id) => id, + Err((code, why)) => return error(code, &why), + }; + let status = app.service.wait(&id, RUN_WAIT).await; + let body = app.service.get(&id).unwrap_or(json!({ "id": id })); + match status { + Some(Status::Done) | Some(Status::Failed) | Some(Status::Cancelled) => reply(200, body), + _ => reply(202, body), + } +} + +async fn job(State(app): State, Path(id): Path) -> Response { + match app.service.get(&id) { + Some(j) => reply(200, j), + None => error(404, "no such job (results are kept for a limited time)"), + } +} + +async fn cancel(State(app): State, Path(id): Path) -> Response { + match app.service.cancel(&id) { + Some(true) => reply(200, app.service.get(&id).unwrap_or(json!({ "id": id }))), + Some(false) => error(409, "the job has already finished"), + None => error(404, "no such job"), + } +} + +/// `{"request": ..., "result": ...}` with the result's own `receipt`: whether +/// the receipt seals that request and that result. +async fn verify(body: Result, axum::extract::rejection::JsonRejection>) -> Response { + let Ok(Json(v)) = body else { return error(400, "the body is not JSON") }; + let (Some(request), Some(result)) = (v.get("request"), v.get("result")) else { + return error(400, "expected {\"request\": ..., \"result\": ...}, the result carrying its receipt"); + }; + let Some(receipt) = result.get("receipt") else { return error(400, "the result carries no receipt") }; + let mut bare = result.clone(); + if let Some(o) = bare.as_object_mut() { + o.remove("receipt"); + } + reply(200, json!({ "valid": receipt::verify(request, &bare, receipt), "key": receipt.get("key") })) +} + +async fn preflight() -> Response { + let mut r = StatusCode::NO_CONTENT.into_response(); + let h = r.headers_mut(); + h.insert("access-control-allow-origin", HeaderValue::from_static("*")); + h.insert("access-control-allow-methods", HeaderValue::from_static("GET, POST, DELETE, OPTIONS")); + h.insert("access-control-allow-headers", HeaderValue::from_static("content-type")); + h.insert("access-control-max-age", HeaderValue::from_static("86400")); + r +} + +pub fn router(app: Shared) -> Router { + Router::new() + .route("/v1", get(describe)) + .route("/v1/health", get(health)) + .route("/v1/methods", get(methods)) + .route("/v1/jobs", post(submit)) + .route("/v1/jobs/{id}", get(job).delete(cancel)) + .route("/v1/run", post(run)) + .route("/v1/verify", post(verify)) + .route_layer(axum::middleware::from_fn(|req: axum::extract::Request, next: axum::middleware::Next| async move { + if req.method() == Method::OPTIONS { preflight().await } else { next.run(req).await } + })) + .fallback(|| async { error(404, "no such endpoint; GET /v1 lists them") }) + .layer(DefaultBodyLimit::max(MAX_BODY)) + .with_state(app) +} diff --git a/wai/wai-quantum-service/src/jobs.rs b/wai/wai-quantum-service/src/jobs.rs new file mode 100644 index 000000000..7e9655a19 --- /dev/null +++ b/wai/wai-quantum-service/src/jobs.rs @@ -0,0 +1,644 @@ +//! The job store, the queue and the supervisor. +//! +//! A public service on a shared host has to keep three promises: a job never +//! takes more memory than it was admitted with, the host never runs short of +//! memory because of this service, and no client crowds out the others. So: +//! +//! - **Admission.** Every job is planned before it is queued +//! ([`crate::catalog::plan`]): its method and an upper estimate of its +//! memory. A job whose estimate is over the per-job cap is refused at once. +//! - **Budget.** Jobs run while their estimates fit the service's total +//! budget; the rest wait in order. A job of a gigabyte or more also waits +//! until the host's free memory covers it with [`Config::min_free_pct`] of +//! the host's memory to spare, so other work on the host comes first. +//! - **Isolation.** Each job runs in a child process (this binary's `worker` +//! mode), at a lower scheduling priority, polled for its resident memory. +//! One that passes its admitted memory or its time limit is killed, and the +//! job fails saying which. +//! - **Fairness.** A client holds at most [`Config::per_client_active`] queued +//! or running jobs, and submits at most [`Config::per_client_burst`] in any +//! [`Config::per_client_window`]. The queue holds at most +//! [`Config::max_queue`] jobs. +//! +//! Results are kept in memory for [`Config::retain`], then dropped. + +use crate::catalog::Plan; +use serde_json::{Value, json}; +use std::collections::{HashMap, VecDeque}; +use std::process::Stdio; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::{Notify, Semaphore}; + +/// The service's limits, from the environment ([`Config::from_env`]). +#[derive(Clone, Debug)] +pub struct Config { + /// The memory all running jobs may hold together, MiB. + pub budget_mib: u64, + /// The memory one job may hold, MiB. + pub job_mib: u64, + /// Jobs running at once, at most. + pub workers: usize, + /// The share of the host's memory, percent, that a starting job must + /// leave free. + pub min_free_pct: u32, + /// The wall time one job may take. + pub job_timeout: Duration, + /// How long a finished job's result is kept. + pub retain: Duration, + /// Jobs waiting at once, at most. + pub max_queue: usize, + /// Queued or running jobs one client may hold. + pub per_client_active: usize, + /// Submissions one client may make in a window. + pub per_client_burst: usize, + pub per_client_window: Duration, +} + +fn env_num(key: &str, default: T) -> T { + std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default) +} + +impl Config { + /// `WAI_QSVC_BUDGET_GB` (96), `WAI_QSVC_JOB_GB` (72), `WAI_QSVC_WORKERS` + /// (4), `WAI_QSVC_MIN_FREE_PCT` (15), `WAI_QSVC_JOB_SECS` (900), + /// `WAI_QSVC_RETAIN_SECS` (86400), `WAI_QSVC_MAX_QUEUE` (256), + /// `WAI_QSVC_CLIENT_ACTIVE` (3), `WAI_QSVC_CLIENT_BURST` (30 in + /// `WAI_QSVC_CLIENT_WINDOW_SECS`, 600). + pub fn from_env() -> Config { + Config { + budget_mib: env_num("WAI_QSVC_BUDGET_GB", 96u64) * 1024, + job_mib: env_num("WAI_QSVC_JOB_GB", 72u64) * 1024, + workers: env_num("WAI_QSVC_WORKERS", 4usize).max(1), + min_free_pct: env_num("WAI_QSVC_MIN_FREE_PCT", 15u32), + job_timeout: Duration::from_secs(env_num("WAI_QSVC_JOB_SECS", 900u64)), + retain: Duration::from_secs(env_num("WAI_QSVC_RETAIN_SECS", 86_400u64)), + max_queue: env_num("WAI_QSVC_MAX_QUEUE", 256usize), + per_client_active: env_num("WAI_QSVC_CLIENT_ACTIVE", 3usize), + per_client_burst: env_num("WAI_QSVC_CLIENT_BURST", 30usize), + per_client_window: Duration::from_secs(env_num("WAI_QSVC_CLIENT_WINDOW_SECS", 600u64)), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Status { + Queued, + Running, + Done, + Failed, + Cancelled, +} + +impl Status { + pub fn name(self) -> &'static str { + match self { + Status::Queued => "queued", + Status::Running => "running", + Status::Done => "done", + Status::Failed => "failed", + Status::Cancelled => "cancelled", + } + } + + fn active(self) -> bool { + matches!(self, Status::Queued | Status::Running) + } +} + +/// One job: what was asked, how it was planned, and how it ended. +#[derive(Debug)] +pub struct Job { + pub id: String, + pub client: String, + pub request: Value, + pub plan: Plan, + pub status: Status, + pub submitted: SystemTime, + pub started: Option, + pub finished: Option, + finished_at: Option, + pub result: Option, + pub error: Option, + pub peak_rss: u64, + child: Option, +} + +fn unix_ms(t: SystemTime) -> u64 { + t.duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0) +} + +impl Job { + /// The job as the API reports it. + pub fn to_json(&self) -> Value { + let mut o = json!({ + "id": self.id, + "status": self.status.name(), + "method": self.plan.method, + "why": self.plan.why, + "estimate": { "memory_bytes": self.plan.memory_bytes, "work": self.plan.work }, + "submitted_ms": unix_ms(self.submitted), + }); + if let Some(t) = self.started { + o["started_ms"] = json!(unix_ms(t)); + } + if let Some(t) = self.finished { + o["finished_ms"] = json!(unix_ms(t)); + } + if self.peak_rss > 0 { + o["peak_rss_bytes"] = json!(self.peak_rss); + } + if let Some(r) = &self.result { + o["result"] = r.clone(); + } + if let Some(e) = &self.error { + o["error"] = json!(e); + } + o + } +} + +/// Why a submission was refused: the HTTP status and the reason. +#[derive(Debug)] +pub struct Refused(pub u16, pub String); + +#[derive(Default)] +struct Client { + submissions: VecDeque, +} + +/// The store, the queue and the scheduler's state. +pub struct Service { + pub config: Config, + jobs: Mutex>, + queue: Mutex>, + clients: Mutex>, + wake: Notify, + memory: Arc, + workers: Arc, + sealer: std::sync::OnceLock>, + pub started: SystemTime, +} + +/// A fresh unguessable job id: 128 random bits, in hex. +fn job_id() -> String { + let mut b = [0u8; 16]; + getrandom::fill(&mut b).expect("the operating system's random source"); + b.iter().map(|x| format!("{x:02x}")).collect() +} + +impl Service { + pub fn new(config: Config) -> Arc { + let memory = Arc::new(Semaphore::new(usize::try_from(config.budget_mib).unwrap_or(usize::MAX).min(Semaphore::MAX_PERMITS))); + let workers = Arc::new(Semaphore::new(config.workers)); + Arc::new(Service { + config, + jobs: Mutex::new(HashMap::new()), + queue: Mutex::new(VecDeque::new()), + clients: Mutex::new(HashMap::new()), + wake: Notify::new(), + memory, + workers, + sealer: std::sync::OnceLock::new(), + started: SystemTime::now(), + }) + } + + /// Seals every result from now on with `signer` ([`crate::receipt`]). + pub fn seal_with(&self, signer: Arc) { + let _ = self.sealer.set(signer); + } + + /// Admits a planned job for `client`, or says why not. + pub fn submit(&self, client: &str, request: Value, plan: Plan) -> Result { + let job_mib = plan.memory_bytes.div_ceil(1 << 20); + if job_mib > self.config.job_mib { + return Err(Refused( + 422, + format!("the {} method would need about {} MiB for this circuit; one job may take at most {} MiB", plan.method, job_mib, self.config.job_mib), + )); + } + let now = Instant::now(); + { + let jobs = self.jobs.lock().unwrap(); + let active = jobs.values().filter(|j| j.client == client && j.status.active()).count(); + if active >= self.config.per_client_active { + return Err(Refused(429, format!("{active} of your jobs are queued or running; wait for one to finish (at most {} at once)", self.config.per_client_active))); + } + } + { + let mut clients = self.clients.lock().unwrap(); + let c = clients.entry(client.to_string()).or_default(); + while c.submissions.front().is_some_and(|t| now.duration_since(*t) > self.config.per_client_window) { + c.submissions.pop_front(); + } + if c.submissions.len() >= self.config.per_client_burst { + return Err(Refused( + 429, + format!("at most {} submissions in {} s; try again shortly", self.config.per_client_burst, self.config.per_client_window.as_secs()), + )); + } + c.submissions.push_back(now); + } + let id = job_id(); + { + let mut queue = self.queue.lock().unwrap(); + if queue.len() >= self.config.max_queue { + return Err(Refused(503, format!("the queue is full ({} jobs); try again later", self.config.max_queue))); + } + queue.push_back(id.clone()); + } + let job = Job { + id: id.clone(), + client: client.to_string(), + request, + plan, + status: Status::Queued, + submitted: SystemTime::now(), + started: None, + finished: None, + finished_at: None, + result: None, + error: None, + peak_rss: 0, + child: None, + }; + self.jobs.lock().unwrap().insert(id.clone(), job); + self.wake.notify_one(); + Ok(id) + } + + /// The job's report, if it is known. + pub fn get(&self, id: &str) -> Option { + self.jobs.lock().unwrap().get(id).map(Job::to_json) + } + + /// The job's status, if it is known. + pub fn status(&self, id: &str) -> Option { + self.jobs.lock().unwrap().get(id).map(|j| j.status) + } + + /// Cancels a queued or running job: whether it was active. + pub fn cancel(&self, id: &str) -> Option { + let mut jobs = self.jobs.lock().unwrap(); + let j = jobs.get_mut(id)?; + if !j.status.active() { + return Some(false); + } + if let Some(pid) = j.child { + kill(pid); + } + j.status = Status::Cancelled; + j.finished = Some(SystemTime::now()); + j.finished_at = Some(Instant::now()); + Some(true) + } + + /// Counts for the service description: queued, running, and the memory + /// held by running jobs (MiB). + pub fn load(&self) -> (usize, usize, u64) { + let jobs = self.jobs.lock().unwrap(); + let queued = jobs.values().filter(|j| j.status == Status::Queued).count(); + let running: Vec<&Job> = jobs.values().filter(|j| j.status == Status::Running).collect(); + (queued, running.len(), running.iter().map(|j| j.plan.memory_bytes.div_ceil(1 << 20)).sum()) + } + + fn finish(&self, id: &str, outcome: Result, peak: u64) { + let mut jobs = self.jobs.lock().unwrap(); + if let Some(j) = jobs.get_mut(id) { + j.child = None; + j.peak_rss = j.peak_rss.max(peak); + if j.status == Status::Cancelled { + return; + } + j.finished = Some(SystemTime::now()); + j.finished_at = Some(Instant::now()); + match outcome { + Ok(mut r) => { + if let Some(s) = self.sealer.get() { + let receipt = s.seal(&j.request, &r); + r["receipt"] = receipt; + } + j.status = Status::Done; + j.result = Some(r); + } + Err(e) => { + j.status = Status::Failed; + j.error = Some(e); + } + } + } + } + + /// Drops finished jobs older than [`Config::retain`]. + fn expire(&self) { + let retain = self.config.retain; + self.jobs.lock().unwrap().retain(|_, j| j.finished_at.is_none_or(|t| t.elapsed() < retain)); + } + + /// Waits until a job is finished (or `timeout` passes): its status. + pub async fn wait(&self, id: &str, timeout: Duration) -> Option { + let deadline = Instant::now() + timeout; + loop { + let s = self.status(id)?; + if !s.active() || Instant::now() >= deadline { + return Some(s); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + + /// The scheduler: starts queued jobs in order as workers, the memory + /// budget and the host's free memory allow. Runs for the life of the + /// service. + pub async fn schedule(self: Arc) { + let mut last_expiry = Instant::now(); + loop { + if last_expiry.elapsed() > Duration::from_secs(60) { + self.expire(); + last_expiry = Instant::now(); + } + let next = self.queue.lock().unwrap().front().cloned(); + let Some(id) = next else { + tokio::select! { + _ = self.wake.notified() => {} + _ = tokio::time::sleep(Duration::from_secs(30)) => {} + } + continue; + }; + // A job cancelled while queued is dropped from the queue. + let mib = { + let jobs = self.jobs.lock().unwrap(); + match jobs.get(&id) { + Some(j) if j.status == Status::Queued => Some(j.plan.memory_bytes.div_ceil(1 << 20).max(16)), + _ => None, + } + }; + let Some(mib) = mib else { + self.queue.lock().unwrap().pop_front(); + continue; + }; + let worker = Arc::clone(&self.workers).acquire_owned().await.expect("the semaphore is never closed"); + let permits = u32::try_from(mib).unwrap_or(u32::MAX); + let memory = Arc::clone(&self.memory).acquire_many_owned(permits).await.expect("the semaphore is never closed"); + // A job of a gigabyte or more waits until the host's free memory + // covers it and the reserve. + if mib >= 1024 { + while !host_has_room(mib << 20, self.config.min_free_pct) { + tokio::time::sleep(Duration::from_secs(2)).await; + } + } + self.queue.lock().unwrap().pop_front(); + let request = { + let mut jobs = self.jobs.lock().unwrap(); + match jobs.get_mut(&id) { + Some(j) if j.status == Status::Queued => { + j.status = Status::Running; + j.started = Some(SystemTime::now()); + Some((j.request.clone(), j.plan.clone())) + } + _ => None, + } + }; + let Some((request, plan)) = request else { continue }; + let me = Arc::clone(&self); + tokio::spawn(async move { + let (outcome, peak) = me.supervise(&id, &request, &plan, mib).await; + me.finish(&id, outcome, peak); + drop(memory); + drop(worker); + }); + } + } + + /// Runs one job in a child process and watches it: the worker's result, + /// or why it failed, and its peak resident memory. + async fn supervise(&self, id: &str, request: &Value, plan: &Plan, mib: u64) -> (Result, u64) { + let exe = match std::env::current_exe() { + Ok(e) => e, + Err(e) => return (Err(format!("the service could not find itself: {e}")), 0), + }; + let mut cmd = low_priority(&exe); + cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true); + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => return (Err(format!("the worker did not start: {e}")), 0), + }; + let pid = child.id().unwrap_or(0); + if let Some(j) = self.jobs.lock().unwrap().get_mut(id) { + j.child = Some(pid); + } + let input = json!({ "request": request, "plan": plan }).to_string(); + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(input.as_bytes()).await; + } + let mut stdout = child.stdout.take().expect("piped"); + let mut stderr = child.stderr.take().expect("piped"); + let reader = tokio::spawn(async move { + let mut out = Vec::new(); + let _ = stdout.read_to_end(&mut out).await; + out + }); + let errors = tokio::spawn(async move { + let mut out = Vec::new(); + let _ = stderr.read_to_end(&mut out).await; + out + }); + // The admitted memory, with room for the process itself. + let limit = (mib + 256) << 20; + let deadline = Instant::now() + self.config.job_timeout; + let mut peak = 0u64; + let why = loop { + match child.try_wait() { + Ok(Some(status)) => break status.success().then_some(()).ok_or(format!("the worker ended: {status}")), + Ok(None) => {} + Err(e) => break Err(format!("the worker could not be watched: {e}")), + } + if let Some(rss) = rss_bytes(pid) { + peak = peak.max(rss); + if rss > limit { + kill(pid); + let _ = child.wait().await; + break Err(format!("the job used {} MiB, over the {} MiB it was admitted with; it was stopped", rss >> 20, mib)); + } + } + if Instant::now() > deadline { + kill(pid); + let _ = child.wait().await; + break Err(format!("the job ran past {} s; it was stopped", self.config.job_timeout.as_secs())); + } + tokio::time::sleep(Duration::from_millis(200)).await; + }; + let out = reader.await.unwrap_or_default(); + let err = errors.await.unwrap_or_default(); + if let Err(e) = why { + let detail = String::from_utf8_lossy(&err); + let detail = detail.trim(); + return (Err(if detail.is_empty() { e } else { format!("{e}: {}", detail.lines().last().unwrap_or(detail)) }), peak); + } + match serde_json::from_slice::(&out) { + Ok(v) => match v.get("error").and_then(Value::as_str) { + Some(e) => (Err(e.to_string()), peak), + None => (Ok(v), peak), + }, + Err(e) => (Err(format!("the worker's output did not parse: {e}")), peak), + } + } +} + +/// The worker command at a lower priority than the host's other work. +fn low_priority(exe: &std::path::Path) -> tokio::process::Command { + let mut cmd = if cfg!(target_os = "macos") { + let mut c = tokio::process::Command::new("/usr/sbin/taskpolicy"); + c.args(["-c", "utility", "/usr/bin/nice", "-n", "10"]).arg(exe); + c + } else { + let mut c = tokio::process::Command::new("nice"); + c.args(["-n", "10"]).arg(exe); + c + }; + cmd.arg("worker"); + cmd +} + +fn kill(pid: u32) { + if pid > 0 { + let _ = std::process::Command::new("kill").args(["-9", &pid.to_string()]).status(); + } +} + +/// A process's resident memory, bytes, the worker's child included where the +/// platform folds it in. +fn rss_bytes(pid: u32) -> Option { + if pid == 0 { + return None; + } + if cfg!(target_os = "linux") { + let s = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?; + let kb: u64 = s.lines().find(|l| l.starts_with("VmRSS:"))?.split_whitespace().nth(1)?.parse().ok()?; + return Some(kb * 1024); + } + let o = std::process::Command::new("ps").args(["-o", "rss=", "-p", &pid.to_string()]).output().ok()?; + let kb: u64 = String::from_utf8_lossy(&o.stdout).trim().parse().ok()?; + Some(kb * 1024) +} + +/// Whether the host's free memory covers `bytes` with `reserve_pct` of its +/// memory to spare. A host that reports neither is taken to have room. +pub fn host_has_room(bytes: u64, reserve_pct: u32) -> bool { + match (host_free_pct(), host_memory()) { + (Some(pct), Some(total)) => { + let free = total / 100 * pct as u64; + free >= bytes.saturating_add(total / 100 * reserve_pct as u64) + } + _ => true, + } +} + +/// The host's memory, bytes. +pub fn host_memory() -> Option { + if cfg!(target_os = "linux") { + let s = std::fs::read_to_string("/proc/meminfo").ok()?; + let kb: u64 = s.lines().find(|l| l.starts_with("MemTotal:"))?.split_whitespace().nth(1)?.parse().ok()?; + return Some(kb * 1024); + } + let o = std::process::Command::new("/usr/sbin/sysctl").args(["-n", "hw.memsize"]).output().ok()?; + String::from_utf8_lossy(&o.stdout).trim().parse().ok() +} + +/// The host's free memory, percent, as the platform reports it. +pub fn host_free_pct() -> Option { + if cfg!(target_os = "linux") { + let s = std::fs::read_to_string("/proc/meminfo").ok()?; + let field = |name: &str| -> Option { s.lines().find(|l| l.starts_with(name))?.split_whitespace().nth(1)?.parse().ok() }; + let (avail, total) = (field("MemAvailable:")?, field("MemTotal:")?); + return (total > 0).then(|| (avail * 100 / total) as u32); + } + let o = std::process::Command::new("/usr/bin/memory_pressure").arg("-Q").output().ok()?; + let text = String::from_utf8_lossy(&o.stdout); + let pct = text.split("percentage:").nth(1)?.trim().trim_end_matches('%').trim(); + pct.parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plan(memory_bytes: u64) -> Plan { + Plan { method: "statevector".into(), why: "test".into(), memory_bytes, work: 1, qubits: 3 } + } + + fn config() -> Config { + Config { + budget_mib: 1024, + job_mib: 512, + workers: 1, + min_free_pct: 0, + job_timeout: Duration::from_secs(5), + retain: Duration::from_secs(60), + max_queue: 4, + per_client_active: 2, + per_client_burst: 3, + per_client_window: Duration::from_secs(60), + } + } + + #[test] + fn a_job_over_the_per_job_cap_is_refused_with_its_estimate() { + let s = Service::new(config()); + let Err(Refused(code, why)) = s.submit("a", json!({}), plan(600 << 20)) else { panic!("admitted") }; + assert_eq!(code, 422); + assert!(why.contains("600 MiB") && why.contains("512 MiB"), "{why}"); + assert!(s.submit("a", json!({}), plan(512 << 20)).is_ok()); + } + + #[test] + fn a_client_holds_a_bounded_number_of_jobs_and_submissions() { + // Two active jobs at once, and two submissions a window. + let s = Service::new(Config { per_client_burst: 2, ..config() }); + assert!(s.submit("a", json!({}), plan(1)).is_ok()); + assert!(s.submit("a", json!({}), plan(1)).is_ok()); + let Err(Refused(code, _)) = s.submit("a", json!({}), plan(1)) else { panic!("a third active job was admitted") }; + assert_eq!(code, 429); + // Another client is not affected. + assert!(s.submit("b", json!({}), plan(1)).is_ok()); + // A cancelled job frees its place, but the burst still counts. + let ids: Vec = s.jobs.lock().unwrap().values().filter(|j| j.client == "a").map(|j| j.id.clone()).collect(); + assert_eq!(s.cancel(&ids[0]), Some(true)); + let Err(Refused(code, why)) = s.submit("a", json!({}), plan(1)) else { panic!("the burst limit did not hold") }; + assert_eq!(code, 429, "{why}"); + assert!(why.contains("submissions"), "{why}"); + } + + #[test] + fn the_queue_is_bounded() { + let s = Service::new(Config { per_client_active: 100, per_client_burst: 100, ..config() }); + for k in 0..4 { + assert!(s.submit(&format!("c{k}"), json!({}), plan(1)).is_ok()); + } + let Err(Refused(code, _)) = s.submit("c9", json!({}), plan(1)) else { panic!("the queue overflowed") }; + assert_eq!(code, 503); + } + + #[test] + fn job_ids_are_unguessable_and_distinct() { + let a = job_id(); + let b = job_id(); + assert_eq!(a.len(), 32); + assert_ne!(a, b); + } + + #[test] + fn this_hosts_free_memory_reads() { + if cfg!(any(target_os = "linux", target_os = "macos")) { + let p = host_free_pct().expect("the platform reports free memory"); + assert!(p <= 100); + let total = host_memory().expect("the platform reports its memory"); + assert!(total > 1 << 30); + // No host has room for more than all of its memory. + assert!(!host_has_room(total, 0)); + assert!(host_has_room(0, 0)); + } + } +} diff --git a/wai/wai-quantum-service/src/main.rs b/wai/wai-quantum-service/src/main.rs new file mode 100644 index 000000000..36becb551 --- /dev/null +++ b/wai/wai-quantum-service/src/main.rs @@ -0,0 +1,111 @@ +//! wai-quantum-service: the WAI quantum stack as a public job service. +//! +//! A job is an OpenQASM 3 program, the outputs wanted (measurement counts, +//! Pauli expectation values) and a method, or `auto` for the strongest method +//! the circuit admits ([`catalog`]). Every result names its method and how +//! accurate it is (bit-exact, exact, bounded or estimated), and is sealed with +//! the service's key ([`receipt`]). Jobs run one per child process, inside a +//! memory budget, at a lower priority than the host's other work ([`jobs`]). +//! +//! ```text +//! wai-quantum-service serve the HTTP API (WAI_QSVC_ADDR, 127.0.0.1:8241) +//! wai-quantum-service worker one job: {"request", "plan"} on stdin, the result on stdout +//! wai-quantum-service run FILE one job from a JSON file, here, printed +//! ``` + +mod catalog; +mod http; +mod jobs; +mod qasm; +mod receipt; +mod run; + +use std::io::Read; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; + +fn main() { + let args: Vec = std::env::args().collect(); + match args.get(1).map(String::as_str) { + Some("worker") => worker(), + Some("run") => match args.get(2) { + Some(path) => run_file(path), + None => usage(), + }, + Some("serve") | None => serve(), + _ => usage(), + } +} + +fn usage() { + eprintln!("usage: wai-quantum-service [serve | worker | run FILE]"); + std::process::exit(2); +} + +/// One job, in this process: the request and plan on stdin, the result (or +/// `{"error": ...}`) on stdout. +fn worker() { + let mut input = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut input) { + println!("{}", serde_json::json!({ "error": format!("the job did not arrive: {e}") })); + return; + } + let out = match serde_json::from_str::(&input) { + Ok(v) => match (v.get("request"), v.get("plan").and_then(|p| serde_json::from_value::(p.clone()).ok())) { + (Some(request), Some(plan)) => run::run(request, &plan).unwrap_or_else(|e| serde_json::json!({ "error": e })), + _ => serde_json::json!({ "error": "the job lacks its request or plan" }), + }, + Err(e) => serde_json::json!({ "error": format!("the job is not JSON: {e}") }), + }; + println!("{out}"); +} + +/// A job from a JSON file, planned and run here (no limits but the per-job +/// memory cap), printed with its plan. +fn run_file(path: &str) { + let text = std::fs::read_to_string(path).unwrap_or_else(|e| { + eprintln!("{path}: {e}"); + std::process::exit(1) + }); + let request: serde_json::Value = serde_json::from_str(&text).unwrap_or_else(|e| { + eprintln!("{path}: not JSON: {e}"); + std::process::exit(1) + }); + let cap = jobs::Config::from_env().job_mib << 20; + let plan = catalog::plan(&request, cap).unwrap_or_else(|e| { + eprintln!("{e}"); + std::process::exit(1) + }); + let result = run::run(&request, &plan).unwrap_or_else(|e| serde_json::json!({ "error": e })); + println!("{}", serde_json::to_string_pretty(&serde_json::json!({ "plan": plan, "result": result })).expect("JSON")); +} + +fn serve() { + let config = jobs::Config::from_env(); + let key_path = receipt::default_path(); + let signer = receipt::Signer::load_or_create(&key_path).unwrap_or_else(|e| { + eprintln!("the signing key at {}: {e}", key_path.display()); + std::process::exit(1) + }); + let addr: SocketAddr = std::env::var("WAI_QSVC_ADDR").ok().and_then(|a| a.parse().ok()).unwrap_or_else(|| SocketAddr::from(([127, 0, 0, 1], 8241))); + let trusted: Vec = std::env::var("WAI_QSVC_TRUSTED_PROXIES") + .unwrap_or_else(|_| "127.0.0.1,::1".into()) + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(4).enable_all().build().expect("the runtime"); + runtime.block_on(async move { + let service = jobs::Service::new(config); + let signer = Arc::new(signer); + service.seal_with(Arc::clone(&signer)); + tokio::spawn(Arc::clone(&service).schedule()); + let app = Arc::new(http::App { service, signer, trusted }); + let listener = tokio::net::TcpListener::bind(addr).await.unwrap_or_else(|e| { + eprintln!("binding {addr}: {e}"); + std::process::exit(1) + }); + eprintln!("wai-quantum-service {} on {addr}, receipt key {}", env!("CARGO_PKG_VERSION"), app.signer.public_key()); + let router = http::router(Arc::clone(&app)); + axum::serve(listener, router.into_make_service_with_connect_info::()).await.expect("the server"); + }); +} diff --git a/wai/wai-quantum-service/src/qasm.rs b/wai/wai-quantum-service/src/qasm.rs new file mode 100644 index 000000000..058ed05b6 --- /dev/null +++ b/wai/wai-quantum-service/src/qasm.rs @@ -0,0 +1,1707 @@ +//! OpenQASM 3, and the OpenQASM 2 forms in common use, to the service's +//! circuit, and that circuit lowered to each method's input. +//! +//! Accepted: `OPENQASM`, `include` (the standard gate libraries are built in), +//! `qubit[n] q;` / `qubit q;` / `qreg q[n];`, `bit[n] c;` / `creg c[n];`, +//! gate calls with angle expressions (`pi`, `tau`, `euler`, `+ - * / ^`, and +//! `sin cos tan exp ln sqrt arcsin arccos arctan`), register broadcast +//! (`h q;`, `cx a, b;` over equal-length registers), user `gate` definitions +//! (inlined, with parameters), `barrier` (ignored), `reset` before a qubit's +//! first gate, and terminal measurement in every form (`c[0] = measure q[0];`, +//! `c = measure q;`, `measure q -> c;`, `measure q;`). With no measurement, +//! every qubit is measured into a bit of its own index. +//! +//! Refused, each with its line: classical control, gate modifiers, a gate on a +//! qubit after its measurement, and anything else not listed. +//! +//! The standard gates: `id x y z h s sdg t tdg sx sxdg rx ry rz p phase u1 u2 +//! u u3 U cx CX cnot cy cz ch cp cphase cu1 crx cry crz cu cu3 swap iswap rxx +//! ryy rzz ccx toffoli ccz cswap fredkin`, with OpenQASM 3's definitions. +//! +//! Lowering ([`Program::matrices`], [`Program::rotations`], +//! [`Program::dyadic`]) is exact up to a global phase; the tests hold each +//! lowering to the others. + +use std::collections::HashMap; +use wai_quantum::gates::{C, Gate as Matrix, complex}; +use wai_quantum::quantum::{BaseGate, Circuit as Dyadic, Gate as DyadicGate}; +use wai_quantum::quantum_spd::{Op as Rot, Pauli, RotCircuit}; +use wai_quantum::quantum_tableau::Clifford; + +/// The most qubits a program declares. +pub const MAX_QUBITS: u32 = 65_536; +/// The most gates a program expands to. +pub const MAX_OPS: usize = 2_000_000; +/// How deep user gate definitions may nest. +const MAX_DEPTH: usize = 32; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Kind { + Id, + X, + Y, + Z, + H, + S, + Sdg, + T, + Tdg, + Sx, + Sxdg, + Rx, + Ry, + Rz, + P, + /// `U(θ, φ, λ)`. + U, + Cx, + Cy, + Cz, + Ch, + Cp, + Crx, + Cry, + Crz, + /// `cu(θ, φ, λ, γ)`: controlled `e^{iγ} U(θ, φ, λ)`. + Cu, + Swap, + Iswap, + Rxx, + Ryy, + Rzz, + Ccx, + Ccz, + Cswap, +} + +impl Kind { + /// The gate's arity: (parameters, qubits). + pub fn arity(self) -> (usize, usize) { + use Kind::*; + match self { + Id | X | Y | Z | H | S | Sdg | T | Tdg | Sx | Sxdg => (0, 1), + Rx | Ry | Rz | P => (1, 1), + U => (3, 1), + Cx | Cy | Cz | Ch | Swap | Iswap => (0, 2), + Cp | Crx | Cry | Crz | Rxx | Ryy | Rzz => (1, 2), + Cu => (4, 2), + Ccx | Ccz | Cswap => (0, 3), + } + } + +} + +/// A builtin gate name: its kind, and any parameters it fixes or reorders. +fn builtin(name: &str) -> Option<(Kind, usize)> { + use Kind::*; + // (kind, the number of parameters the name takes) + Some(match name { + "id" | "i" => (Id, 0), + "x" => (X, 0), + "y" => (Y, 0), + "z" => (Z, 0), + "h" => (H, 0), + "s" => (S, 0), + "sdg" => (Sdg, 0), + "t" => (T, 0), + "tdg" => (Tdg, 0), + "sx" => (Sx, 0), + "sxdg" => (Sxdg, 0), + "rx" => (Rx, 1), + "ry" => (Ry, 1), + "rz" => (Rz, 1), + "p" | "phase" | "u1" => (P, 1), + "u2" => (U, 2), + "u" | "u3" | "U" => (U, 3), + "cx" | "CX" | "cnot" => (Cx, 0), + "cy" => (Cy, 0), + "cz" => (Cz, 0), + "ch" => (Ch, 0), + "cp" | "cphase" | "cu1" => (Cp, 1), + "crx" => (Crx, 1), + "cry" => (Cry, 1), + "crz" => (Crz, 1), + "cu3" => (Cu, 3), + "cu" => (Cu, 4), + "swap" => (Swap, 0), + "iswap" => (Iswap, 0), + "rxx" => (Rxx, 1), + "ryy" => (Ryy, 1), + "rzz" => (Rzz, 1), + "ccx" | "toffoli" => (Ccx, 0), + "ccz" => (Ccz, 0), + "cswap" | "fredkin" => (Cswap, 0), + _ => return None, + }) +} + +/// The parameters a builtin name's arguments become. +fn normalise(kind: Kind, given: usize, args: Vec) -> Vec { + match (kind, given) { + // u2(φ, λ) = U(π/2, φ, λ). + (Kind::U, 2) => vec![std::f64::consts::FRAC_PI_2, args[0], args[1]], + // cu3(θ, φ, λ) = cu(θ, φ, λ, 0). + (Kind::Cu, 3) => vec![args[0], args[1], args[2], 0.0], + _ => args, + } +} + +/// One gate on named qubits. +#[derive(Clone, Debug, PartialEq)] +pub struct Op { + pub kind: Kind, + pub qubits: Vec, + pub params: Vec, +} + +/// A parsed program: its gates in order, and which qubit each written bit +/// measures. +#[derive(Clone, Debug, PartialEq)] +pub struct Program { + pub n_qubits: u32, + pub ops: Vec, + /// `(qubit, bit)` for each measurement, in order. + pub measured: Vec<(u32, u32)>, + pub n_bits: u32, +} + +// --------------------------------------------------------------------------- +// Lexer +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, PartialEq)] +enum Tok { + Ident(String), + Num(f64), + Str(String), + Sym(&'static str), +} + +fn lex(src: &str) -> Result, String> { + let chars: Vec = src.chars().collect(); + let mut out = Vec::new(); + let (mut i, mut line) = (0usize, 1usize); + while i < chars.len() { + let c = chars[i]; + if c == '\n' { + line += 1; + i += 1; + } else if c.is_whitespace() { + i += 1; + } else if c == '/' && chars.get(i + 1) == Some(&'/') { + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + } else if c == '/' && chars.get(i + 1) == Some(&'*') { + i += 2; + while i < chars.len() && !(chars[i] == '*' && chars.get(i + 1) == Some(&'/')) { + if chars[i] == '\n' { + line += 1; + } + i += 1; + } + i += 2; + } else if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(|d| d.is_ascii_digit())) { + let start = i; + while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.' || chars[i] == '_') { + i += 1; + } + if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') { + let save = i; + i += 1; + if i < chars.len() && (chars[i] == '+' || chars[i] == '-') { + i += 1; + } + if i < chars.len() && chars[i].is_ascii_digit() { + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + } else { + i = save; + } + } + let text: String = chars[start..i].iter().filter(|&&c| c != '_').collect(); + let v: f64 = text.parse().map_err(|_| format!("line {line}: a number that does not read: {text}"))?; + out.push((Tok::Num(v), line)); + } else if c.is_alphabetic() || c == '_' || c == '$' { + let start = i; + while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == '$') { + i += 1; + } + out.push((Tok::Ident(chars[start..i].iter().collect()), line)); + } else if c == '"' { + let start = i + 1; + i += 1; + while i < chars.len() && chars[i] != '"' { + i += 1; + } + out.push((Tok::Str(chars[start..i.min(chars.len())].iter().collect()), line)); + i += 1; + } else { + let two: String = chars[i..(i + 2).min(chars.len())].iter().collect(); + let sym = match two.as_str() { + "->" => Some("->"), + "**" => Some("**"), + "==" => Some("=="), + _ => None, + }; + if let Some(s) = sym { + out.push((Tok::Sym(s), line)); + i += 2; + continue; + } + let s = match c { + ';' => ";", + ',' => ",", + '(' => "(", + ')' => ")", + '[' => "[", + ']' => "]", + '{' => "{", + '}' => "}", + '=' => "=", + '+' => "+", + '-' => "-", + '*' => "*", + '/' => "/", + '^' => "^", + '@' => "@", + ':' => ":", + _ => return Err(format!("line {line}: unexpected character {c:?}")), + }; + out.push((Tok::Sym(s), line)); + i += 1; + } + } + Ok(out) +} + +// --------------------------------------------------------------------------- +// Expressions +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug)] +enum Expr { + Num(f64), + Var(String), + Neg(Box), + Bin(char, Box, Box), + Call(String, Box), +} + +impl Expr { + fn eval(&self, env: &HashMap) -> Result { + Ok(match self { + Expr::Num(v) => *v, + Expr::Var(name) => match name.as_str() { + "pi" | "π" => std::f64::consts::PI, + "tau" | "τ" => std::f64::consts::TAU, + "euler" | "ℯ" => std::f64::consts::E, + _ => *env.get(name).ok_or_else(|| format!("unknown name {name}"))?, + }, + Expr::Neg(a) => -a.eval(env)?, + Expr::Bin(op, a, b) => { + let (x, y) = (a.eval(env)?, b.eval(env)?); + match op { + '+' => x + y, + '-' => x - y, + '*' => x * y, + '/' => x / y, + _ => x.powf(y), + } + } + Expr::Call(f, a) => { + let x = a.eval(env)?; + match f.as_str() { + "sin" => x.sin(), + "cos" => x.cos(), + "tan" => x.tan(), + "exp" => x.exp(), + "ln" => x.ln(), + "sqrt" => x.sqrt(), + "arcsin" => x.asin(), + "arccos" => x.acos(), + "arctan" => x.atan(), + _ => return Err(format!("unknown function {f}")), + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +/// A gate call inside a user definition: its name, parameter expressions and +/// formal qubit names. +#[derive(Clone, Debug)] +struct Call { + name: String, + params: Vec, + args: Vec, + line: usize, +} + +#[derive(Clone, Debug)] +struct Def { + params: Vec, + qubits: Vec, + body: Vec, +} + +struct Parser { + toks: Vec<(Tok, usize)>, + pos: usize, + qregs: HashMap, + cregs: HashMap, + n_qubits: u32, + n_bits: u32, + defs: HashMap, + ops: Vec, + measured: Vec<(u32, u32)>, + measured_qubits: Vec, + touched: Vec, +} + +impl Parser { + fn line(&self) -> usize { + self.toks.get(self.pos).or(self.toks.last()).map(|t| t.1).unwrap_or(0) + } + + fn err(&self, msg: impl std::fmt::Display) -> Result { + Err(format!("line {}: {msg}", self.line())) + } + + fn peek(&self) -> Option<&Tok> { + self.toks.get(self.pos).map(|t| &t.0) + } + + fn next(&mut self) -> Option { + let t = self.toks.get(self.pos).map(|t| t.0.clone()); + self.pos += 1; + t + } + + fn is_sym(&self, s: &str) -> bool { + matches!(self.peek(), Some(Tok::Sym(x)) if *x == s) + } + + fn eat(&mut self, s: &str) -> bool { + if self.is_sym(s) { + self.pos += 1; + true + } else { + false + } + } + + fn expect(&mut self, s: &str) -> Result<(), String> { + if self.eat(s) { Ok(()) } else { self.err(format!("expected `{s}`")) } + } + + fn ident(&mut self) -> Result { + match self.next() { + Some(Tok::Ident(s)) => Ok(s), + _ => { + self.pos -= 1; + self.err("expected a name") + } + } + } + + fn skip_to_semicolon(&mut self) { + while let Some(t) = self.next() { + if t == Tok::Sym(";") { + break; + } + } + } + + // expr := term (('+'|'-') term)* + fn expr(&mut self) -> Result { + let mut a = self.term()?; + loop { + if self.eat("+") { + a = Expr::Bin('+', Box::new(a), Box::new(self.term()?)); + } else if self.eat("-") { + a = Expr::Bin('-', Box::new(a), Box::new(self.term()?)); + } else { + return Ok(a); + } + } + } + + fn term(&mut self) -> Result { + let mut a = self.unary()?; + loop { + if self.eat("*") { + a = Expr::Bin('*', Box::new(a), Box::new(self.unary()?)); + } else if self.eat("/") { + a = Expr::Bin('/', Box::new(a), Box::new(self.unary()?)); + } else { + return Ok(a); + } + } + } + + fn unary(&mut self) -> Result { + if self.eat("-") { + return Ok(Expr::Neg(Box::new(self.unary()?))); + } + if self.eat("+") { + return self.unary(); + } + let base = self.atom()?; + if self.eat("^") || self.eat("**") { + return Ok(Expr::Bin('^', Box::new(base), Box::new(self.unary()?))); + } + Ok(base) + } + + fn atom(&mut self) -> Result { + match self.next() { + Some(Tok::Num(v)) => Ok(Expr::Num(v)), + Some(Tok::Ident(name)) => { + if self.eat("(") { + let arg = self.expr()?; + self.expect(")")?; + Ok(Expr::Call(name, Box::new(arg))) + } else { + Ok(Expr::Var(name)) + } + } + Some(Tok::Sym("(")) => { + let e = self.expr()?; + self.expect(")")?; + Ok(e) + } + _ => { + self.pos -= 1; + self.err("expected a number, a name or `(`") + } + } + } + + fn integer(&mut self) -> Result { + let v = self.expr()?.eval(&HashMap::new()).or_else(|e| self.err(e))?; + if v < 0.0 || v.fract() != 0.0 || v > u32::MAX as f64 { + return self.err(format!("{v} is not a register size or index")); + } + Ok(v as u32) + } + + /// `name` or `name[i]`, as the qubits (or bits) it names. + fn operand(&mut self, bits: bool) -> Result, String> { + let name = self.ident()?; + let (start, len) = { + let regs = if bits { &self.cregs } else { &self.qregs }; + match regs.get(&name) { + Some(r) => *r, + None => return self.err(format!("no {} register named {name}", if bits { "bit" } else { "qubit" })), + } + }; + if self.eat("[") { + let i = self.integer()?; + self.expect("]")?; + if i >= len { + return self.err(format!("{name}[{i}] is outside {name}, which has {len}")); + } + Ok(vec![start + i]) + } else { + Ok((start..start + len).collect()) + } + } + + fn declare(&mut self, name: String, size: u32, bits: bool) -> Result<(), String> { + if self.qregs.contains_key(&name) || self.cregs.contains_key(&name) { + return self.err(format!("{name} is declared twice")); + } + if bits { + self.cregs.insert(name, (self.n_bits, size)); + self.n_bits = self.n_bits.checked_add(size).ok_or_else(|| "too many bits".to_string())?; + } else { + let total = self.n_qubits as u64 + size as u64; + if total > MAX_QUBITS as u64 { + return self.err(format!("{total} qubits; this service reads programs of at most {MAX_QUBITS}")); + } + self.qregs.insert(name, (self.n_qubits, size)); + self.n_qubits = total as u32; + self.measured_qubits.resize(self.n_qubits as usize, false); + self.touched.resize(self.n_qubits as usize, false); + } + Ok(()) + } + + fn statement(&mut self) -> Result<(), String> { + let line = self.line(); + let Some(tok) = self.next() else { return Ok(()) }; + let Tok::Ident(word) = tok else { + self.pos -= 1; + return self.err("expected a statement"); + }; + match word.as_str() { + "OPENQASM" => { + self.skip_to_semicolon(); + } + "include" => { + self.skip_to_semicolon(); + } + "qubit" | "bit" => { + let size = if self.eat("[") { + let n = self.integer()?; + self.expect("]")?; + n + } else { + 1 + }; + let name = self.ident()?; + if self.eat("=") { + return self.err("initialised declarations are not supported"); + } + self.expect(";")?; + self.declare(name, size, word == "bit")?; + } + "qreg" | "creg" => { + let name = self.ident()?; + self.expect("[")?; + let n = self.integer()?; + self.expect("]")?; + self.expect(";")?; + self.declare(name, n, word == "creg")?; + } + "gate" => self.definition()?, + "barrier" => self.skip_to_semicolon(), + "reset" => { + let qs = self.operand(false)?; + self.expect(";")?; + if qs.iter().any(|&q| self.touched[q as usize]) { + return Err(format!("line {line}: reset after a qubit's first gate is not supported (mid-circuit reset)")); + } + } + "measure" => { + let qs = self.operand(false)?; + let bs = if self.eat("->") { + self.operand(true)? + } else { + if !self.cregs.is_empty() { + return Err(format!("line {line}: `measure` without a target bit while bit registers are declared")); + } + qs.clone() + }; + self.expect(";")?; + self.measure(&qs, &bs, line)?; + } + "if" | "while" | "for" | "def" | "defcal" | "cal" | "box" | "let" | "const" | "input" | "output" | "ctrl" | "negctrl" | "inv" | "pow" | "delay" | "gphase" => { + return Err(format!("line {line}: `{word}` is not supported by this service")); + } + _ => { + // `c = measure q;` / `c[0] = measure q[0];`, or a gate call. + if self.cregs.contains_key(&word) && (self.is_sym("=") || self.is_sym("[")) { + self.pos -= 1; + let bs = self.operand(true)?; + self.expect("=")?; + match self.next() { + Some(Tok::Ident(m)) if m == "measure" => {} + _ => return Err(format!("line {line}: only measurement results may be assigned to bits")), + } + let qs = self.operand(false)?; + self.expect(";")?; + self.measure(&qs, &bs, line)?; + } else { + self.call(word, line)?; + } + } + } + Ok(()) + } + + fn measure(&mut self, qs: &[u32], bs: &[u32], line: usize) -> Result<(), String> { + if qs.len() != bs.len() { + return Err(format!("line {line}: {} qubits measured into {} bits", qs.len(), bs.len())); + } + for (&q, &b) in qs.iter().zip(bs) { + if self.measured_qubits[q as usize] { + return Err(format!("line {line}: qubit {q} is measured twice")); + } + self.measured_qubits[q as usize] = true; + self.measured.push((q, b)); + } + if self.cregs.is_empty() { + self.n_bits = self.n_bits.max(bs.iter().map(|&b| b + 1).max().unwrap_or(0)); + } + Ok(()) + } + + fn definition(&mut self) -> Result<(), String> { + let name = self.ident()?; + let mut params = Vec::new(); + if self.eat("(") && !self.eat(")") { + loop { + params.push(self.ident()?); + if self.eat(")") { + break; + } + self.expect(",")?; + } + } + let mut qubits = vec![self.ident()?]; + while self.eat(",") { + qubits.push(self.ident()?); + } + self.expect("{")?; + let mut body = Vec::new(); + while !self.eat("}") { + let line = self.line(); + let g = self.ident()?; + if g == "barrier" { + self.skip_to_semicolon(); + continue; + } + let mut ps = Vec::new(); + if self.eat("(") && !self.eat(")") { + loop { + ps.push(self.expr()?); + if self.eat(")") { + break; + } + self.expect(",")?; + } + } + let mut args = vec![self.ident()?]; + while self.eat(",") { + args.push(self.ident()?); + } + self.expect(";")?; + for a in &args { + if !qubits.contains(a) { + return Err(format!("line {line}: {a} is not a qubit of gate {name}")); + } + } + body.push(Call { name: g, params: ps, args, line }); + } + if builtin(&name).is_some() { + // A redefinition of a standard gate (as qelib1.inc carries) is + // the standard gate: the builtin is kept. + return Ok(()); + } + self.defs.insert(name, Def { params, qubits, body }); + Ok(()) + } + + fn call(&mut self, name: String, line: usize) -> Result<(), String> { + let mut params = Vec::new(); + if self.eat("(") && !self.eat(")") { + loop { + let e = self.expr()?; + params.push(e.eval(&HashMap::new()).map_err(|e| format!("line {line}: {e}"))?); + if self.eat(")") { + break; + } + self.expect(",")?; + } + } + if self.is_sym("@") { + return Err(format!("line {line}: gate modifiers are not supported")); + } + let mut operands = vec![self.operand(false)?]; + while self.eat(",") { + operands.push(self.operand(false)?); + } + self.expect(";")?; + // Broadcast over registers of one length. + let width = operands.iter().map(Vec::len).max().unwrap_or(1); + if operands.iter().any(|o| o.len() != 1 && o.len() != width) { + return Err(format!("line {line}: registers of different lengths in one gate call")); + } + for k in 0..width { + let qs: Vec = operands.iter().map(|o| if o.len() == 1 { o[0] } else { o[k] }).collect(); + self.apply(&name, ¶ms, &qs, line, 0)?; + } + Ok(()) + } + + fn apply(&mut self, name: &str, params: &[f64], qubits: &[u32], line: usize, depth: usize) -> Result<(), String> { + if depth > MAX_DEPTH { + return Err(format!("line {line}: gate definitions nest deeper than {MAX_DEPTH}")); + } + for (i, q) in qubits.iter().enumerate() { + if qubits[..i].contains(q) { + return Err(format!("line {line}: qubit {q} named twice in one gate")); + } + if self.measured_qubits[*q as usize] { + return Err(format!("line {line}: a gate on qubit {q} after its measurement is not supported (mid-circuit measurement)")); + } + } + if let Some((kind, given)) = builtin(name) { + let (np, nq) = kind.arity(); + if params.len() != given || qubits.len() != nq { + return Err(format!("line {line}: {name} takes {given} parameters and {nq} qubits")); + } + if params.iter().any(|p| !p.is_finite()) { + return Err(format!("line {line}: {name} has a parameter that is not finite")); + } + let params = normalise(kind, given, params.to_vec()); + debug_assert_eq!(params.len(), np); + if self.ops.len() >= MAX_OPS { + return Err(format!("line {line}: more than {MAX_OPS} gates")); + } + for &q in qubits { + self.touched[q as usize] = true; + } + self.ops.push(Op { kind, qubits: qubits.to_vec(), params }); + return Ok(()); + } + let Some(def) = self.defs.get(name).cloned() else { + return Err(format!("line {line}: unknown gate {name}")); + }; + if params.len() != def.params.len() || qubits.len() != def.qubits.len() { + return Err(format!("line {line}: {name} takes {} parameters and {} qubits", def.params.len(), def.qubits.len())); + } + let env: HashMap = def.params.iter().cloned().zip(params.iter().copied()).collect(); + let at: HashMap<&str, u32> = def.qubits.iter().map(String::as_str).zip(qubits.iter().copied()).collect(); + for c in &def.body { + let ps: Vec = c.params.iter().map(|e| e.eval(&env)).collect::>().map_err(|e| format!("line {}: {e}", c.line))?; + let qs: Vec = c.args.iter().map(|a| at[a.as_str()]).collect(); + self.apply(&c.name, &ps, &qs, c.line, depth + 1)?; + } + Ok(()) + } +} + +/// Parses an OpenQASM program. +pub fn parse(src: &str) -> Result { + let toks = lex(src)?; + let mut p = Parser { + toks, + pos: 0, + qregs: HashMap::new(), + cregs: HashMap::new(), + n_qubits: 0, + n_bits: 0, + defs: HashMap::new(), + ops: Vec::new(), + measured: Vec::new(), + measured_qubits: Vec::new(), + touched: Vec::new(), + }; + while p.pos < p.toks.len() { + p.statement()?; + } + if p.n_qubits == 0 { + return Err("the program declares no qubits".into()); + } + let (mut measured, mut n_bits) = (p.measured, p.n_bits); + if measured.is_empty() { + measured = (0..p.n_qubits).map(|q| (q, q)).collect(); + n_bits = p.n_qubits; + } + Ok(Program { n_qubits: p.n_qubits, ops: p.ops, measured, n_bits }) +} + +// --------------------------------------------------------------------------- +// Lowering +// --------------------------------------------------------------------------- + +/// `(sin x, cos x)` from the crate's own trigonometry (through a gate it +/// builds), so a matrix is the same bits on every machine. +fn sin_cos(x: f64) -> (f64, f64) { + let m = Matrix::ry(0, 2.0 * x).matrix; + (m[2].re, m[0].re) +} + +fn cis(x: f64) -> C { + Matrix::phase(0, x).matrix[3] +} + +fn mul(a: C, b: C) -> C { + complex(a.re * b.re - a.im * b.im, a.re * b.im + a.im * b.re) +} + +const O: C = C { re: 0.0, im: 0.0 }; +const ONE: C = C { re: 1.0, im: 0.0 }; + +/// `U(θ, φ, λ)`, OpenQASM 3's definition. +fn u_matrix(theta: f64, phi: f64, lambda: f64) -> [C; 4] { + let (s, c) = sin_cos(theta / 2.0); + let el = cis(lambda); + let ep = cis(phi); + [complex(c, 0.0), complex(-el.re * s, -el.im * s), complex(ep.re * s, ep.im * s), { + let e = cis(phi + lambda); + complex(e.re * c, e.im * c) + }] +} + +/// `[[I, 0], [0, U]]` with `controls` control qubits first (most significant). +fn controlled(u: &[C], controls: u32) -> Vec { + let d = (u.len() as f64).sqrt() as usize; + let dim = d << controls; + let mut m = vec![O; dim * dim]; + let off = dim - d; + for i in 0..off { + m[i * dim + i] = ONE; + } + for r in 0..d { + for c in 0..d { + m[(off + r) * dim + off + c] = u[r * d + c]; + } + } + m +} + +/// `exp(−iθ P⊗P/2)` for `P` given as its 2 × 2 matrix. +fn pair_rotation(p: [C; 4], theta: f64) -> Vec { + let (s, c) = sin_cos(theta / 2.0); + let mut m = vec![O; 16]; + for r in 0..4 { + for col in 0..4 { + let pp = mul(p[(r >> 1) * 2 + (col >> 1)], p[(r & 1) * 2 + (col & 1)]); + let mut v = complex(pp.im * s, -pp.re * s); // −i·s·(P⊗P) + if r == col { + v.re += c; + } + m[r * 4 + col] = v; + } + } + m +} + +fn one_qubit(kind: Kind, p: &[f64]) -> [C; 4] { + let g = |m: Matrix| [m.matrix[0], m.matrix[1], m.matrix[2], m.matrix[3]]; + match kind { + Kind::Id => [ONE, O, O, ONE], + Kind::X => g(Matrix::x(0)), + Kind::Y => g(Matrix::y(0)), + Kind::Z => g(Matrix::z(0)), + Kind::H => g(Matrix::h(0)), + Kind::S => g(Matrix::s(0)), + Kind::Sdg => g(Matrix::sdg(0)), + Kind::T => g(Matrix::t(0)), + Kind::Tdg => g(Matrix::tdg(0)), + Kind::Sx => [complex(0.5, 0.5), complex(0.5, -0.5), complex(0.5, -0.5), complex(0.5, 0.5)], + Kind::Sxdg => [complex(0.5, -0.5), complex(0.5, 0.5), complex(0.5, 0.5), complex(0.5, -0.5)], + Kind::Rx => g(Matrix::rx(0, p[0])), + Kind::Ry => g(Matrix::ry(0, p[0])), + Kind::Rz => g(Matrix::rz(0, p[0])), + Kind::P => g(Matrix::phase(0, p[0])), + Kind::U => u_matrix(p[0], p[1], p[2]), + _ => unreachable!("not a one-qubit gate"), + } +} + +impl Op { + /// The gate as a matrix on its qubits (the first the most significant). + pub fn matrix(&self) -> Matrix { + use Kind::*; + let q = self.qubits.clone(); + let p = &self.params; + let matrix = match self.kind { + Id | X | Y | Z | H | S | Sdg | T | Tdg | Sx | Sxdg | Rx | Ry | Rz | P | U => one_qubit(self.kind, p).to_vec(), + Cx => controlled(&one_qubit(X, &[]), 1), + Cy => controlled(&one_qubit(Y, &[]), 1), + Cz => controlled(&one_qubit(Z, &[]), 1), + Ch => controlled(&one_qubit(H, &[]), 1), + Cp => controlled(&one_qubit(P, p), 1), + Crx => controlled(&one_qubit(Rx, p), 1), + Cry => controlled(&one_qubit(Ry, p), 1), + Crz => controlled(&one_qubit(Rz, p), 1), + Cu => { + let g = cis(p[3]); + let u = u_matrix(p[0], p[1], p[2]).map(|x| mul(g, x)); + controlled(&u, 1) + } + Swap => Matrix::swap(0, 1).matrix, + Iswap => Matrix::iswap(0, 1).matrix, + Rxx => pair_rotation(one_qubit(X, &[]), p[0]), + Ryy => pair_rotation(one_qubit(Y, &[]), p[0]), + Rzz => Matrix::rzz(0, 1, p[0]).matrix, + Ccx => controlled(&one_qubit(X, &[]), 2), + Ccz => controlled(&one_qubit(Z, &[]), 2), + Cswap => controlled(&Matrix::swap(0, 1).matrix, 1), + }; + Matrix { qubits: q, matrix } + } +} + +/// `θ` as a whole number of quarter turns (π/2), modulo 4, if it is one +/// within 1e-12. +fn quarter(theta: f64) -> Option { + let q = theta / std::f64::consts::FRAC_PI_2; + let m = q.round(); + ((q - m).abs() <= 1e-12).then(|| (m as i64).rem_euclid(4) as u32) +} + +/// Appends `diag(1, i^m)` on `q`: a Z rotation by `m` quarter turns, up to a +/// global phase. +fn quarter_z(out: &mut Vec, q: u32, m: u32) { + match m % 4 { + 0 => {} + 1 => out.push(Clifford::S(q)), + 2 => out.push(Clifford::Z(q)), + _ => out.push(Clifford::Sdg(q)), + } +} + +/// Appends `crz(θ)` for `θ` a multiple of π: `P(−θ/2)` on the control and, +/// when `θ` is an odd multiple, `CZ`. +fn clifford_crz(out: &mut Vec, c: u32, t: u32, theta: f64) -> Option<()> { + let m = quarter(theta)?; + if m % 2 == 1 { + return None; + } + quarter_z(out, c, quarter(-theta / 2.0)?); + if m == 2 { + out.push(Clifford::CZ(c, t)); + } + Some(()) +} + +/// The tolerance, in turns, within which an angle reads as dyadic: far below +/// the error of any angle written as an expression in `pi`, and far below the +/// grid of `2^-32` turns, so an arbitrary angle almost never passes. +const DYADIC_TOLERANCE: f64 = 1e-14; + +/// `2πm/2^k` for the smallest `k ≤ 32` within [`DYADIC_TOLERANCE`] turns: +/// `m mod 2^k` scaled to denominator `2^32`. +fn dyadic(angle: f64) -> Option { + let t = (angle / std::f64::consts::TAU).rem_euclid(1.0); + for k in 0..=32u32 { + let scale = (1u64 << k) as f64; + let m = (t * scale).round(); + if (t * scale - m).abs() <= DYADIC_TOLERANCE * scale { + return Some(((m as u64) % (1u64 << k)) << (32 - k)); + } + } + None +} + +struct DyadicOut { + c: Dyadic, +} + +impl DyadicOut { + fn g(&mut self, base: BaseGate, controls: &[u32], target: u32, param: u16) { + self.c.ops.push(DyadicGate { base, controls: controls.iter().map(|&q| q as u8).collect(), target: target as u8, param }); + } + + /// `diag(1, e^{iλ})` on `target` under `controls`: one `P(k)` per set bit. + fn phase(&mut self, controls: &[u32], target: u32, lambda: f64) -> Result<(), String> { + let m = dyadic(lambda).ok_or_else(|| format!("the angle {lambda} is not a dyadic multiple of 2π (2πm/2^k, k ≤ 32)"))?; + for j in 0..32u16 { + if m >> j & 1 == 1 { + self.g(BaseGate::P, controls, target, 32 - j); + } + } + Ok(()) + } + + fn crz(&mut self, c: u32, t: u32, theta: f64) -> Result<(), String> { + self.phase(&[], c, -theta / 2.0)?; + self.phase(&[c], t, theta) + } + + fn rx(&mut self, q: u32, theta: f64) -> Result<(), String> { + self.g(BaseGate::H, &[], q, 0); + self.phase(&[], q, theta)?; + self.g(BaseGate::H, &[], q, 0); + Ok(()) + } + + fn ry(&mut self, q: u32, theta: f64) -> Result<(), String> { + self.g(BaseGate::Sdg, &[], q, 0); + self.rx(q, theta)?; + self.g(BaseGate::S, &[], q, 0); + Ok(()) + } + + fn cx(&mut self, c: u32, t: u32) { + self.g(BaseGate::X, &[c], t, 0); + } + + fn rzz(&mut self, a: u32, b: u32, theta: f64) -> Result<(), String> { + self.cx(a, b); + self.phase(&[], b, theta)?; + self.cx(a, b); + Ok(()) + } +} + +/// Appends `op` lowered to the rotation form. +fn rot(out: &mut RotCircuit, op: &Op) { + use Kind::*; + let q = &op.qubits; + let p = &op.params; + let r = |axis: Vec<(u32, Pauli)>, theta: f64| Rot::Rot { axis, theta }; + let crz = |out: &mut RotCircuit, c: u32, t: u32, theta: f64| { + out.push(r(vec![(t, Pauli::Z)], theta / 2.0)); + out.push(r(vec![(c, Pauli::Z), (t, Pauli::Z)], -theta / 2.0)); + }; + let ccx = |out: &mut RotCircuit, a: u32, b: u32, c: u32| { + let t = |out: &mut RotCircuit, x: u32, s: f64| { + out.push(r(vec![(x, Pauli::Z)], s * std::f64::consts::FRAC_PI_4)); + }; + out.push(Rot::H(c)); + out.push(Rot::CX(b, c)); + t(out, c, -1.0); + out.push(Rot::CX(a, c)); + t(out, c, 1.0); + out.push(Rot::CX(b, c)); + t(out, c, -1.0); + out.push(Rot::CX(a, c)); + t(out, b, 1.0); + t(out, c, 1.0); + out.push(Rot::H(c)); + out.push(Rot::CX(a, b)); + t(out, a, 1.0); + t(out, b, -1.0); + out.push(Rot::CX(a, b)); + }; + match op.kind { + Id => {} + X => { + out.push(Rot::X(q[0])); + } + Y => { + out.push(Rot::Y(q[0])); + } + Z => { + out.push(Rot::Z(q[0])); + } + H => { + out.push(Rot::H(q[0])); + } + S => { + out.push(Rot::S(q[0])); + } + Sdg => { + out.push(Rot::Sdg(q[0])); + } + Sx => { + out.push(Rot::SX(q[0])); + } + Sxdg => { + out.push(Rot::SXdg(q[0])); + } + T => { + out.push(r(vec![(q[0], Pauli::Z)], std::f64::consts::FRAC_PI_4)); + } + Tdg => { + out.push(r(vec![(q[0], Pauli::Z)], -std::f64::consts::FRAC_PI_4)); + } + Rx => { + out.rx(q[0], p[0]); + } + Ry => { + out.ry(q[0], p[0]); + } + Rz | P => { + out.rz(q[0], p[0]); + } + U => { + out.rz(q[0], p[2]); + out.ry(q[0], p[0]); + out.rz(q[0], p[1]); + } + Cx => { + out.push(Rot::CX(q[0], q[1])); + } + Cz => { + out.push(Rot::CZ(q[0], q[1])); + } + Cy => { + out.push(Rot::Sdg(q[1])); + out.push(Rot::CX(q[0], q[1])); + out.push(Rot::S(q[1])); + } + Ch => { + // qelib1.inc's definition. + let (a, b) = (q[0], q[1]); + out.push(Rot::H(b)); + out.push(Rot::Sdg(b)); + out.push(Rot::CX(a, b)); + out.push(Rot::H(b)); + out.push(r(vec![(b, Pauli::Z)], std::f64::consts::FRAC_PI_4)); + out.push(Rot::CX(a, b)); + out.push(r(vec![(b, Pauli::Z)], std::f64::consts::FRAC_PI_4)); + out.push(Rot::H(b)); + out.push(Rot::S(b)); + out.push(Rot::X(b)); + out.push(Rot::S(a)); + } + Cp => { + let (c, t) = (q[0], q[1]); + out.push(r(vec![(c, Pauli::Z)], p[0] / 2.0)); + out.push(r(vec![(t, Pauli::Z)], p[0] / 2.0)); + out.push(r(vec![(c, Pauli::Z), (t, Pauli::Z)], -p[0] / 2.0)); + } + Crz => crz(out, q[0], q[1], p[0]), + Crx => { + out.push(Rot::H(q[1])); + crz(out, q[0], q[1], p[0]); + out.push(Rot::H(q[1])); + } + Cry => { + out.push(Rot::Sdg(q[1])); + out.push(Rot::H(q[1])); + crz(out, q[0], q[1], p[0]); + out.push(Rot::H(q[1])); + out.push(Rot::S(q[1])); + } + Cu => { + let (c, t) = (q[0], q[1]); + let (theta, phi, lambda, gamma) = (p[0], p[1], p[2], p[3]); + crz(out, c, t, lambda); + out.push(Rot::Sdg(t)); + out.push(Rot::H(t)); + crz(out, c, t, theta); + out.push(Rot::H(t)); + out.push(Rot::S(t)); + crz(out, c, t, phi); + out.rz(c, gamma + (phi + lambda) / 2.0); + } + Swap => { + out.push(Rot::Swap(q[0], q[1])); + } + Iswap => { + out.push(r(vec![(q[0], Pauli::X), (q[1], Pauli::X)], -std::f64::consts::FRAC_PI_2)); + out.push(r(vec![(q[0], Pauli::Y), (q[1], Pauli::Y)], -std::f64::consts::FRAC_PI_2)); + } + Rxx => { + out.push(r(vec![(q[0], Pauli::X), (q[1], Pauli::X)], p[0])); + } + Ryy => { + out.push(r(vec![(q[0], Pauli::Y), (q[1], Pauli::Y)], p[0])); + } + Rzz => { + out.push(r(vec![(q[0], Pauli::Z), (q[1], Pauli::Z)], p[0])); + } + Ccx => ccx(out, q[0], q[1], q[2]), + Ccz => { + out.push(Rot::H(q[2])); + ccx(out, q[0], q[1], q[2]); + out.push(Rot::H(q[2])); + } + Cswap => { + out.push(Rot::CX(q[2], q[1])); + ccx(out, q[0], q[1], q[2]); + out.push(Rot::CX(q[2], q[1])); + } + } +} + +impl Program { + /// Every gate as a matrix: the input of the dense state vector, the + /// tensor networks and the noisy simulators (qubit 0 the most significant + /// bit of their index). + pub fn matrices(&self) -> Vec { + self.ops.iter().map(Op::matrix).collect() + } + + /// Every gate as a matrix on at most two qubits: the input of the matrix + /// product state. The three-qubit gates are decomposed exactly (Toffoli + /// into CX and T gates, as qelib1.inc writes it). + pub fn local_matrices(&self) -> Vec { + let op = |kind: Kind, qubits: &[u32]| Op { kind, qubits: qubits.to_vec(), params: vec![] }.matrix(); + let ccx = |out: &mut Vec, a: u32, b: u32, c: u32| { + use Kind::*; + for (k, q) in [ + (H, vec![c]), (Cx, vec![b, c]), (Tdg, vec![c]), (Cx, vec![a, c]), (T, vec![c]), (Cx, vec![b, c]), (Tdg, vec![c]), (Cx, vec![a, c]), + (T, vec![b]), (T, vec![c]), (H, vec![c]), (Cx, vec![a, b]), (T, vec![a]), (Tdg, vec![b]), (Cx, vec![a, b]), + ] { + out.push(op(k, &q)); + } + }; + let mut out = Vec::with_capacity(self.ops.len()); + for o in &self.ops { + let q = &o.qubits; + match o.kind { + Kind::Ccx => ccx(&mut out, q[0], q[1], q[2]), + Kind::Ccz => { + out.push(op(Kind::H, &[q[2]])); + ccx(&mut out, q[0], q[1], q[2]); + out.push(op(Kind::H, &[q[2]])); + } + Kind::Cswap => { + out.push(op(Kind::Cx, &[q[2], q[1]])); + ccx(&mut out, q[0], q[1], q[2]); + out.push(op(Kind::Cx, &[q[2], q[1]])); + } + _ => out.push(o.matrix()), + } + } + out + } + + /// The circuit as Pauli rotations and Cliffords: the input of sparse Pauli + /// dynamics. Exact up to a global phase, which no expectation value sees. + pub fn rotations(&self) -> RotCircuit { + let mut out = RotCircuit::new(self.n_qubits); + for op in &self.ops { + rot(&mut out, op); + } + out + } + + /// The circuit in the dyadic gate set: the input of the bit-exact state + /// vector and the stabilizer tableau. Exact up to a global phase; refused + /// when an angle is not `2πm/2^k` (`k ≤ 32`), or the program is wider than + /// the dyadic circuit holds (255 qubits). + pub fn dyadic(&self) -> Result { + if self.n_qubits > 255 { + return Err(format!("{} qubits; the dyadic circuit holds at most 255", self.n_qubits)); + } + let mut o = DyadicOut { c: Dyadic::new(self.n_qubits as u8) }; + for op in &self.ops { + let q = &op.qubits; + let p = &op.params; + use Kind::*; + match op.kind { + Id => {} + X => o.g(BaseGate::X, &[], q[0], 0), + Y => o.g(BaseGate::Y, &[], q[0], 0), + Z => o.g(BaseGate::Z, &[], q[0], 0), + H => o.g(BaseGate::H, &[], q[0], 0), + S => o.g(BaseGate::S, &[], q[0], 0), + Sdg => o.g(BaseGate::Sdg, &[], q[0], 0), + T => o.g(BaseGate::T, &[], q[0], 0), + Tdg => o.g(BaseGate::Tdg, &[], q[0], 0), + Sx | Sxdg => { + o.g(BaseGate::H, &[], q[0], 0); + o.g(if op.kind == Sx { BaseGate::S } else { BaseGate::Sdg }, &[], q[0], 0); + o.g(BaseGate::H, &[], q[0], 0); + } + Rx => o.rx(q[0], p[0])?, + Ry => o.ry(q[0], p[0])?, + Rz | P => o.phase(&[], q[0], p[0])?, + U => { + o.phase(&[], q[0], p[2])?; + o.ry(q[0], p[0])?; + o.phase(&[], q[0], p[1])?; + } + Cx => o.g(BaseGate::X, &[q[0]], q[1], 0), + Cy => o.g(BaseGate::Y, &[q[0]], q[1], 0), + Cz => o.g(BaseGate::Z, &[q[0]], q[1], 0), + Ch => o.g(BaseGate::H, &[q[0]], q[1], 0), + Cp => o.phase(&[q[0]], q[1], p[0])?, + Crz => o.crz(q[0], q[1], p[0])?, + Crx => { + o.g(BaseGate::H, &[], q[1], 0); + o.crz(q[0], q[1], p[0])?; + o.g(BaseGate::H, &[], q[1], 0); + } + Cry => { + o.g(BaseGate::Sdg, &[], q[1], 0); + o.g(BaseGate::H, &[], q[1], 0); + o.crz(q[0], q[1], p[0])?; + o.g(BaseGate::H, &[], q[1], 0); + o.g(BaseGate::S, &[], q[1], 0); + } + Cu => { + let (c, t) = (q[0], q[1]); + let (theta, phi, lambda, gamma) = (p[0], p[1], p[2], p[3]); + o.crz(c, t, lambda)?; + o.g(BaseGate::Sdg, &[], t, 0); + o.g(BaseGate::H, &[], t, 0); + o.crz(c, t, theta)?; + o.g(BaseGate::H, &[], t, 0); + o.g(BaseGate::S, &[], t, 0); + o.crz(c, t, phi)?; + o.phase(&[], c, gamma + (phi + lambda) / 2.0)?; + } + Swap => { + o.cx(q[0], q[1]); + o.cx(q[1], q[0]); + o.cx(q[0], q[1]); + } + Iswap => { + let (a, b) = (q[0], q[1]); + o.g(BaseGate::S, &[], a, 0); + o.g(BaseGate::S, &[], b, 0); + o.g(BaseGate::H, &[], a, 0); + o.cx(a, b); + o.cx(b, a); + o.g(BaseGate::H, &[], b, 0); + } + Rzz => o.rzz(q[0], q[1], p[0])?, + Rxx => { + o.g(BaseGate::H, &[], q[0], 0); + o.g(BaseGate::H, &[], q[1], 0); + o.rzz(q[0], q[1], p[0])?; + o.g(BaseGate::H, &[], q[0], 0); + o.g(BaseGate::H, &[], q[1], 0); + } + Ryy => { + o.g(BaseGate::Sdg, &[], q[0], 0); + o.g(BaseGate::Sdg, &[], q[1], 0); + o.g(BaseGate::H, &[], q[0], 0); + o.g(BaseGate::H, &[], q[1], 0); + o.rzz(q[0], q[1], p[0])?; + o.g(BaseGate::H, &[], q[0], 0); + o.g(BaseGate::H, &[], q[1], 0); + o.g(BaseGate::S, &[], q[0], 0); + o.g(BaseGate::S, &[], q[1], 0); + } + Ccx => o.g(BaseGate::X, &[q[0], q[1]], q[2], 0), + Ccz => o.g(BaseGate::Z, &[q[0], q[1]], q[2], 0), + Cswap => { + o.cx(q[2], q[1]); + o.g(BaseGate::X, &[q[0], q[1]], q[2], 0); + o.cx(q[2], q[1]); + } + } + } + Ok(o.c) + } + + /// The circuit as Clifford gates on the wide tableau, exact up to a global + /// phase; refused when a gate is not Clifford at its angles (rotations + /// must be whole quarter turns, controlled phases whole half turns). + pub fn clifford(&self) -> Result, String> { + use Kind::*; + let mut out = Vec::new(); + for op in &self.ops { + let q = &op.qubits; + let p = &op.params; + let not = || format!("{:?}{} is not a Clifford gate", op.kind, if p.is_empty() { String::new() } else { format!("({})", p.iter().map(|x| format!("{x}")).collect::>().join(", ")) }); + let qz = |x: f64| quarter(x).ok_or_else(not); + match op.kind { + Id => {} + X => out.push(Clifford::X(q[0])), + Y => out.push(Clifford::Y(q[0])), + Z => out.push(Clifford::Z(q[0])), + H => out.push(Clifford::H(q[0])), + S => out.push(Clifford::S(q[0])), + Sdg => out.push(Clifford::Sdg(q[0])), + Sx => out.push(Clifford::SX(q[0])), + Sxdg => out.push(Clifford::SXdg(q[0])), + T | Tdg | Ch | Ccx | Ccz | Cswap => return Err(not()), + Rz | P => quarter_z(&mut out, q[0], qz(p[0])?), + Rx => { + let m = qz(p[0])?; + out.push(Clifford::H(q[0])); + quarter_z(&mut out, q[0], m); + out.push(Clifford::H(q[0])); + } + Ry => { + let m = qz(p[0])?; + out.extend([Clifford::Sdg(q[0]), Clifford::H(q[0])]); + quarter_z(&mut out, q[0], m); + out.extend([Clifford::H(q[0]), Clifford::S(q[0])]); + } + U => { + let (theta, phi, lambda) = (qz(p[0])?, qz(p[1])?, qz(p[2])?); + quarter_z(&mut out, q[0], lambda); + out.extend([Clifford::Sdg(q[0]), Clifford::H(q[0])]); + quarter_z(&mut out, q[0], theta); + out.extend([Clifford::H(q[0]), Clifford::S(q[0])]); + quarter_z(&mut out, q[0], phi); + } + Cx => out.push(Clifford::CX(q[0], q[1])), + Cy => out.push(Clifford::CY(q[0], q[1])), + Cz => out.push(Clifford::CZ(q[0], q[1])), + Swap => out.push(Clifford::Swap(q[0], q[1])), + Iswap => { + let (a, b) = (q[0], q[1]); + out.extend([Clifford::S(a), Clifford::S(b), Clifford::H(a), Clifford::CX(a, b), Clifford::CX(b, a), Clifford::H(b)]); + } + Cp => match qz(p[0])? { + 0 => {} + 2 => out.push(Clifford::CZ(q[0], q[1])), + _ => return Err(not()), + }, + Crz => clifford_crz(&mut out, q[0], q[1], p[0]).ok_or_else(not)?, + Crx => { + out.push(Clifford::H(q[1])); + clifford_crz(&mut out, q[0], q[1], p[0]).ok_or_else(not)?; + out.push(Clifford::H(q[1])); + } + Cry => { + out.extend([Clifford::Sdg(q[1]), Clifford::H(q[1])]); + clifford_crz(&mut out, q[0], q[1], p[0]).ok_or_else(not)?; + out.extend([Clifford::H(q[1]), Clifford::S(q[1])]); + } + Cu => { + let (c, t) = (q[0], q[1]); + let (theta, phi, lambda, gamma) = (p[0], p[1], p[2], p[3]); + clifford_crz(&mut out, c, t, lambda).ok_or_else(not)?; + out.extend([Clifford::Sdg(t), Clifford::H(t)]); + clifford_crz(&mut out, c, t, theta).ok_or_else(not)?; + out.extend([Clifford::H(t), Clifford::S(t)]); + clifford_crz(&mut out, c, t, phi).ok_or_else(not)?; + quarter_z(&mut out, c, qz(gamma + (phi + lambda) / 2.0)?); + } + Rzz | Rxx | Ryy => { + let (a, b) = (q[0], q[1]); + let m = qz(p[0])?; + let (pre, post): (Vec, Vec) = match op.kind { + Rxx => (vec![Clifford::H(a), Clifford::H(b)], vec![Clifford::H(a), Clifford::H(b)]), + Ryy => (vec![Clifford::Sdg(a), Clifford::Sdg(b), Clifford::H(a), Clifford::H(b)], vec![Clifford::H(a), Clifford::H(b), Clifford::S(a), Clifford::S(b)]), + _ => (vec![], vec![]), + }; + out.extend(pre); + out.push(Clifford::CX(a, b)); + quarter_z(&mut out, b, m); + out.push(Clifford::CX(a, b)); + out.extend(post); + } + } + } + Ok(out) + } + + /// The classical outcome a measurement of the qubits gives, as the bit + /// string the API reports: bit `n_bits − 1` first, bits no measurement + /// writes `0`. `qubit(q)` reads qubit `q`'s measured value. + pub fn bitstring(&self, qubit: impl Fn(u32) -> bool) -> String { + let mut bits = vec![b'0'; self.n_bits as usize]; + for &(q, b) in &self.measured { + if qubit(q) { + bits[(self.n_bits - 1 - b) as usize] = b'1'; + } + } + String::from_utf8(bits).expect("ASCII") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registers_broadcast_and_measurement_forms_parse() { + let p = parse( + "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[3] q;\nqubit r;\nbit[4] c;\nh q;\ncx q[0], r; // a comment\n/* a\nblock */ rz(pi/4) q[2];\nc[0] = measure q[0];\nc[3] = measure r;\n", + ) + .unwrap(); + assert_eq!((p.n_qubits, p.n_bits), (4, 4)); + assert_eq!(p.ops.len(), 5); + assert_eq!(p.ops[3], Op { kind: Kind::Cx, qubits: vec![0, 3], params: vec![] }); + assert_eq!(p.ops[4].params, vec![std::f64::consts::FRAC_PI_4]); + assert_eq!(p.measured, vec![(0, 0), (3, 3)]); + assert_eq!(p.bitstring(|q| q == 3), "1000"); + let q2 = parse("OPENQASM 2.0;\ninclude \"qelib1.inc\";\nqreg q[2];\ncreg c[2];\nh q[0];\ncx q[0],q[1];\nmeasure q -> c;\n").unwrap(); + assert_eq!(q2.measured, vec![(0, 0), (1, 1)]); + let all = parse("qubit[2] q; x q[1];").unwrap(); + assert_eq!((all.measured.clone(), all.bitstring(|q| q == 1)), (vec![(0, 0), (1, 1)], "10".to_string())); + } + + #[test] + fn user_gates_inline_with_parameters_and_qelib_redefinitions_keep_the_builtin() { + let p = parse( + "qubit[2] q;\ngate cx a, b { U(0,0,0) a; }\ngate my(theta, phi) a, b { rz(theta/2) a; cx a, b; ry(-phi) b; }\nmy(pi, 2*pi/3) q[1], q[0];", + ) + .unwrap(); + assert_eq!(p.ops.len(), 3); + assert_eq!(p.ops[0].kind, Kind::Rz); + assert!((p.ops[0].params[0] - std::f64::consts::FRAC_PI_2).abs() < 1e-15); + assert_eq!(p.ops[1], Op { kind: Kind::Cx, qubits: vec![1, 0], params: vec![] }); + assert!((p.ops[2].params[0] + 2.0 * std::f64::consts::PI / 3.0).abs() < 1e-15); + } + + #[test] + fn what_is_not_supported_is_refused_with_its_line() { + for (src, needle) in [ + ("qubit[1] q;\nbit c;\nc = measure q;\nx q;", "line 4"), + ("qubit[1] q;\nif (c) x q;", "not supported"), + ("qubit[1] q;\nfoo q;", "unknown gate foo"), + ("qubit[1] q;\nrx q;", "rx takes 1 parameters"), + ("qubit[2] q;\ncx q[0], q[0];", "named twice"), + ("qubit[2] q;\nx q[0];\nreset q[0];", "mid-circuit reset"), + ("qubit[2] q;\nh q[2];", "outside q"), + ("bit c;", "no qubits"), + ] { + let e = parse(src).unwrap_err(); + assert!(e.contains(needle), "{src:?}: {e}"); + } + } + + #[test] + fn dyadic_angles_are_recognised() { + use std::f64::consts::PI; + assert_eq!(dyadic(PI / 2.0), Some(1 << 30)); + assert_eq!(dyadic(-PI / 4.0), Some((1u64 << 32) - (1 << 29))); + assert_eq!(dyadic(0.0), Some(0)); + assert_eq!(dyadic(0.3), None); + assert_eq!(dyadic(2.0 * PI), Some(0)); + } + + /// The circuits every lowering is checked on: every kind, at dyadic angles + /// (so the dyadic lowering applies). + fn every_kind(n: u32, seed: u64) -> Program { + use std::f64::consts::PI; + let mut s = seed; + let mut next = || { + s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + s >> 33 + }; + let kinds = [ + Kind::Id, Kind::X, Kind::Y, Kind::Z, Kind::H, Kind::S, Kind::Sdg, Kind::T, Kind::Tdg, Kind::Sx, Kind::Sxdg, Kind::Rx, Kind::Ry, Kind::Rz, Kind::P, Kind::U, Kind::Cx, Kind::Cy, + Kind::Cz, Kind::Ch, Kind::Cp, Kind::Crx, Kind::Cry, Kind::Crz, Kind::Cu, Kind::Swap, Kind::Iswap, Kind::Rxx, Kind::Ryy, Kind::Rzz, Kind::Ccx, Kind::Ccz, Kind::Cswap, + ]; + let mut ops = Vec::new(); + for round in 0..3 { + for &kind in &kinds { + let (np, nq) = kind.arity(); + let mut qubits: Vec = Vec::new(); + while qubits.len() < nq { + let q = (next() % n as u64) as u32; + if !qubits.contains(&q) { + qubits.push(q); + } + } + let params = (0..np).map(|_| (next() % 64) as f64 * PI / 16.0 - 2.0 * PI + round as f64 * PI / 32.0).collect(); + ops.push(Op { kind, qubits, params }); + } + } + Program { n_qubits: n, ops, measured: (0..n).map(|q| (q, q)).collect(), n_bits: n } + } + + fn dense(p: &Program) -> wai_quantum::quantum_sv::StateVector { + let mut sv = wai_quantum::quantum_sv::StateVector::zero(p.n_qubits).unwrap(); + sv.run(&p.matrices(), 1).unwrap(); + sv + } + + #[test] + fn the_matrices_are_unitary() { + for op in every_kind(4, 3).ops { + let m = op.matrix(); + let d = 1usize << m.qubits.len(); + for r in 0..d { + for c in 0..d { + let mut acc = complex(0.0, 0.0); + for k in 0..d { + let a = m.matrix[k * d + r]; + let b = m.matrix[k * d + c]; + acc.re += a.re * b.re + a.im * b.im; + acc.im += a.re * b.im - a.im * b.re; + } + let want = if r == c { 1.0 } else { 0.0 }; + assert!((acc.re - want).abs() < 1e-12 && acc.im.abs() < 1e-12, "{:?} is not unitary", op.kind); + } + } + } + } + + #[test] + fn the_dyadic_lowering_is_the_matrices_up_to_a_global_phase() { + for seed in 0..6 { + let p = every_kind(4, seed); + let want = dense(&p); + let c = p.dyadic().unwrap(); + let got = c.simulate().unwrap(); + // |⟨want|got⟩| = 1, the dyadic state's index having qubit 0 as its + // least significant bit and the dense one's as its most. + let n = p.n_qubits; + let (mut re, mut im) = (0.0, 0.0); + for x in 0..1usize << n { + let mut y = 0usize; + for q in 0..n { + if x >> q & 1 == 1 { + y |= 1 << (n - 1 - q); + } + } + let a = got.amps[x]; + let (ar, ai) = (a.re as f64 / (1u64 << 30) as f64, a.im as f64 / (1u64 << 30) as f64); + let (br, bi) = (want.re[y], want.im[y]); + re += br * ar + bi * ai; + im += br * ai - bi * ar; + } + let overlap = (re * re + im * im).sqrt(); + assert!((overlap - 1.0).abs() < 1e-6, "seed {seed}: overlap {overlap}"); + } + } + + /// Circuits of every kind, at angles where each is Clifford. + fn clifford_kinds(n: u32, seed: u64) -> Program { + use std::f64::consts::FRAC_PI_2; + let mut s = seed; + let mut next = || { + s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + s >> 33 + }; + let kinds = [ + Kind::Id, Kind::X, Kind::Y, Kind::Z, Kind::H, Kind::S, Kind::Sdg, Kind::Sx, Kind::Sxdg, Kind::Rx, Kind::Ry, Kind::Rz, Kind::P, Kind::U, Kind::Cx, Kind::Cy, Kind::Cz, Kind::Cp, + Kind::Crx, Kind::Cry, Kind::Crz, Kind::Cu, Kind::Swap, Kind::Iswap, Kind::Rxx, Kind::Ryy, Kind::Rzz, + ]; + let mut ops = Vec::new(); + for _ in 0..4 { + for &kind in &kinds { + let (np, nq) = kind.arity(); + let mut qubits: Vec = Vec::new(); + while qubits.len() < nq { + let q = (next() % n as u64) as u32; + if !qubits.contains(&q) { + qubits.push(q); + } + } + // Controlled phases and controlled rotations need whole half + // turns; the rest, quarter turns. + let half = matches!(kind, Kind::Cp | Kind::Crx | Kind::Cry | Kind::Crz | Kind::Cu); + let params = (0..np).map(|_| (next() % 8) as f64 * FRAC_PI_2 * if half { 2.0 } else { 1.0 } - 4.0 * FRAC_PI_2).collect(); + ops.push(Op { kind, qubits, params }); + } + } + Program { n_qubits: n, ops, measured: (0..n).map(|q| (q, q)).collect(), n_bits: n } + } + + #[test] + fn the_clifford_lowering_gives_the_matrices_expectation_values() { + use wai_quantum::quantum_tableau::Tableau; + for seed in 0..12 { + let p = clifford_kinds(5, seed); + let sv = dense(&p); + let t = Tableau::run(p.n_qubits, &p.clifford().unwrap()).unwrap(); + let mut r = seed; + for _ in 0..30 { + r = r.wrapping_mul(6364136223846793005).wrapping_add(1); + let string: Vec<(u32, char)> = (0..5).filter_map(|q| match (r >> (33 + 2 * q)) % 4 { 0 => None, 1 => Some((q as u32, 'X')), 2 => Some((q as u32, 'Y')), _ => Some((q as u32, 'Z')) }).collect(); + let want = sv.expectation(&[(1.0, string.clone())]); + let got = t.expectation(&string).unwrap() as f64; + assert!((got - want).abs() < 1e-9, "seed {seed} {string:?}: {got} vs {want}"); + } + } + // What is not Clifford is refused, with what. + let t = parse("qubit[2] q; h q[0]; t q[1];").unwrap(); + assert!(t.clifford().unwrap_err().contains("T is not a Clifford gate")); + let r = parse("qubit[1] q; rz(0.3) q[0];").unwrap(); + assert!(r.clifford().unwrap_err().contains("Rz(0.3) is not a Clifford gate")); + assert!(parse("qubit[2] q; cp(pi/2) q[0], q[1];").unwrap().clifford().is_err()); + assert!(parse("qubit[2] q; cp(pi) q[0], q[1]; rx(pi/2) q[0];").unwrap().clifford().is_ok()); + } + + #[test] + fn the_local_lowering_is_the_matrices_up_to_a_global_phase() { + for seed in 0..6 { + let p = every_kind(4, seed + 300); + let want = dense(&p); + let mut got = wai_quantum::quantum_sv::StateVector::zero(p.n_qubits).unwrap(); + let local = p.local_matrices(); + assert!(local.iter().all(|g| g.qubits.len() <= 2)); + got.run(&local, 1).unwrap(); + let (mut re, mut im) = (0.0, 0.0); + for x in 0..1usize << p.n_qubits { + re += want.re[x] * got.re[x] + want.im[x] * got.im[x]; + im += want.re[x] * got.im[x] - want.im[x] * got.re[x]; + } + assert!(((re * re + im * im).sqrt() - 1.0).abs() < 1e-9, "seed {seed}"); + } + } + + #[test] + fn the_rotation_lowering_gives_the_matrices_expectation_values() { + use wai_quantum::quantum_spd::{SpdConfig, expectation}; + for seed in 0..6 { + let p = every_kind(4, seed + 100); + let sv = dense(&p); + let rc = p.rotations(); + let cfg = SpdConfig { threshold: 0.0, max_weight: None }; + for (k, string) in [vec![(0u32, 'Z')], vec![(1, 'X'), (2, 'Y')], vec![(0, 'Y'), (3, 'Z'), (1, 'X')], vec![(2, 'X'), (3, 'X')]].into_iter().enumerate() { + let want = sv.expectation(&[(1.0, string.clone())]); + let obs: Vec<(u32, Pauli)> = string.iter().map(|&(q, c)| (q, match c { 'X' => Pauli::X, 'Y' => Pauli::Y, _ => Pauli::Z })).collect(); + let got = expectation(&rc, &[(1.0, obs)], &[], &cfg).unwrap(); + assert!((got.value - want).abs() < 1e-9, "seed {seed} observable {k}: {} vs {want}", got.value); + } + } + } +} diff --git a/wai/wai-quantum-service/src/receipt.rs b/wai/wai-quantum-service/src/receipt.rs new file mode 100644 index 000000000..a10f7e5be --- /dev/null +++ b/wai/wai-quantum-service/src/receipt.rs @@ -0,0 +1,143 @@ +//! Signed receipts: every finished job's result is sealed with the service's +//! Ed25519 key, binding the request as submitted and the result as returned, +//! so a client can show anyone what was run and what came back. + +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use wai_quantum::ed25519_dalek::{Signer as _, SigningKey, Verifier as _, VerifyingKey}; + +const DOMAIN: &[u8] = b"wai:quantum-service-receipt\x01"; + +/// The service's signing key. +pub struct Signer { + key: SigningKey, +} + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn unhex(s: &str) -> Option> { + s.len().is_multiple_of(2).then_some(())?; + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect() +} + +/// A length-prefixed byte string, so that no field reads as part of another. +fn put(o: &mut Vec, bytes: &[u8]) { + o.extend_from_slice(&(bytes.len() as u64).to_be_bytes()); + o.extend_from_slice(bytes); +} + +/// The digest a receipt signs: BLAKE3(domain ‖ put(request) ‖ put(result)), +/// each the compact JSON text serde_json writes (keys in insertion order, as +/// the service builds them and returns them). +pub fn digest(request: &Value, result: &Value) -> [u8; 32] { + let mut o = DOMAIN.to_vec(); + put(&mut o, request.to_string().as_bytes()); + put(&mut o, result.to_string().as_bytes()); + *blake3::hash(&o).as_bytes() +} + +impl Signer { + /// The key at `path`, created (32 random bytes, readable by the owner + /// only) when there is none. + pub fn load_or_create(path: &Path) -> std::io::Result { + if let Ok(bytes) = std::fs::read(path) { + let seed: [u8; 32] = bytes.as_slice().try_into().map_err(|_| std::io::Error::other(format!("{} is not a 32-byte key", path.display())))?; + return Ok(Signer { key: SigningKey::from_bytes(&seed) }); + } + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).map_err(|e| std::io::Error::other(e.to_string()))?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + write_private(path, &seed)?; + Ok(Signer { key: SigningKey::from_bytes(&seed) }) + } + + /// A key for tests. + #[cfg(test)] + pub fn from_seed(seed: [u8; 32]) -> Signer { + Signer { key: SigningKey::from_bytes(&seed) } + } + + /// The public key, in hex. + pub fn public_key(&self) -> String { + hex(self.key.verifying_key().as_bytes()) + } + + /// The receipt for `result` of `request`. + pub fn seal(&self, request: &Value, result: &Value) -> Value { + let d = digest(request, result); + let sig = self.key.sign(&d); + json!({ "alg": "ed25519", "key": self.public_key(), "digest": hex(&d), "signature": hex(&sig.to_bytes()) }) + } +} + +/// Whether `receipt` is a valid seal of `result` of `request` under the key +/// it names. +pub fn verify(request: &Value, result: &Value, receipt: &Value) -> bool { + let field = |k: &str| receipt.get(k).and_then(Value::as_str).and_then(unhex); + let (Some(key), Some(sig)) = (field("key"), field("signature")) else { return false }; + let (Ok(key), Ok(sig)) = (<[u8; 32]>::try_from(key.as_slice()), <[u8; 64]>::try_from(sig.as_slice())) else { return false }; + let Ok(vk) = VerifyingKey::from_bytes(&key) else { return false }; + let d = digest(request, result); + field("digest").is_some_and(|g| g == d) && vk.verify(&d, &wai_quantum::ed25519_dalek::Signature::from_bytes(&sig)).is_ok() +} + +#[cfg(unix)] +fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path)?; + f.write_all(bytes) +} + +#[cfg(not(unix))] +fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + std::fs::write(path, bytes) +} + +/// `WAI_QSVC_KEY`, or `~/.config/wai-quantum-service/signing.key`. +pub fn default_path() -> PathBuf { + if let Some(p) = std::env::var_os("WAI_QSVC_KEY") { + return PathBuf::from(p); + } + let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(".")); + home.join(".config/wai-quantum-service/signing.key") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_receipt_verifies_and_any_change_breaks_it() { + let s = Signer::from_seed([7; 32]); + let req = json!({"qasm": "OPENQASM 3.0;", "shots": 10}); + let res = json!({"method": "statevector", "counts": {"0": 10}}); + let r = s.seal(&req, &res); + assert!(verify(&req, &res, &r)); + assert!(!verify(&req, &json!({"method": "statevector", "counts": {"0": 9}}), &r)); + assert!(!verify(&json!({"qasm": "OPENQASM 3.0;", "shots": 11}), &res, &r)); + let mut forged = r.clone(); + forged["key"] = json!(Signer::from_seed([8; 32]).public_key()); + assert!(!verify(&req, &res, &forged)); + } + + #[test] + fn a_key_is_created_once_private_and_then_reused() { + let dir = std::env::temp_dir().join(format!("wai-qsvc-key-{}", std::process::id())); + let path = dir.join("k"); + let _ = std::fs::remove_dir_all(&dir); + let a = Signer::load_or_create(&path).unwrap(); + let b = Signer::load_or_create(&path).unwrap(); + assert_eq!(a.public_key(), b.public_key()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + } + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/wai/wai-quantum-service/src/run.rs b/wai/wai-quantum-service/src/run.rs new file mode 100644 index 000000000..d6a59de41 --- /dev/null +++ b/wai/wai-quantum-service/src/run.rs @@ -0,0 +1,544 @@ +//! The worker: one job, run by its plan's method, its answers in the API's +//! terms. Bit strings are the program's classical bits, bit `n_bits − 1` +//! first; an amplitude's bit string names every qubit, qubit `n − 1` first. + +use crate::catalog::{self, MAX_OUTCOMES, Plan, Request}; +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::time::Instant; + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +/// The most characters of bit strings a counts map lists. +const MAX_COUNTS_CHARS: usize = 16 << 20; + +/// A counts map: the most frequent outcomes (at most [`MAX_OUTCOMES`], and +/// at most [`MAX_COUNTS_CHARS`] characters of bit strings), and how many +/// shots the omitted ones held. +fn counts(map: HashMap) -> Value { + let mut v: Vec<(String, u64)> = map.into_iter().collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + let width = v.first().map(|x| x.0.len()).unwrap_or(1).max(1); + let keep = MAX_OUTCOMES.min(MAX_COUNTS_CHARS / width).max(1); + let omitted: u64 = v.iter().skip(keep).map(|x| x.1).sum(); + let distinct = v.len(); + v.truncate(keep); + let mut o = serde_json::Map::new(); + for (k, c) in v { + o.insert(k, json!(c)); + } + json!({ "counts": o, "distinct_outcomes": distinct, "omitted_shots": omitted }) +} + +/// The basis index an amplitude's bit string names, with qubit `q` at bit +/// `pos(q)` of the index. +fn index_of(bits: &str, n: u32, pos: impl Fn(u32) -> u32) -> usize { + let b = bits.as_bytes(); + let mut x = 0usize; + for q in 0..n { + if b[(n - 1 - q) as usize] == b'1' { + x |= 1 << pos(q); + } + } + x +} + +/// `⟨ψ|P|ψ⟩` on the bit-exact state, exactly: the fixed-point amplitudes +/// multiplied and summed in 128-bit integers, then scaled once. +fn expect_fixed(amps: &[wai_quantum::quantum::Amp], string: &[(u32, char)]) -> f64 { + let (mut flip, mut zmask, mut ys) = (0usize, 0usize, 0u32); + for &(q, p) in string { + let bit = 1usize << q; + match p { + 'X' => flip |= bit, + 'Y' => { + flip |= bit; + zmask |= bit; + ys += 1; + } + _ => zmask |= bit, + } + } + let (mut re, mut im) = (0i128, 0i128); + for (x, a) in amps.iter().enumerate() { + let b = amps[x ^ flip]; + let sign: i128 = if (x & zmask).count_ones() % 2 == 0 { 1 } else { -1 }; + // conj(ψ[x⊕flip]) · ψ[x] + let pr = b.re as i128 * a.re as i128 + b.im as i128 * a.im as i128; + let pi = b.re as i128 * a.im as i128 - b.im as i128 * a.re as i128; + re += sign * pr; + im += sign * pi; + } + // times i^{ys}: the real part. + let v = match ys % 4 { + 0 => re, + 1 => -im, + 2 => -re, + _ => im, + }; + v as f64 / (1u128 << 60) as f64 +} + +fn expectation_entries(r: &Request, value: impl Fn(&[(u32, char)]) -> f64) -> Value { + Value::Array(r.observables.iter().map(|(text, coeff, string)| json!({ "observable": text, "coeff": coeff, "value": coeff * value(string) })).collect()) +} + +/// Runs `plan` for the job `request`: the result, or why it failed. +pub fn run(request: &Value, plan: &Plan) -> Result { + let r = catalog::read(request)?; + let started = Instant::now(); + let n = r.program.n_qubits; + let mut out = json!({ + "method": plan.method, + "qubits": n, + "gates": r.program.ops.len(), + }); + match plan.method.as_str() { + "exact" => { + let c = r.program.dyadic()?; + let sv = c.simulate().map_err(|e| e.to_string())?; + out["accuracy"] = json!({ "class": "bit-exact" }); + out["state_hash"] = json!(hex(&sv.statevector_hash())); + if r.shots > 0 { + let per_index = sv.sample_shots(r.seed, r.shots); + let mut map: HashMap = HashMap::new(); + for (x, &k) in per_index.iter().enumerate() { + if k > 0 { + *map.entry(r.program.bitstring(|q| x >> q & 1 == 1)).or_insert(0) += k; + } + } + merge(&mut out, counts(map)); + } + if !r.observables.is_empty() { + out["expectations"] = expectation_entries(&r, |s| expect_fixed(&sv.amps, s)); + } + if !r.amplitudes.is_empty() { + let scale = (1u64 << 30) as f64; + out["amplitudes"] = Value::Array( + r.amplitudes + .iter() + .map(|b| { + let a = sv.amps[index_of(b, n, |q| q)]; + json!({ "bits": b, "re": a.re as f64 / scale, "im": a.im as f64 / scale }) + }) + .collect(), + ); + } + } + "statevector" => { + let gates = r.program.matrices(); + let width = r.options.get("fusion").and_then(Value::as_u64).unwrap_or(3).clamp(1, 5) as usize; + let mut sv = wai_quantum::quantum_sv::StateVector::zero(n).map_err(|e| format!("{e:?}"))?; + sv.run(&gates, width).map_err(|e| format!("{e:?}"))?; + out["accuracy"] = json!({ "class": "exact", "fusion": width }); + if r.shots > 0 { + let mut map: HashMap = HashMap::new(); + for x in sv.sample(r.shots as usize, r.seed) { + *map.entry(r.program.bitstring(|q| x >> (n - 1 - q) & 1 == 1)).or_insert(0) += 1; + } + merge(&mut out, counts(map)); + } + if !r.observables.is_empty() { + out["expectations"] = expectation_entries(&r, |s| sv.expectation(&[(1.0, s.to_vec())])); + } + if !r.amplitudes.is_empty() { + out["amplitudes"] = Value::Array( + r.amplitudes + .iter() + .map(|b| { + let a = sv.amplitude(index_of(b, n, |q| n - 1 - q)); + json!({ "bits": b, "re": a.re, "im": a.im }) + }) + .collect(), + ); + } + } + "stabilizer" => { + use wai_quantum::quantum_tableau::Tableau; + let t = Tableau::run(n, &r.program.clifford()?).map_err(|e| e.to_string())?; + out["accuracy"] = json!({ "class": "exact" }); + if r.shots > 0 { + let support = t.support(); + let measured: Vec = r.program.measured.iter().map(|&(q, _)| q).collect(); + let sampler = support.sampler(&measured); + let nb = r.program.n_bits as usize; + let mut map: HashMap = HashMap::new(); + for (bits, k) in sampler.counts(r.shots, r.seed) { + let mut s = vec![b'0'; nb]; + for (j, &(_, b)) in r.program.measured.iter().enumerate() { + if bits[j / 64] >> (j % 64) & 1 == 1 { + s[nb - 1 - b as usize] = b'1'; + } + } + *map.entry(String::from_utf8(s).expect("ASCII")).or_insert(0) += k; + } + merge(&mut out, counts(map)); + out["outcome_dimension"] = json!(support.dimension()); + } + if !r.observables.is_empty() { + let mut entries = Vec::new(); + for (text, coeff, string) in &r.observables { + let v = t.expectation(string).map_err(|e| e.to_string())?; + entries.push(json!({ "observable": text, "coeff": coeff, "value": coeff * v as f64 })); + } + out["expectations"] = Value::Array(entries); + } + } + "pauli" => { + use wai_quantum::quantum_spd::{Pauli, SpdConfig, expectation}; + let rc = r.program.rotations(); + let threshold = r.options.get("threshold").and_then(Value::as_f64).unwrap_or(1e-5).clamp(0.0, 1.0); + let max_weight = r.options.get("max_weight").and_then(Value::as_u64).map(|w| w as u32); + // The ladder: the same value at up to three thresholds a decade + // apart above the one asked for, so a reader sees it settle. + let ladder = r.options.get("ladder").and_then(Value::as_bool).unwrap_or(true) && threshold > 0.0; + let rungs: Vec = if ladder { [1000.0, 100.0, 10.0].iter().map(|f| threshold * f).filter(|&t| t < 1.0).chain([threshold]).collect() } else { vec![threshold] }; + out["accuracy"] = json!({ "class": "bounded", "threshold": threshold, "max_weight": max_weight }); + let mut entries = Vec::new(); + for (text, coeff, string) in &r.observables { + let obs: Vec<(u32, Pauli)> = string.iter().map(|&(q, c)| (q, match c { 'X' => Pauli::X, 'Y' => Pauli::Y, _ => Pauli::Z })).collect(); + let mut steps = Vec::new(); + let mut last = None; + for &t in &rungs { + let res = expectation(&rc, &[(*coeff, obs.clone())], &[], &SpdConfig { threshold: t, max_weight }).map_err(|e| e.to_string())?; + steps.push(json!({ "threshold": t, "value": res.value, "bound": res.truncation_bound, "peak_terms": res.peak_terms })); + last = Some(res); + } + let res = last.expect("one rung at least"); + let mut entry = json!({ "observable": text, "coeff": coeff, "value": res.value, "bound": res.truncation_bound, "peak_terms": res.peak_terms }); + if steps.len() > 1 { + entry["ladder"] = Value::Array(steps); + } + entries.push(entry); + } + out["expectations"] = Value::Array(entries); + } + "mps" => { + use wai_quantum::quantum_mpsim::{Chain, MpsConfig}; + let chi = catalog::mps_bond(&r); + let cutoff = r.options.get("cutoff").and_then(Value::as_f64).unwrap_or(1e-12).clamp(0.0, 1e-2); + let mut chain = Chain::run(n, &r.program.local_matrices(), MpsConfig { max_bond: chi, cutoff }).map_err(|e| e.to_string())?; + let rep = chain.report(); + out["accuracy"] = if rep.truncations == 0 { + json!({ "class": "exact", "max_bond": chi, "max_bond_used": rep.max_bond_used, "note": "no truncation dropped weight beyond the cutoff's rounding" }) + } else { + json!({ "class": "heuristic", "max_bond": chi, "max_bond_used": rep.max_bond_used, "discarded_weight": rep.discarded, "fidelity_estimate": rep.fidelity_estimate, "truncations": rep.truncations }) + }; + if rep.truncations > 0 && rep.fidelity_estimate < 0.9 { + out["warning"] = json!(format!("the truncation dropped weight: the fidelity estimate is {:.3}; a larger max_bond may help, or the circuit may be too entangled for this method", rep.fidelity_estimate)); + } + if r.shots > 0 { + let mut map: HashMap = HashMap::new(); + for bits in chain.sample(r.shots, r.seed) { + *map.entry(r.program.bitstring(|q| bits[q as usize] == 1)).or_insert(0) += 1; + } + merge(&mut out, counts(map)); + } + if !r.observables.is_empty() { + let mut entries = Vec::new(); + for (text, coeff, string) in &r.observables { + let v = chain.expectation(string).map_err(|e| e.to_string())?; + entries.push(json!({ "observable": text, "coeff": coeff, "value": coeff * v })); + } + out["expectations"] = Value::Array(entries); + } + if !r.amplitudes.is_empty() { + let mut entries = Vec::new(); + for b in &r.amplitudes { + let bits: Vec = (0..n).map(|q| b.as_bytes()[(n - 1 - q) as usize] - b'0').collect(); + let a = chain.amplitude(&bits).map_err(|e| e.to_string())?; + entries.push(json!({ "bits": b, "re": a.re, "im": a.im })); + } + out["amplitudes"] = Value::Array(entries); + } + } + "tensor_network" => { + use wai_quantum::quantum_tn::{Network, PathOptions, contract_plan, find_path}; + let gates = r.program.matrices(); + out["accuracy"] = json!({ "class": "exact" }); + let mut entries = Vec::new(); + let mut flops = 0.0; + for b in &r.amplitudes { + // qubit q's bit, in qubit order. + let bits: Vec = (0..n).map(|q| b.as_bytes()[(n - 1 - q) as usize] - b'0').collect(); + let mut net = Network::amplitude(n, &gates, &bits, &[]).map_err(|e| format!("{e:?}"))?; + net.simplify(); + let plan = find_path(&net.shape(), &net.dims, &net.open, &PathOptions::default()); + flops += plan.total_flops(); + let t = contract_plan(&net, &plan).map_err(|e| format!("{e:?}"))?; + let a = t.data.first().copied().unwrap_or(wai_quantum::gates::complex(0.0, 0.0)); + entries.push(json!({ "bits": b, "re": a.re, "im": a.im })); + } + out["amplitudes"] = Value::Array(entries); + out["contraction_flops"] = json!(flops); + } + "density_matrix" => { + use wai_quantum::quantum_noisy::DensityMatrix; + let noise = r.noise.ok_or("no noise model")?; + let rho = DensityMatrix::run(&r.program.matrices(), n, &noise).map_err(|e| format!("{e:?}"))?; + out["accuracy"] = json!({ "class": "exact", "note": "the noisy state itself, under the model; counts are seeded samples of its distribution" }); + if r.shots > 0 { + let p = rho.probabilities(noise.readout); + let mut map: HashMap = HashMap::new(); + for x in sample_distribution(&p, r.shots, r.seed) { + *map.entry(r.program.bitstring(|q| x >> (n - 1 - q) & 1 == 1)).or_insert(0) += 1; + } + merge(&mut out, counts(map)); + } + if !r.observables.is_empty() { + out["expectations"] = expectation_entries(&r, |s| rho.expectation(&[(1.0, s.to_vec())])); + } + } + "trajectories" => { + use wai_quantum::quantum_noisy::branching_trajectories; + let noise = r.noise.ok_or("no noise model")?; + let count = catalog::trajectory_count(&r); + // The plan reserved memory for this many states. + let states = ((plan.memory_bytes.saturating_sub(256 << 20)) / (catalog::amplitude_bytes(n) * if n < 16 { 8 } else { 1 }).max(1)).clamp(1, 16) as usize; + let threads = std::thread::available_parallelism().map_or(1, |t| t.get()).min(8); + let observables: Vec = r.observables.iter().map(|(_, c, s)| vec![(*c, s.clone())]).collect(); + let run = branching_trajectories(&r.program.matrices(), n, &noise, &observables, count, r.seed, states, threads).map_err(|e| format!("{e:?}"))?; + out["accuracy"] = json!({ + "class": "estimate", + "trajectories": count, + "histories": run.histories, + "gate_applications": run.gate_applications, + "note": "each shot is an independent noisy trajectory in law; shots that took the same errors share one state (histories counts the distinct ones); expectations are means over the trajectories with their standard errors" + }); + if !r.observables.is_empty() { + out["expectations"] = Value::Array( + r.observables + .iter() + .zip(&run.expectations) + .map(|((text, coeff, _), e)| json!({ "observable": text, "coeff": coeff, "value": e.mean, "stderr": e.stderr })) + .collect(), + ); + } + if r.shots > 0 { + let mut map: HashMap = HashMap::new(); + for &(x, k) in &run.counts { + *map.entry(r.program.bitstring(|q| x >> (n - 1 - q) & 1 == 1)).or_insert(0) += k; + } + merge(&mut out, counts(map)); + out["shots"] = json!(count); + } + } + other => return Err(format!("unknown method {other}")), + } + out["elapsed_ms"] = json!(started.elapsed().as_millis() as u64); + Ok(out) +} + +fn merge(out: &mut Value, extra: Value) { + if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) { + for (k, v) in e { + o.insert(k.clone(), v.clone()); + } + } +} + +/// `shots` seeded draws from a probability vector (sorted uniforms swept +/// once through the cumulative distribution, as the state vector samples). +fn sample_distribution(p: &[f64], shots: u64, seed: u64) -> Vec { + let mut s = seed; + let mut u: Vec = (0..shots) + .map(|_| { + s = s.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = s; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + (((z ^ (z >> 31)) >> 11) as f64 + 0.5) / 9_007_199_254_740_992.0 + }) + .collect(); + u.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let total: f64 = p.iter().sum(); + let mut out = Vec::with_capacity(shots as usize); + let (mut cum, mut k) = (0.0, 0usize); + for (x, &px) in p.iter().enumerate() { + cum += px / total; + while k < u.len() && (u[k] < cum || x == p.len() - 1) { + out.push(x as u64); + k += 1; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::catalog::plan; + + const CAP: u64 = 8 << 30; + + fn go(job: Value) -> Value { + let p = plan(&job, CAP).unwrap(); + run(&job, &p).unwrap() + } + + #[test] + fn a_bell_pair_is_the_same_on_every_method_that_takes_it() { + let qasm = "OPENQASM 3.0; qubit[2] q; bit[2] c; h q[0]; cx q[0], q[1]; c = measure q;"; + for method in ["stabilizer", "exact", "statevector"] { + let r = go(json!({ "qasm": qasm, "method": method, "shots": 1000, "seed": 3 })); + let c = &r["counts"]; + let (a, b) = (c["00"].as_u64().unwrap_or(0), c["11"].as_u64().unwrap_or(0)); + assert_eq!(a + b, 1000, "{method}: {r}"); + assert!(a > 400 && b > 400, "{method}: {r}"); + assert!(c.get("01").is_none() && c.get("10").is_none(), "{method}: {r}"); + } + for method in ["exact", "statevector", "pauli"] { + let r = go(json!({ "qasm": qasm, "method": method, "observables": ["Z0 Z1", "X0 X1", "Y0 Y1", "Z0"] })); + let v: Vec = r["expectations"].as_array().unwrap().iter().map(|e| e["value"].as_f64().unwrap()).collect(); + for (got, want) in v.iter().zip([1.0, 1.0, -1.0, 0.0]) { + assert!((got - want).abs() < 1e-8, "{method}: {v:?}"); + } + } + } + + #[test] + fn bit_strings_put_the_highest_bit_first() { + let r = go(json!({ "qasm": "qubit[3] q; x q[0];", "method": "statevector", "shots": 10 })); + assert_eq!(r["counts"]["001"], json!(10), "{r}"); + let r = go(json!({ "qasm": "qubit[3] q; x q[0];", "method": "exact", "shots": 10 })); + assert_eq!(r["counts"]["001"], json!(10), "{r}"); + let r = go(json!({ "qasm": "qubit[3] q; x q[0];", "method": "stabilizer", "shots": 10 })); + assert_eq!(r["counts"]["001"], json!(10), "{r}"); + // Measured into bits of other indices, the bits follow. + let r = go(json!({ "qasm": "qubit[3] q; bit[4] c; x q[0]; c[3] = measure q[0]; c[1] = measure q[2];", "method": "stabilizer", "shots": 10 })); + assert_eq!(r["counts"]["1000"], json!(10), "{r}"); + // An amplitude names every qubit, the highest first. + for method in ["exact", "statevector", "tensor_network"] { + let r = go(json!({ "qasm": "qubit[3] q; x q[0];", "method": method, "amplitudes": ["001", "100"] })); + let a = r["amplitudes"].as_array().unwrap(); + assert!((a[0]["re"].as_f64().unwrap().hypot(a[0]["im"].as_f64().unwrap()) - 1.0).abs() < 1e-8, "{method}: {r}"); + assert!(a[1]["re"].as_f64().unwrap().abs() < 1e-12, "{method}: {r}"); + } + } + + #[test] + fn continuous_angles_agree_across_methods() { + let qasm = "qubit[5] q; rx(0.37) q[0]; ry(1.1) q[1]; cx q[0], q[2]; rzz(0.8) q[2], q[3]; u3(0.4, 0.2, -0.7) q[4]; cry(0.9) q[4], q[1]; crz(0.3) q[3], q[0];"; + let obs = json!(["Z0", "X1 Z2", "Y3 Y4", "Z0 Z1 Z2 Z3 Z4"]); + let sv = go(json!({ "qasm": qasm, "method": "statevector", "observables": obs })); + let pa = go(json!({ "qasm": qasm, "method": "pauli", "observables": obs, "options": { "threshold": 0.0 } })); + for (a, b) in sv["expectations"].as_array().unwrap().iter().zip(pa["expectations"].as_array().unwrap()) { + assert!((a["value"].as_f64().unwrap() - b["value"].as_f64().unwrap()).abs() < 1e-9, "{a} vs {b}"); + assert_eq!(b["bound"].as_f64().unwrap(), 0.0); + } + let amp = go(json!({ "qasm": qasm, "method": "statevector", "amplitudes": ["01101"] })); + let tn = go(json!({ "qasm": qasm, "method": "tensor_network", "amplitudes": ["01101"] })); + let (x, y) = (&["amplitudes"][0], &tn["amplitudes"][0]); + assert!((x["re"].as_f64().unwrap() - y["re"].as_f64().unwrap()).abs() < 1e-12 && (x["im"].as_f64().unwrap() - y["im"].as_f64().unwrap()).abs() < 1e-12, "{x} vs {y}"); + } + + #[test] + fn trajectory_counts_follow_the_density_matrix_and_share_histories() { + let qasm = "qubit[3] q; bit[3] c; h q[0]; cx q[0], q[1]; ry(0.7) q[2]; cx q[1], q[2]; c = measure q;"; + let noise = json!({ "depolarizing_1q": 0.002, "depolarizing_2q": 0.01, "amplitude_damping": 0.01, "readout": 0.02 }); + let shots = 20_000u64; + let dm = go(json!({ "qasm": qasm, "method": "density_matrix", "noise": noise, "shots": 1_000_000 })); + let tr = go(json!({ "qasm": qasm, "method": "trajectories", "noise": noise, "shots": shots, "seed": 4 })); + assert_eq!(tr["shots"], shots); + let total: u64 = tr["counts"].as_object().unwrap().values().map(|v| v.as_u64().unwrap()).sum(); + assert_eq!(total, shots); + // Every outcome within five standard errors of the (finely sampled) exact distribution. + for (bits, c) in tr["counts"].as_object().unwrap() { + let p = dm["counts"][bits].as_u64().unwrap_or(0) as f64 / 1e6; + let f = c.as_u64().unwrap() as f64 / shots as f64; + let se = (p * (1.0 - p) / shots as f64).sqrt().max(2e-4); + assert!((f - p).abs() < 5.0 * se + 2e-3, "{bits}: {f} vs {p}"); + } + let histories = tr["accuracy"]["histories"].as_u64().unwrap(); + assert!(histories < shots / 4, "{histories} histories for {shots} shots"); + } + + #[test] + fn pauli_answers_carry_a_threshold_ladder() { + let qasm = "qubit[6] q; rx(0.3) q[0]; rzz(0.4) q[0], q[1]; ry(0.9) q[1]; rzz(0.2) q[1], q[2]; rx(1.1) q[2]; cx q[2], q[3]; ry(0.5) q[3];"; + let pa = go(json!({ "qasm": qasm, "method": "pauli", "observables": ["Z3", "X0 Z1"], "options": { "threshold": 1e-4 } })); + for e in pa["expectations"].as_array().unwrap() { + let ladder = e["ladder"].as_array().unwrap(); + let ts: Vec = ladder.iter().map(|r| r["threshold"].as_f64().unwrap()).collect(); + assert_eq!(ts.len(), 4, "{e}"); + assert!((ts[0] - 0.1).abs() < 1e-15 && (ts[3] - 1e-4).abs() < 1e-18, "{ts:?}"); + assert_eq!(ladder[3]["value"], e["value"]); + // A smaller threshold never drops more. + let bounds: Vec = ladder.iter().map(|r| r["bound"].as_f64().unwrap()).collect(); + assert!(bounds.windows(2).all(|w| w[1] <= w[0] + 1e-15), "{bounds:?}"); + } + let flat = go(json!({ "qasm": qasm, "method": "pauli", "observables": ["Z3"], "options": { "threshold": 1e-4, "ladder": false } })); + assert!(flat["expectations"][0].get("ladder").is_none()); + } + + #[test] + fn noise_is_modelled_exactly_and_by_trajectories() { + let qasm = "qubit[2] q; h q[0]; cx q[0], q[1];"; + let noise = json!({ "depolarizing_2q": 0.2 }); + let dm = go(json!({ "qasm": qasm, "method": "density_matrix", "noise": noise, "observables": ["Z0 Z1"] })); + let zz = dm["expectations"][0]["value"].as_f64().unwrap(); + // Of the 15 two-qubit Paulis the channel applies (p/15 each), the 8 + // that anticommute with Z⊗Z flip it: ⟨ZZ⟩ = 1 − 2·(8p/15). + assert!((zz - (1.0 - 16.0 * 0.2 / 15.0)).abs() < 1e-12, "{dm}"); + let tr = go(json!({ "qasm": qasm, "method": "trajectories", "noise": noise, "observables": ["Z0 Z1"], "options": { "trajectories": 4000 } })); + let (mean, se) = (tr["expectations"][0]["value"].as_f64().unwrap(), tr["expectations"][0]["stderr"].as_f64().unwrap()); + assert!((mean - zz).abs() < 5.0 * se + 1e-9, "trajectories {mean} ± {se} vs exact {zz}"); + } + + #[test] + fn a_thousand_qubit_clifford_circuit_is_sampled_and_read_exactly() { + let n = 1000; + let mut q = format!("qubit[{n}] q; h q[0];"); + for i in 0..n - 1 { + q.push_str(&format!(" cx q[{i}], q[{}];", i + 1)); + } + q.push_str(" rz(pi/2) q[0]; s q[0];"); + let r = go(json!({ "qasm": q, "shots": 200, "observables": ["Z0 Z999", "X0", "Y0 Y1"] })); + assert_eq!(r["method"], "stabilizer", "{r}"); + let c = r["counts"].as_object().unwrap(); + assert_eq!(c.len(), 2, "{c:?}"); + assert!(c.keys().all(|k| k == &"0".repeat(n) || k == &"1".repeat(n))); + assert_eq!(r["outcome_dimension"], json!(1)); + let v: Vec = r["expectations"].as_array().unwrap().iter().map(|e| e["value"].as_f64().unwrap()).collect(); + assert_eq!(v, vec![1.0, 0.0, 0.0]); + } + + #[test] + fn the_matrix_product_state_agrees_and_reaches_wide_circuits() { + // Small: no truncation, so exact, and the state vector's values. + let qasm = "qubit[6] q; rx(0.37) q[0]; ry(1.1) q[1]; cx q[0], q[2]; rzz(0.8) q[2], q[3]; ccx q[1], q[3], q[5]; crz(0.3) q[4], q[0]; cswap q[0], q[2], q[4];"; + let obs = json!(["Z0", "X1 Z2", "Y3 Y4", "Z0 Z1 Z2 Z3 Z4 Z5"]); + let sv = go(json!({ "qasm": qasm, "method": "statevector", "observables": obs, "amplitudes": ["010101"] })); + let m = go(json!({ "qasm": qasm, "method": "mps", "observables": obs, "amplitudes": ["010101"] })); + assert_eq!(m["accuracy"]["class"], "exact", "{m}"); + for (a, b) in sv["expectations"].as_array().unwrap().iter().zip(m["expectations"].as_array().unwrap()) { + assert!((a["value"].as_f64().unwrap() - b["value"].as_f64().unwrap()).abs() < 1e-9, "{a} vs {b}"); + } + let (x, y) = (&sv["amplitudes"][0], &m["amplitudes"][0]); + assert!((x["re"].as_f64().unwrap() - y["re"].as_f64().unwrap()).abs() < 1e-9 && (x["im"].as_f64().unwrap() - y["im"].as_f64().unwrap()).abs() < 1e-9); + // Wide: 200 qubits of low entanglement, sampled by auto. + let n = 200; + let mut q = format!("qubit[{n}] q;"); + for i in 0..n { + q.push_str(&format!(" ry(0.3) q[{i}];")); + } + for i in 0..n - 1 { + q.push_str(&format!(" cx q[{i}], q[{}];", i + 1)); + } + let w = go(json!({ "qasm": q, "shots": 100, "observables": ["Z0"] })); + assert_eq!(w["method"], "mps", "{w}"); + assert_eq!(w["counts"].as_object().unwrap().values().map(|v| v.as_u64().unwrap()).sum::(), 100); + } + + #[test] + fn the_exact_method_is_bit_exact_and_names_its_state() { + let job = json!({ "qasm": "qubit[4] q; h q; t q[1]; cx q[1], q[2]; cp(pi/8) q[0], q[3];", "method": "exact", "shots": 100, "observables": ["X0 X3"] }); + let a = go(job.clone()); + let b = go(job); + assert_eq!(a["state_hash"], b["state_hash"]); + assert_eq!(a["counts"], b["counts"]); + assert_eq!(a["state_hash"].as_str().unwrap().len(), 64); + } +} diff --git a/wai/wai-quantum/Cargo.lock b/wai/wai-quantum/Cargo.lock index d8148e1bc..541174291 100644 --- a/wai/wai-quantum/Cargo.lock +++ b/wai/wai-quantum/Cargo.lock @@ -1079,7 +1079,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.2" +version = "0.6.3" dependencies = [ "blake3", "block2", diff --git a/wai/wai-quantum/Cargo.toml b/wai/wai-quantum/Cargo.toml index 71aca7807..d0d02fff3 100644 --- a/wai/wai-quantum/Cargo.toml +++ b/wai/wai-quantum/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wai-quantum" -version = "0.6.2" +version = "0.6.3" edition = "2024" rust-version = "1.97" license = "Apache-2.0" @@ -76,6 +76,8 @@ quantum = [] quantum_toolchain = ["quantum"] quantum_mps = ["quantum"] quantum_stabilizer = ["quantum"] +quantum_tableau = [] +quantum_mpsim = ["quantum_sv", "quantum_mps"] quantum_pauli = ["quantum"] # Sparse Pauli dynamics at utility scale: arbitrary-angle Pauli rotations on up # to 1024 qubits, reproducible bit for bit, with a rigorous truncation bound. @@ -199,7 +201,7 @@ quantum_phasor_meter = ["_signing"] # Everything. full = [ - "quantum", "quantum_toolchain", "quantum_mps", "quantum_stabilizer", "quantum_pauli", "quantum_spd", "quantum_spd_receipt", "quantum_bptn", "quantum_resource", "quantum_resource_receipt", "quantum_frame", "quantum_tn", "quantum_gbs", "quantum_sv", "quantum_arch", "quantum_ldpc", "quantum_qio", "quantum_uf", "quantum_noisy", "quantum_dmrg", "quantum_tdvp", "quantum_chem", "quantum_nqs", "quantum_qir", "quantum_info", "quantum_qudit", "quantum_comm", "quantum_source", "quantum_channel", + "quantum", "quantum_toolchain", "quantum_mps", "quantum_stabilizer", "quantum_tableau", "quantum_mpsim", "quantum_pauli", "quantum_spd", "quantum_spd_receipt", "quantum_bptn", "quantum_resource", "quantum_resource_receipt", "quantum_frame", "quantum_tn", "quantum_gbs", "quantum_sv", "quantum_arch", "quantum_ldpc", "quantum_qio", "quantum_uf", "quantum_noisy", "quantum_dmrg", "quantum_tdvp", "quantum_chem", "quantum_nqs", "quantum_qir", "quantum_info", "quantum_qudit", "quantum_comm", "quantum_source", "quantum_channel", "quantum_receipt", "quantum_ops", "quantum_cal", "quantum_mitigate", "quantum_qec", "quantum_control", "quantum_noise", "quantum_compile", "quantum_atom", "quantum_qbom", "quantum_fabric", "quantum_vml", "quantum_phasor", "quantum_qfhrr", "quantum_kernel", "quantum_qdata", @@ -338,6 +340,14 @@ required-features = ["quantum_arch"] name = "sv_run" required-features = ["quantum_sv"] +[[example]] +name = "sv_bench" +required-features = ["quantum_sv"] + +[[example]] +name = "noisy_bench" +required-features = ["quantum_noisy"] + [[example]] name = "gbs_check" required-features = ["quantum_gbs"] diff --git a/wai/wai-quantum/examples/noisy_bench.rs b/wai/wai-quantum/examples/noisy_bench.rs new file mode 100644 index 000000000..92f8739aa --- /dev/null +++ b/wai/wai-quantum/examples/noisy_bench.rs @@ -0,0 +1,61 @@ +//! Time noisy shots two ways: independent trajectories, and branching +//! trajectories (shots that share a history share a state), at three error +//! rates. +//! +//! ```text +//! cargo run --release --example noisy_bench --features quantum_noisy -- N SHOTS DEPTH +//! ``` +use std::time::Instant; +use wai_quantum::gates::Gate; +use wai_quantum::quantum_noisy::{branching_trajectories, trajectories, NoiseModel}; + +fn brickwork(n: u32, depth: usize) -> Vec { + let mut g = Vec::new(); + let mut a = 0.1f64; + for layer in 0..depth { + for q in 0..n { + a += 0.7311; + g.push(if q % 2 == 0 { Gate::rx(q, a) } else { Gate::ry(q, a) }); + } + let mut q = (layer % 2) as u32; + while q + 1 < n { + g.push(Gate::cz(q, q + 1)); + q += 2; + } + } + g +} + +fn main() { + let a: Vec = std::env::args().collect(); + let n: u32 = a.get(1).map_or(14, |s| s.parse().unwrap()); + let shots: u64 = a.get(2).map_or(1000, |s| s.parse().unwrap()); + let depth: usize = a.get(3).map_or(10, |s| s.parse().unwrap()); + let threads = std::thread::available_parallelism().map_or(1, |t| t.get()); + let g = brickwork(n, depth); + let obs = vec![vec![(1.0, vec![(0, 'Z'), (1, 'Z')])]]; + println!("{n} qubits, {} gates, {shots} shots, {threads} threads", g.len()); + for (name, noise) in [ + ("rare: 1e-4 / 1e-3", NoiseModel { depol1: 1e-4, depol2: 1e-3, readout: 0.01, ..NoiseModel::default() }), + ("moderate: 1e-3 / 5e-3", NoiseModel { depol1: 1e-3, depol2: 5e-3, readout: 0.01, ..NoiseModel::default() }), + ("heavy: 5e-3 / 2e-2, damping 1e-3", NoiseModel { depol1: 5e-3, depol2: 2e-2, damping: 1e-3, readout: 0.01, ..NoiseModel::default() }), + ] { + let t0 = Instant::now(); + let b = branching_trajectories(&g, n, &noise, &obs, shots, 1, 16, threads).unwrap(); + let tb = t0.elapsed().as_secs_f64(); + let t0 = Instant::now(); + let t = trajectories(&g, n, &noise, &obs, shots as usize, 1, threads).unwrap(); + let tt = t0.elapsed().as_secs_f64(); + println!( + " {name:34} independent {tt:7.2} s; branching {tb:7.2} s ({:5.1}x): {} histories, {} gate applications of {}; {:.4} ± {:.4} vs {:.4} ± {:.4}", + tt / tb, + b.histories, + b.gate_applications, + shots * g.len() as u64, + b.expectations[0].mean, + b.expectations[0].stderr, + t.expectations[0].mean, + t.expectations[0].stderr + ); + } +} diff --git a/wai/wai-quantum/examples/sv_bench.rs b/wai/wai-quantum/examples/sv_bench.rs new file mode 100644 index 000000000..241e91b7a --- /dev/null +++ b/wai/wai-quantum/examples/sv_bench.rs @@ -0,0 +1,176 @@ +//! Time the dense state vector: single gates by position and kind, then whole +//! circuits (brickwork, QFT, QAOA) at each fusion width. +//! +//! ```text +//! cargo run --release --example sv_bench --features quantum_sv -- N [gates|circuits|all] +//! ``` +//! +//! A gate pass reads and writes every amplitude once, 32 bytes each, so the +//! `GB/s` column is that traffic over the time. +use std::time::Instant; +use wai_quantum::gates::Gate; +use wai_quantum::quantum_sv::{Schedule, StateVector}; + +fn rng(seed: u64) -> impl FnMut() -> f64 { + let mut s = seed; + move || { + s = s.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = s; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + (((z ^ (z >> 31)) >> 11) as f64 + 0.5) / 9_007_199_254_740_992.0 + } +} + +fn brickwork(n: u32, depth: usize, seed: u64) -> Vec { + let mut u = rng(seed); + let mut g = Vec::new(); + for layer in 0..depth { + for q in 0..n { + g.push(Gate::rx(q, u() * 6.0)); + g.push(Gate::rz(q, u() * 6.0)); + } + let mut q = (layer % 2) as u32; + while q + 1 < n { + g.push(Gate::fsim(q, q + 1, u() * 3.0, u() * 3.0)); + q += 2; + } + } + g +} + +fn qft(n: u32) -> Vec { + let mut g = Vec::new(); + for i in 0..n { + g.push(Gate::h(i)); + for j in i + 1..n { + g.push(Gate::cphase(j, i, core::f64::consts::PI / f64::from(1u32 << (j - i).min(30)))); + } + } + g +} + +fn qaoa(n: u32, p: usize, seed: u64) -> Vec { + let mut u = rng(seed); + let mut g: Vec = (0..n).map(Gate::h).collect(); + for _ in 0..p { + let gamma = u(); + for q in 0..n { + g.push(Gate::rzz(q, (q + 1) % n, gamma)); + g.push(Gate::rzz(q, (q + 3) % n, gamma)); + } + let beta = u(); + for q in 0..n { + g.push(Gate::rx(q, beta)); + } + } + g +} + +fn time(reps: usize, mut f: F) -> f64 { + f(); + let t0 = Instant::now(); + for _ in 0..reps { + f(); + } + t0.elapsed().as_secs_f64() / reps as f64 +} + +fn main() { + let a: Vec = std::env::args().collect(); + let n: u32 = a.get(1).map_or(24, |s| s.parse().unwrap()); + let what = a.get(2).map_or("all", |s| s.as_str()); + let bytes = 32.0 * (1u64 << n) as f64; + let mut sv = StateVector::zero(n).unwrap(); + sv.run(&(0..n).map(Gate::h).collect::>(), 0).unwrap(); + let threads = std::thread::available_parallelism().map_or(1, |t| t.get()); + println!("{n} qubits, {threads} threads, {:.2} GiB of state", 16.0 * (1u64 << n) as f64 / (1u64 << 30) as f64); + if what == "gates" || what == "all" { + let reps = if n >= 28 { 3 } else { 10 }; + let cases: Vec<(&str, Gate)> = vec![ + ("rx, top qubit", Gate::rx(0, 0.3)), + ("rx, middle qubit", Gate::rx(n / 2, 0.3)), + ("rx, bottom qubit", Gate::rx(n - 1, 0.3)), + ("rz (diagonal), middle", Gate::rz(n / 2, 0.3)), + ("fsim, top pair", Gate::fsim(0, 1, 0.4, 0.2)), + ("fsim, middle pair", Gate::fsim(n / 2, n / 2 + 1, 0.4, 0.2)), + ("fsim, bottom pair", Gate::fsim(n - 2, n - 1, 0.4, 0.2)), + ("fsim, far apart", Gate::fsim(1, n - 2, 0.4, 0.2)), + ("rzz (diagonal), middle", Gate::rzz(n / 2, n / 2 + 1, 0.3)), + ]; + for (name, g) in cases { + let t = time(reps, || sv.apply(&g).unwrap()); + println!(" {name:26} {:9.2} ms {:7.1} GB/s", t * 1e3, bytes / t / 1e9); + } + for k in 3..=5u32 { + // A dense k-qubit gate: a fused brickwork block on the bottom k qubits. + let block = brickwork(k, 3, 7).into_iter().map(|mut g| { + g.qubits.iter_mut().for_each(|q| *q += n - k); + g + }); + let fused = wai_quantum::quantum_sv::fuse(&block.collect::>(), k as usize); + if fused.len() == 1 { + let g = &fused[0]; + let t = time(reps, || sv.apply(g).unwrap()); + println!(" dense {k}-qubit, bottom {:9.2} ms {:7.1} GB/s", t * 1e3, bytes / t / 1e9); + } + } + } + if what == "cache" { + // Below 2^14 amplitudes every kernel runs on one thread: the in-cache + // rate of one core, in amplitudes per second. + let reps = (1usize << 26) >> n; + let cases: Vec<(&str, Gate)> = vec![ + ("rx, top qubit", Gate::rx(0, 0.3)), + ("rx, bottom qubit", Gate::rx(n - 1, 0.3)), + ("rz (diagonal)", Gate::rz(n / 2, 0.3)), + ("fsim, top pair", Gate::fsim(0, 1, 0.4, 0.2)), + ("fsim, bottom pair", Gate::fsim(n - 2, n - 1, 0.4, 0.2)), + ("fsim, far apart", Gate::fsim(1, n - 2, 0.4, 0.2)), + ]; + for (name, g) in cases { + let t = time(reps, || sv.apply(&g).unwrap()); + println!(" {name:26} {:9.1} M amplitudes/s per core", (1u64 << n) as f64 / t / 1e6); + } + for k in 3..=5u32 { + let block = brickwork(k, 3, 7).into_iter().map(|mut g| { + g.qubits.iter_mut().for_each(|q| *q += n - k); + g + }); + let fused = wai_quantum::quantum_sv::fuse(&block.collect::>(), k as usize); + if fused.len() == 1 { + let g = &fused[0]; + let t = time(reps, || sv.apply(g).unwrap()); + println!(" dense {k}-qubit {:9.1} M amplitudes/s per core", (1u64 << n) as f64 / t / 1e6); + } + } + } + if what == "circuits" || what == "all" { + let circuits: Vec<(&str, Vec)> = vec![("brickwork depth 20", brickwork(n, 20, 1)), ("qft", qft(n)), ("qaoa p=2", qaoa(n, 2, 3))]; + let tile: u32 = std::env::var("TILE").ok().map_or(15, |s| s.parse().unwrap()); + let low: u32 = std::env::var("RUN").ok().map_or(5, |s| s.parse().unwrap()); + let tiled = Schedule { tile_bits: tile, run_bits: low }; + for (name, gates) in &circuits { + for width in [2usize, 3, 4, 5] { + let passes = wai_quantum::quantum_sv::fuse(gates, width).len(); + let mut times = Vec::new(); + let mut states = Vec::new(); + for schedule in [Schedule::UNTILED, tiled] { + let mut s = StateVector::zero(n).unwrap(); + let t0 = Instant::now(); + s.run_scheduled(gates, width, schedule).unwrap(); + times.push(t0.elapsed().as_secs_f64()); + states.push(s); + } + let same = states[0].re.iter().chain(&states[0].im).zip(states[1].re.iter().chain(&states[1].im)).all(|(a, b)| a.to_bits() == b.to_bits()); + println!( + " {name:20} {} gates, width {width}: {passes:5} gates fused; untiled {:8.3} s, tiled {:8.3} s ({:4.1}x), same bits: {same}", + gates.len(), + times[0], + times[1], + times[0] / times[1] + ); + } + } + } +} diff --git a/wai/wai-quantum/src/lib.rs b/wai/wai-quantum/src/lib.rs index 1d56e98c3..89275ce5f 100644 --- a/wai/wai-quantum/src/lib.rs +++ b/wai/wai-quantum/src/lib.rs @@ -66,6 +66,9 @@ pub mod gates; ))] mod repro; +#[cfg(feature = "quantum_spd")] +mod pool; + /// Re-exported so consumers get a **version-matched** signing type: the receipt /// sealing APIs take an `ed25519_dalek::SigningKey`, and a caller who depended on /// their own copy could easily pick an incompatible version. @@ -245,6 +248,10 @@ pub mod quantum_receipt; #[cfg(feature = "quantum_stabilizer")] pub mod quantum_stabilizer; +#[cfg(feature = "quantum_tableau")] +pub mod quantum_tableau; +#[cfg(feature = "quantum_mpsim")] +pub mod quantum_mpsim; #[cfg(feature = "quantum_toolchain")] pub mod quantum_toolchain; diff --git a/wai/wai-quantum/src/linalg.rs b/wai/wai-quantum/src/linalg.rs index b9ed6925b..6bb5c3818 100644 --- a/wai/wai-quantum/src/linalg.rs +++ b/wai/wai-quantum/src/linalg.rs @@ -247,6 +247,212 @@ pub(crate) fn jacobi_svd_strict(mut m: Vec>, rows: usize, cols: usize) -> } +// =========================================================================== +// Hermitian eigendecomposition (A = V · diag(λ) · V†) +// =========================================================================== + +/// The eigenpairs of a Hermitian matrix, `A = W·Z·diag(values)·(W·Z)†`: `W` +/// unitary (the Householder reflections and a diagonal phase), `Z` real +/// orthogonal (the QL rotations). [`Eigh::vector`] forms one eigenvector. +#[cfg(feature = "quantum_mpsim")] +pub(crate) struct Eigh { + /// The eigenvalues, in no particular order. + pub(crate) values: Vec, + n: usize, + /// `W`, row-major. + w: Vec, + /// `Zᵀ`: row `i` is the eigenvector of `values[i]` in the tridiagonal basis. + zt: Vec, +} + +#[cfg(feature = "quantum_mpsim")] +impl Eigh { + /// The unit eigenvector of `values[i]`. + pub(crate) fn vector(&self, i: usize) -> Vec { + let n = self.n; + let z = &self.zt[i * n..(i + 1) * n]; + (0..n) + .map(|r| { + let mut acc = C::ZERO; + for (wj, &zj) in self.w[r * n..(r + 1) * n].iter().zip(z) { + acc = acc.add(wj.scale(zj)); + } + acc + }) + .collect() + } +} + +/// `√(a² + b²)` without overflow or underflow on the way. +#[cfg(feature = "quantum_mpsim")] +fn hypot(a: f64, b: f64) -> f64 { + let (x, y) = (a.abs(), b.abs()); + let (big, small) = if x > y { (x, y) } else { (y, x) }; + if big == 0.0 { + 0.0 + } else { + let r = small / big; + big * (1.0 + r * r).sqrt() + } +} + +/// Eigendecomposition of the Hermitian `n × n` matrix `a` (row-major; only +/// its Hermitian part is meaningful). Householder reflections take it to a +/// tridiagonal matrix, a diagonal phase makes that real, and the QL algorithm +/// with implicit shifts diagonalises it. Each step runs in a fixed order with +/// only `+ − × ÷ √`, so the result is the same on every machine. +#[cfg(feature = "quantum_mpsim")] +pub(crate) fn hermitian_eigh(mut a: Vec, n: usize) -> Eigh { + let mut q = vec![C::ZERO; n * n]; + for i in 0..n { + q[i * n + i] = C::ONE; + } + // beta[k]: the (complex) entry below the diagonal in column k. + let mut beta = vec![C::ZERO; n.saturating_sub(1)]; + let mut v = Vec::with_capacity(n); + let mut w = vec![C::ZERO; n]; + for k in 0..n.saturating_sub(2) { + let m = n - k - 1; + v.clear(); + v.extend((0..m).map(|i| a[(k + 1 + i) * n + k])); + let tail: f64 = v[1..].iter().map(|x| x.norm2()).sum(); + if tail == 0.0 { + beta[k] = v[0]; + continue; + } + let xnorm = (v[0].norm2() + tail).sqrt(); + // alpha = −(x₀/|x₀|)·‖x‖: the reflection maps the column to alpha·e₁. + let a0 = v[0].norm2().sqrt(); + let phase = if a0 == 0.0 { C::ONE } else { C::new(v[0].re / a0, v[0].im / a0) }; + let alpha = phase.scale(-xnorm); + v[0] = v[0].sub(alpha); + let vn = v.iter().map(|x| x.norm2()).sum::().sqrt(); + for x in v.iter_mut() { + *x = x.scale(1.0 / vn); + } + // B ← H·B·H on the trailing block, H = I − 2vv†: with w = B·v and + // s = v†·w, B − v·q† − q·v† for q = 2w − 2s·v. + for i in 0..m { + let row = &a[(k + 1 + i) * n + k + 1..(k + 1 + i) * n + n]; + let mut acc = C::ZERO; + for (bij, vj) in row.iter().zip(&v) { + acc = acc.add(bij.mul(*vj)); + } + w[i] = acc; + } + let mut s = 0.0; + for i in 0..m { + s += v[i].conj().mul(w[i]).re; + } + for i in 0..m { + w[i] = w[i].scale(2.0).sub(v[i].scale(2.0 * s)); + } + for i in 0..m { + let (vi, qi) = (v[i], w[i]); + let row = &mut a[(k + 1 + i) * n + k + 1..(k + 1 + i) * n + n]; + for j in 0..m { + row[j] = row[j].sub(vi.mul(w[j].conj())).sub(qi.mul(v[j].conj())); + } + } + beta[k] = alpha; + // Q ← Q·H on columns k + 1 .. + for r in 0..n { + let row = &mut q[r * n + k + 1..r * n + n]; + let mut t = C::ZERO; + for (x, vj) in row.iter().zip(&v) { + t = t.add(x.mul(*vj)); + } + let t2 = t.scale(2.0); + for (x, vj) in row.iter_mut().zip(&v) { + *x = x.sub(t2.mul(vj.conj())); + } + } + } + if n >= 2 { + beta[n - 2] = a[(n - 1) * n + n - 2]; + } + let mut d: Vec = (0..n).map(|i| a[i * n + i].re).collect(); + // A diagonal phase δ makes the off-diagonal real and nonnegative: + // δ₀ = 1, δₖ₊₁ = δₖ·βₖ/|βₖ|. W = Q·diag(δ). + let mut e = vec![0.0; n]; + let mut delta = C::ONE; + for k in 0..n.saturating_sub(1) { + let b = beta[k]; + let bn = b.norm2().sqrt(); + e[k] = bn; + if bn != 0.0 { + delta = delta.mul(C::new(b.re / bn, b.im / bn)); + } + for r in 0..n { + q[r * n + k + 1] = q[r * n + k + 1].mul(delta); + } + } + // QL with implicit shifts on (d, e); e[k] joins k and k + 1. + let mut zt = vec![0.0; n * n]; + for i in 0..n { + zt[i * n + i] = 1.0; + } + for l in 0..n { + let mut iterations = 0; + loop { + let mut m = l; + while m + 1 < n { + let dd = d[m].abs() + d[m + 1].abs(); + if e[m].abs() + dd == dd { + break; + } + m += 1; + } + if m == l || iterations == 64 { + break; + } + iterations += 1; + let mut g = (d[l + 1] - d[l]) / (2.0 * e[l]); + let mut r = hypot(g, 1.0); + g = d[m] - d[l] + e[l] / (g + if g >= 0.0 { r } else { -r }); + let (mut s, mut c, mut p) = (1.0, 1.0, 0.0); + let mut i = m; + let mut underflow = false; + while i > l { + i -= 1; + let f = s * e[i]; + let b = c * e[i]; + r = hypot(f, g); + e[i + 1] = r; + if r == 0.0 { + d[i + 1] -= p; + e[m] = 0.0; + underflow = true; + break; + } + s = f / r; + c = g / r; + g = d[i + 1] - p; + r = (d[i] - g) * s + 2.0 * c * b; + p = s * r; + d[i + 1] = g + p; + g = c * r - b; + let (lo, hi) = zt.split_at_mut((i + 1) * n); + let (zi, zi1) = (&mut lo[i * n..(i + 1) * n], &mut hi[..n]); + for (x, y) in zi.iter_mut().zip(zi1.iter_mut()) { + let f = *y; + *y = s * *x + c * f; + *x = c * *x - s * f; + } + } + if underflow { + continue; + } + d[l] -= p; + e[l] = g; + if m < n { + e[m] = 0.0; + } + } + } + Eigh { values: d, n, w: q, zt } +} + // =========================================================================== // Householder QR (A = Q · R) // =========================================================================== @@ -417,3 +623,84 @@ mod svd_tests { } } } + +#[cfg(all(test, feature = "quantum_mpsim"))] +mod eigh_tests { + use super::*; + + fn random_hermitian(n: usize, seed: u64, rank: usize) -> Vec { + let mut s = seed; + let mut next = || { + s = s.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = s; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + ((z ^ (z >> 31)) >> 11) as f64 / 9_007_199_254_740_992.0 - 0.5 + }; + // B·B† for a random n × rank B: Hermitian, positive semidefinite, of that rank. + let b: Vec = (0..n * rank).map(|_| C::new(next(), next())).collect(); + let mut a = vec![C::ZERO; n * n]; + for i in 0..n { + for j in 0..n { + let mut acc = C::ZERO; + for k in 0..rank { + acc = acc.add(b[i * rank + k].mul(b[j * rank + k].conj())); + } + a[i * n + j] = acc; + } + } + a + } + + #[test] + fn eigenpairs_reconstruct_and_vectors_are_orthonormal() { + for (n, rank, seed) in [(1usize, 1usize, 1u64), (2, 2, 2), (3, 1, 3), (17, 17, 4), (40, 9, 5), (64, 64, 6), (130, 70, 7)] { + let a = random_hermitian(n, seed, rank); + let scale = (0..n).map(|i| a[i * n + i].re).fold(0.0, f64::max).max(1.0); + let e = hermitian_eigh(a.clone(), n); + let vs: Vec> = (0..n).map(|i| e.vector(i)).collect(); + for i in 0..n { + for j in 0..n { + let dot = (0..n).fold(C::ZERO, |acc, r| acc.add(vs[i][r].conj().mul(vs[j][r]))); + let want = if i == j { 1.0 } else { 0.0 }; + assert!((dot.re - want).abs() < 1e-12 && dot.im.abs() < 1e-12, "n {n}: = {dot:?}"); + } + // A·v = λ·v + for r in 0..n { + let av = (0..n).fold(C::ZERO, |acc, c| acc.add(a[r * n + c].mul(vs[i][c]))); + let lv = vs[i][r].scale(e.values[i]); + assert!((av.re - lv.re).abs() < 1e-11 * scale && (av.im - lv.im).abs() < 1e-11 * scale, "n {n}, pair {i}, row {r}"); + } + } + // Rank-deficient: n − rank eigenvalues at zero. + let zeros = e.values.iter().filter(|&&x| x.abs() < 1e-10 * scale).count(); + assert!(zeros >= n - rank, "n {n} rank {rank}: {zeros} zero eigenvalues"); + } + } + + #[test] + fn special_matrices() { + // The identity, the zero matrix, a diagonal and an already tridiagonal one. + for n in [1usize, 4, 9] { + let mut id = vec![C::ZERO; n * n]; + for i in 0..n { + id[i * n + i] = C::ONE; + } + let e = hermitian_eigh(id, n); + assert!(e.values.iter().all(|&x| (x - 1.0).abs() < 1e-15)); + let e = hermitian_eigh(vec![C::ZERO; n * n], n); + assert!(e.values.iter().all(|&x| x == 0.0)); + } + let mut t = vec![C::ZERO; 9]; + t[0] = C::new(2.0, 0.0); + t[4] = C::new(3.0, 0.0); + t[8] = C::new(5.0, 0.0); + t[1] = C::new(0.0, 1.0); + t[3] = C::new(0.0, -1.0); + let e = hermitian_eigh(t, 3); + let mut v = e.values.clone(); + v.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let r = 0.5 * 5f64.sqrt(); + assert!((v[0] - (2.5 - r)).abs() < 1e-14 && (v[1] - (2.5 + r)).abs() < 1e-14 && (v[2] - 5.0).abs() < 1e-14, "{v:?}"); + } +} diff --git a/wai/wai-quantum/src/pool.rs b/wai/wai-quantum/src/pool.rs new file mode 100644 index 000000000..4f4fa8587 --- /dev/null +++ b/wai/wai-quantum/src/pool.rs @@ -0,0 +1,255 @@ +//! A fixed set of worker threads for a run made of many short parallel steps. +//! +//! Spawning threads for every step of a run costs tens of microseconds a +//! thread, which is more than a short step's work. [`scoped`] starts the +//! workers once; [`Pool::run`] hands each step to all of them and returns when +//! every one has finished it. A step's pieces go to whichever worker is free +//! next, so a slow or busy core holds up less of it, but each piece's result +//! lands in its own place, so a step's result never depends on timing. + +use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Condvar, Mutex}; + +/// The step the workers are running: a closure borrowed for one +/// [`Pool::run`] call. +#[derive(Clone, Copy)] +struct Job(*const (dyn Fn(usize) + Sync)); + +// SAFETY: the closure is `Sync`, and `Pool::run` keeps it alive until every +// worker has finished with it. +unsafe impl Send for Job {} + +struct State { + generation: u64, + job: Option, + stop: bool, + /// Workers still running the current step. + running: usize, + panicked: bool, +} + +struct Shared { + state: Mutex, + start: Condvar, + finish: Condvar, +} + +impl Shared { + fn worker(&self, index: usize) { + let mut seen = 0; + loop { + let job = { + let mut s = self.state.lock().unwrap(); + while s.generation == seen && !s.stop { + s = self.start.wait(s).unwrap(); + } + if s.stop { + return; + } + seen = s.generation; + s.job.expect("a step to run") + }; + // SAFETY: `run` does not return, and so does not release the + // closure, until `running` reaches zero below. + let ok = catch_unwind(AssertUnwindSafe(|| unsafe { (*job.0)(index) })).is_ok(); + let mut s = self.state.lock().unwrap(); + s.panicked |= !ok; + s.running -= 1; + if s.running == 0 { + self.finish.notify_one(); + } + } + } + + fn stop(&self) { + self.state.lock().unwrap().stop = true; + self.start.notify_all(); + } +} + +/// Workers `0..threads`; worker 0 is the calling thread. +pub(crate) struct Pool<'a> { + threads: usize, + shared: Option<&'a Shared>, +} + +/// Stops the workers when the run ends, normally or by a panic, so the +/// scope can join them. +struct StopOnDrop<'a>(&'a Shared); + +impl Drop for StopOnDrop<'_> { + fn drop(&mut self) { + self.0.stop(); + } +} + +/// Run `f` with `threads` workers (one, the caller, on wasm32 or when +/// `threads` ≤ 1). +pub(crate) fn scoped(threads: usize, f: impl FnOnce(&Pool<'_>) -> R) -> R { + if threads <= 1 || cfg!(target_arch = "wasm32") { + return f(&Pool { threads: 1, shared: None }); + } + let shared = Shared { state: Mutex::new(State { generation: 0, job: None, stop: false, running: 0, panicked: false }), start: Condvar::new(), finish: Condvar::new() }; + std::thread::scope(|sc| { + for w in 1..threads { + let shared = &shared; + sc.spawn(move || shared.worker(w)); + } + let _stop = StopOnDrop(&shared); + f(&Pool { threads, shared: Some(&shared) }) + }) +} + +impl Pool<'_> { + pub(crate) fn threads(&self) -> usize { + self.threads + } + + /// Run `job(w)` on every worker `w` and wait for all of them. + pub(crate) fn run(&self, job: &(dyn Fn(usize) + Sync)) { + let Some(shared) = self.shared else { + job(0); + return; + }; + // SAFETY: only the lifetime is erased. The workers call the closure + // between here and the wait below, and this function does not return + // (normally or by unwinding) before `running` is zero. + let erased: &'static (dyn Fn(usize) + Sync) = unsafe { std::mem::transmute::<&(dyn Fn(usize) + Sync), &'static (dyn Fn(usize) + Sync)>(job) }; + { + let mut s = shared.state.lock().unwrap(); + s.generation += 1; + s.job = Some(Job(erased)); + s.running = self.threads - 1; + } + shared.start.notify_all(); + let mine = catch_unwind(AssertUnwindSafe(|| job(0))); + let panicked = { + let mut s = shared.state.lock().unwrap(); + while s.running > 0 { + s = shared.finish.wait(s).unwrap(); + } + s.job = None; + std::mem::take(&mut s.panicked) + }; + if let Err(p) = mine { + resume_unwind(p); + } + assert!(!panicked, "a worker panicked"); + } + + /// Run `f(i)` for every `i` in `0..pieces`, each piece once, taken by + /// whichever worker is free. + fn each(&self, pieces: usize, f: &(dyn Fn(usize) + Sync)) { + if pieces <= 1 || self.threads == 1 { + (0..pieces).for_each(f); + return; + } + let next = AtomicUsize::new(0); + self.run(&|_| { + loop { + let i = next.fetch_add(1, Ordering::Relaxed); + if i >= pieces { + break; + } + f(i); + } + }); + } + + /// `f` over `parts` contiguous ranges covering `0..len`, the results in + /// range order. + pub(crate) fn split(&self, len: usize, parts: usize, f: &(dyn Fn(core::ops::Range) -> R + Sync)) -> Vec { + let parts = parts.clamp(1, len.max(1)); + let per = len.div_ceil(parts); + if parts == 1 || self.threads == 1 { + return (0..parts).map(|w| f((w * per).min(len)..((w + 1) * per).min(len))).collect(); + } + let slots: Vec>> = (0..parts).map(|_| Mutex::new(None)).collect(); + self.each(parts, &|w| { + let r = f((w * per).min(len)..((w + 1) * per).min(len)); + *slots[w].lock().unwrap() = Some(r); + }); + slots.into_iter().map(|m| m.into_inner().unwrap().expect("every share ran")).collect() + } + + /// `f` over the chunks of `data` (each `per` long, the last shorter), each + /// with its offset, at most one chunk a worker at a time, the results in + /// chunk order. + pub(crate) fn chunks(&self, data: &mut [T], per: usize, f: &(dyn Fn(usize, &mut [T]) -> R + Sync)) -> Vec { + let per = per.max(1); + let slots: Vec>> = data.chunks_mut(per).enumerate().map(|(i, c)| Mutex::new(Some((i * per, c)))).collect(); + let out: Vec>> = (0..slots.len()).map(|_| Mutex::new(None)).collect(); + if slots.len() <= 1 || self.threads == 1 { + return slots.into_iter().map(|m| m.into_inner().unwrap().map(|(at, c)| f(at, c)).expect("a chunk")).collect(); + } + self.each(slots.len(), &|i| { + let (at, c) = slots[i].lock().unwrap().take().expect("a chunk, once"); + *out[i].lock().unwrap() = Some(f(at, c)); + }); + out.into_iter().map(|m| m.into_inner().unwrap().expect("every chunk ran")).collect() + } +} + +impl Pool<'_> { + /// [`chunks`](Pool::chunks) over two slices of the same length, chunked + /// alike. + pub(crate) fn chunks2(&self, a: &mut [A], b: &mut [B], per: usize, f: &(dyn Fn(&mut [A], &mut [B]) + Sync)) { + let per = per.max(1); + let slots: Vec>> = a.chunks_mut(per).zip(b.chunks_mut(per)).map(|p| Mutex::new(Some(p))).collect(); + if slots.len() <= 1 || self.threads == 1 { + for m in slots { + let (x, y) = m.into_inner().unwrap().expect("a chunk"); + f(x, y); + } + return; + } + self.each(slots.len(), &|i| { + let (x, y) = slots[i].lock().unwrap().take().expect("a chunk, once"); + f(x, y); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_worker_runs_every_step() { + for threads in [1usize, 2, 7] { + scoped(threads, |pool| { + for step in 0..200usize { + let hits = AtomicUsize::new(0); + pool.run(&|w| { + hits.fetch_add(w + 1, Ordering::Relaxed); + }); + assert_eq!(hits.into_inner(), threads * (threads + 1) / 2, "step {step}"); + } + let sums = pool.split(1000, 5, &|r| r.sum::()); + assert_eq!(sums.iter().sum::(), 999 * 1000 / 2); + let mut v: Vec = (0..1003).collect(); + let firsts = pool.chunks(&mut v, 100, &|at, c| { + c.iter_mut().for_each(|x| *x *= 2); + at + }); + assert_eq!(firsts, (0..11).map(|i| i * 100).collect::>()); + assert!(v.iter().enumerate().all(|(i, &x)| x == 2 * i)); + }); + } + } + + #[test] + fn a_panicking_step_reaches_the_caller() { + let r = catch_unwind(|| { + scoped(4, |pool| { + pool.run(&|w| { + if w == 2 { + panic!("worker 2"); + } + }); + }) + }); + assert!(r.is_err()); + } +} diff --git a/wai/wai-quantum/src/quantum_mpsim.rs b/wai/wai-quantum/src/quantum_mpsim.rs new file mode 100644 index 000000000..f95f4be2a --- /dev/null +++ b/wai/wai-quantum/src/quantum_mpsim.rs @@ -0,0 +1,824 @@ +//! Matrix-product-state simulation of circuits with any angles — +//! `wai.quantum.mpsim`. +//! +//! The state is a chain of tensors, one per qubit, of bond dimension at most +//! [`MpsConfig::max_bond`], kept in mixed-canonical form: every tensor left of +//! the orthogonality center is left-orthonormal and every one right of it +//! right-orthonormal, the center moved by QR. A two-qubit gate on neighbours +//! is a two-site update: the two tensors are contracted with the gate into a +//! matrix `M`, split again, and truncated to the bond dimension and to +//! [`MpsConfig::cutoff`]. The split keeps the leading eigenvectors `U` of +//! `M·M†` (the leading left singular vectors of `M`) and makes `U†·M` the new +//! center, so no singular value is divided by. A matrix taller than wide is +//! first factored `M = Q·R`, and the same is done to `R`. With the center in +//! the block, that truncation is the best of its rank in the 2-norm. The weight +//! it drops, `1 − ‖U†·M‖² / ‖M‖²`, is exactly how far the state moved. Shares +//! below 10⁻¹² of an update's weight are the eigensolver's rounding and are +//! always dropped. An update that loses less than 10⁻¹³ of its weight dropped +//! only rounding and is not counted as a truncation. A gate on qubits further +//! apart is applied by swapping them next to each other and back. +//! +//! Every truncation is reported ([`MpsReport`]): the bonds used, the summed +//! discarded weight `Σ εᵢ`, and the fidelity estimate `Π (1 − εᵢ)`. A run +//! that discards nothing is exact to rounding. A run that does is an +//! approximation whose estimate is the standard one, not a bound. +//! +//! Readouts: +//! - amplitudes of bit strings, a product of matrices; +//! - Pauli expectation values, by transfer matrices; +//! - measurement shots, by sequential conditional sampling. With the center +//! on the first qubit, each qubit's outcome given the earlier ones has +//! probability `‖L·A[s]‖²`, `O(n χ²)` a shot. +//! +//! Determinism: the eigensolver (Householder reflections, then QL with +//! implicit shifts) runs in a fixed order, and every operation is IEEE +//! `+ − × ÷ √`, so a run is the same bits on every machine. +//! +//! Qubit `q` is site `q`; bit strings are given and returned one bit per +//! qubit, qubit 0 first. + +#![allow(clippy::needless_range_loop)] + +use crate::gates::{C, Gate}; +use crate::linalg::hermitian_eigh; + +/// Shares of an update's weight below this are the eigensolver's rounding +/// (it works on `M·M†`, whose small eigenvalues carry an error near `n·ε` of +/// the largest), and are always dropped. +const ROUNDING_SHARE: f64 = 1e-12; + +/// An update whose kept part misses less than this share of the weight +/// dropped nothing but rounding, and is not counted as a truncation. +const ROUNDING_LOSS: f64 = 1e-13; + +/// The most qubits a chain holds. +pub const MAX_QUBITS: u32 = 4096; + +/// How hard the chain may be truncated. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MpsConfig { + /// The largest bond dimension kept. + pub max_bond: usize, + /// Singular values whose squared share of the total is below this are + /// dropped (0 keeps every nonzero one). + pub cutoff: f64, +} + +impl Default for MpsConfig { + fn default() -> Self { + MpsConfig { max_bond: 256, cutoff: 1e-14 } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MpsError { + TooManyQubits(u32), + /// The gate at this position: not one or two qubits, a repeated qubit, a + /// qubit outside the chain, or a matrix of the wrong size. + BadGate(usize), + /// A bit string or Pauli string that does not fit the chain. + BadQuery(String), +} + +impl std::fmt::Display for MpsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MpsError::TooManyQubits(n) => write!(f, "{n} qubits; a chain holds at most {MAX_QUBITS}"), + MpsError::BadGate(i) => write!(f, "gate {i} is not a one- or two-qubit gate on the chain"), + MpsError::BadQuery(why) => write!(f, "{why}"), + } + } +} + +impl std::error::Error for MpsError {} + +/// What a run's truncations cost. +#[derive(Clone, Debug, PartialEq)] +pub struct MpsReport { + /// The largest bond dimension the chain reached. + pub max_bond_used: usize, + /// The bond dimensions now, between qubit `i` and `i + 1`. + pub bonds: Vec, + /// `Σ εᵢ`: the discarded weight, summed over every truncation. + pub discarded: f64, + /// `Π (1 − εᵢ)`: the standard estimate of the fidelity with the exact + /// state. + pub fidelity_estimate: f64, + /// Two-site updates that dropped weight. + pub truncations: u64, + /// Two-site updates in all (gates and the swaps that route them). + pub updates: u64, +} + +/// A matrix product state. Site `i` is `(dl[i], 2, dr[i])`, flat at +/// `(l·2 + s)·dr + r`. +#[derive(Clone, Debug)] +pub struct Chain { + n: usize, + a: Vec>, + dl: Vec, + dr: Vec, + center: usize, + cfg: MpsConfig, + max_bond_used: usize, + discarded: f64, + fidelity: f64, + truncations: u64, + updates: u64, +} + +#[inline] +fn at(l: usize, s: usize, r: usize, dr: usize) -> usize { + (l * 2 + s) * dr + r +} + +/// `Q` with orthonormal columns and `R` with `m = Q·R`, for `m` given by +/// columns (`rows × cols`), by Gram–Schmidt applied twice. `Q` has +/// `min(rows, cols)` columns, at most; a column that is (numerically) in the +/// span of the earlier ones is dropped, and its coefficients go into `R`. +fn qr(cols: &[Vec], rows: usize) -> (Vec>, Vec>) { + let mut q: Vec> = Vec::new(); + // r[k][j]: coefficient of q_k in column j. + let mut r: Vec> = Vec::new(); + let scale: f64 = cols.iter().flatten().map(|x| x.norm2()).sum::().max(1e-300); + for (j, col) in cols.iter().enumerate() { + let mut v = col.clone(); + let mut coeff = vec![C::ZERO; q.len()]; + for _pass in 0..2 { + for (k, qk) in q.iter().enumerate() { + let mut dot = C::ZERO; + for i in 0..rows { + dot = dot.add(qk[i].conj().mul(v[i])); + } + coeff[k] = coeff[k].add(dot); + for i in 0..rows { + v[i] = v[i].sub(qk[i].mul(dot)); + } + } + } + let norm2: f64 = v.iter().map(|x| x.norm2()).sum(); + for (k, c) in coeff.into_iter().enumerate() { + r[k][j] = c; + } + if q.len() < rows && norm2 > 1e-28 * scale { + let norm = norm2.sqrt(); + let qn: Vec = v.iter().map(|x| x.scale(1.0 / norm)).collect(); + q.push(qn); + let mut row = vec![C::ZERO; cols.len()]; + row[j] = C { re: norm, im: 0.0 }; + r.push(row); + } + } + if q.is_empty() { + // The zero matrix: one unit column and a zero R. + let mut e = vec![C::ZERO; rows]; + e[0] = C::ONE; + q.push(e); + r.push(vec![C::ZERO; cols.len()]); + } + (q, r) +} + +impl Chain { + /// `|0…0⟩` on `n` qubits. + pub fn new(n: u32, cfg: MpsConfig) -> Result { + if n == 0 || n > MAX_QUBITS { + return Err(MpsError::TooManyQubits(n)); + } + let n = n as usize; + Ok(Chain { + n, + a: (0..n).map(|_| vec![C::ONE, C::ZERO]).collect(), + dl: vec![1; n], + dr: vec![1; n], + center: 0, + cfg: MpsConfig { max_bond: cfg.max_bond.max(1), cutoff: cfg.cutoff.max(0.0) }, + max_bond_used: 1, + discarded: 0.0, + fidelity: 1.0, + truncations: 0, + updates: 0, + }) + } + + /// The circuit `gates` on `n` qubits from `|0…0⟩`. + pub fn run(n: u32, gates: &[Gate], cfg: MpsConfig) -> Result { + let mut c = Chain::new(n, cfg)?; + for (i, g) in gates.iter().enumerate() { + c.apply(g).map_err(|_| MpsError::BadGate(i))?; + } + Ok(c) + } + + pub fn qubits(&self) -> u32 { + self.n as u32 + } + + /// The truncations so far. + pub fn report(&self) -> MpsReport { + MpsReport { + max_bond_used: self.max_bond_used, + bonds: self.dr[..self.n - 1].to_vec(), + discarded: self.discarded, + fidelity_estimate: self.fidelity, + truncations: self.truncations, + updates: self.updates, + } + } + + /// Moves the orthogonality center one site right, by QR of the center. + fn shift_right(&mut self) { + let i = self.center; + let (dl, dr) = (self.dl[i], self.dr[i]); + let rows = dl * 2; + // Columns of the (dl·2) × dr matrix. + let cols: Vec> = (0..dr).map(|r| (0..rows).map(|ls| self.a[i][ls * dr + r]).collect()).collect(); + let (q, rm) = qr(&cols, rows); + let k = q.len(); + let mut na = vec![C::ZERO; rows * k]; + for (c, qc) in q.iter().enumerate() { + for ls in 0..rows { + na[ls * k + c] = qc[ls]; + } + } + // A[i+1] ← R · A[i+1]. + let (ndr, nb_old) = (self.dr[i + 1], &self.a[i + 1]); + let mut nb = vec![C::ZERO; k * 2 * ndr]; + for c in 0..k { + for j in 0..dr { + let rc = rm[c][j]; + if rc.re == 0.0 && rc.im == 0.0 { + continue; + } + for s in 0..2 { + for r in 0..ndr { + let idx = at(c, s, r, ndr); + nb[idx] = nb[idx].add(rc.mul(nb_old[at(j, s, r, ndr)])); + } + } + } + } + self.a[i] = na; + self.dr[i] = k; + self.a[i + 1] = nb; + self.dl[i + 1] = k; + self.center = i + 1; + } + + /// Moves the orthogonality center one site left, by LQ of the center + /// (QR of its conjugate transpose). + fn shift_left(&mut self) { + let i = self.center; + let (dl, dr) = (self.dl[i], self.dr[i]); + let width = 2 * dr; + // Columns of the conjugate transpose: one per left index l. + let cols: Vec> = (0..dl).map(|l| (0..width).map(|sr| self.a[i][l * width + sr].conj()).collect()).collect(); + let (q, rm) = qr(&cols, width); + let k = q.len(); + // A[i] ← Q† as (k, 2, dr). + let mut na = vec![C::ZERO; k * width]; + for (c, qc) in q.iter().enumerate() { + for sr in 0..width { + na[c * width + sr] = qc[sr].conj(); + } + } + // A[i−1] ← A[i−1] · R†. + let (pdl, old) = (self.dl[i - 1], &self.a[i - 1]); + let pdr = self.dr[i - 1]; + let mut nb = vec![C::ZERO; pdl * 2 * k]; + for l in 0..pdl { + for s in 0..2 { + for c in 0..k { + let mut acc = C::ZERO; + for j in 0..pdr { + acc = acc.add(old[at(l, s, j, pdr)].mul(rm[c][j].conj())); + } + nb[at(l, s, c, k)] = acc; + } + } + } + self.a[i] = na; + self.dl[i] = k; + self.a[i - 1] = nb; + self.dr[i - 1] = k; + self.center = i - 1; + } + + fn move_center(&mut self, to: usize) { + while self.center < to { + self.shift_right(); + } + while self.center > to { + self.shift_left(); + } + } + + fn apply1(&mut self, i: usize, g: &[C]) { + let (dl, dr) = (self.dl[i], self.dr[i]); + let a = &mut self.a[i]; + for l in 0..dl { + for r in 0..dr { + let (a0, a1) = (a[at(l, 0, r, dr)], a[at(l, 1, r, dr)]); + a[at(l, 0, r, dr)] = g[0].mul(a0).add(g[1].mul(a1)); + a[at(l, 1, r, dr)] = g[2].mul(a0).add(g[3].mul(a1)); + } + } + } + + /// A two-qubit gate on sites `i`, `i + 1`, its 4×4 matrix with site `i` + /// the more significant bit; the center ends on `i + 1`. + fn apply2_adjacent(&mut self, i: usize, g: &[C]) { + self.move_center(i); + let (dl, dc, dr) = (self.dl[i], self.dr[i], self.dr[i + 1]); + let (rows, cols) = (dl * 2, 2 * dr); + // M[(l, t1), (t2, r)] = Σ G[(t1 t2), (s1 s2)] Σ_c A[l,s1,c] B[c,s2,r], by columns. + let mut m: Vec> = vec![vec![C::ZERO; rows]; cols]; + let (ai, bi) = (&self.a[i], &self.a[i + 1]); + for l in 0..dl { + for r in 0..dr { + let mut th = [C::ZERO; 4]; + for s1 in 0..2 { + for s2 in 0..2 { + let mut acc = C::ZERO; + for c in 0..dc { + acc = acc.add(ai[at(l, s1, c, dc)].mul(bi[at(c, s2, r, dr)])); + } + th[s1 * 2 + s2] = acc; + } + } + for t1 in 0..2 { + for t2 in 0..2 { + let mut v = C::ZERO; + for s in 0..4 { + v = v.add(g[(t1 * 2 + t2) * 4 + s].mul(th[s])); + } + m[t2 * dr + r][l * 2 + t1] = v; + } + } + } + } + // The kept left space: the leading eigenvectors of B·B†, where B is M + // itself when it is at most as tall as wide, and R of M = Q·R + // otherwise (then Q carries the left side). Each kept vector u gives + // the centre's row u†·B, so no singular value is ever divided by. + let total: f64 = m.iter().flatten().map(|x| x.norm2()).sum(); + let (left, b): (Option>>, Vec>) = if rows <= cols { + (None, m) + } else { + let (q, r) = qr(&m, rows); + let b = (0..cols).map(|j| (0..q.len()).map(|k| r[k][j]).collect()).collect(); + (Some(q), b) + }; + let h = left.as_ref().map_or(rows, |q| q.len()); + let mut gram = vec![C::ZERO; h * h]; + for col in &b { + for (x, &ca) in col.iter().enumerate() { + if ca.re == 0.0 && ca.im == 0.0 { + continue; + } + let row = &mut gram[x * h..(x + 1) * h]; + for (g, cb) in row.iter_mut().zip(col) { + *g = g.add(ca.mul(cb.conj())); + } + } + } + let eig = hermitian_eigh(gram, h); + // Keep the largest, at most max_bond, dropping those whose share of + // the total is under the cutoff (or under the rounding share). + let floor = self.cfg.cutoff.max(ROUNDING_SHARE); + let mut order: Vec = (0..h).collect(); + order.sort_by(|&x, &y| eig.values[y].partial_cmp(&eig.values[x]).unwrap().then(x.cmp(&y))); + let mut keep = Vec::new(); + for &k in &order { + if keep.len() >= self.cfg.max_bond { + break; + } + let lambda = eig.values[k].max(0.0); + let share = if total > 0.0 { lambda / total } else { 0.0 }; + if (share <= floor || lambda == 0.0) && !keep.is_empty() { + break; + } + keep.push(k); + } + let us: Vec> = keep.iter().map(|&k| eig.vector(k)).collect(); + let chi = keep.len(); + // The centre: row c is u_c†·B. + let mut nb = vec![C::ZERO; chi * cols]; + for (c, u) in us.iter().enumerate() { + for (tr, col) in b.iter().enumerate() { + let mut acc = C::ZERO; + for (uk, bk) in u.iter().zip(col) { + acc = acc.add(uk.conj().mul(*bk)); + } + nb[c * cols + tr] = acc; + } + } + let kept: f64 = nb.iter().map(|x| x.norm2()).sum(); + let eps = if total > 0.0 { (1.0 - kept / total).max(0.0) } else { 0.0 }; + self.updates += 1; + if eps > ROUNDING_LOSS { + self.truncations += 1; + self.discarded += eps; + self.fidelity *= 1.0 - eps; + } + self.max_bond_used = self.max_bond_used.max(chi); + // The kept part renormalised, so the state keeps unit norm. + let renorm = if kept > 0.0 { (total / kept).sqrt() } else { 1.0 }; + for x in nb.iter_mut() { + *x = x.scale(renorm); + } + // The left side: the kept vectors, through Q when B was R. + let mut na = vec![C::ZERO; rows * chi]; + for (c, u) in us.iter().enumerate() { + match &left { + None => { + for lt in 0..rows { + na[lt * chi + c] = u[lt]; + } + } + Some(q) => { + for lt in 0..rows { + let mut acc = C::ZERO; + for (qk, uk) in q.iter().zip(u) { + acc = acc.add(qk[lt].mul(*uk)); + } + na[lt * chi + c] = acc; + } + } + } + } + self.a[i] = na; + self.dr[i] = chi; + self.a[i + 1] = nb; + self.dl[i + 1] = chi; + self.center = i + 1; + } + + /// Applies a one- or two-qubit gate (its matrix with `qubits[0]` the + /// most significant bit). + pub fn apply(&mut self, g: &Gate) -> Result<(), MpsError> { + let n = self.n; + match *g.qubits.as_slice() { + [q] if (q as usize) < n && g.matrix.len() == 4 => { + self.apply1(q as usize, &g.matrix); + Ok(()) + } + [a, b] if a != b && (a as usize) < n && (b as usize) < n && g.matrix.len() == 16 => { + let (a, b) = (a as usize, b as usize); + // The matrix with the lower site the more significant bit. + let m: Vec = if a < b { + g.matrix.clone() + } else { + let p = |x: usize| (x & 1) << 1 | (x >> 1); + (0..16).map(|k| g.matrix[p(k / 4) * 4 + p(k % 4)]).collect() + }; + let (lo, hi) = (a.min(b), a.max(b)); + let swap = Gate::swap(0, 1).matrix; + // Bring `hi` down next to `lo`, apply, and take it back. + for k in (lo + 1..hi).rev() { + self.apply2_adjacent(k, &swap); + } + self.apply2_adjacent(lo, &m); + for k in lo + 1..hi { + self.apply2_adjacent(k, &swap); + } + Ok(()) + } + _ => Err(MpsError::BadGate(0)), + } + } + + /// `⟨bits|ψ⟩`, one bit per qubit, qubit 0 first. + pub fn amplitude(&self, bits: &[u8]) -> Result { + if bits.len() != self.n || bits.iter().any(|&b| b > 1) { + return Err(MpsError::BadQuery(format!("a bit string of {} bits, each 0 or 1", self.n))); + } + let mut v = vec![C::ONE]; + for i in 0..self.n { + let (dl, dr) = (self.dl[i], self.dr[i]); + let s = bits[i] as usize; + let mut w = vec![C::ZERO; dr]; + for l in 0..dl { + if v[l].re == 0.0 && v[l].im == 0.0 { + continue; + } + for r in 0..dr { + w[r] = w[r].add(v[l].mul(self.a[i][at(l, s, r, dr)])); + } + } + v = w; + } + Ok(v[0]) + } + + /// `⟨ψ|P|ψ⟩` for the Pauli string `P` (`(qubit, 'X'|'Y'|'Z'|'I')`). + pub fn expectation(&self, pauli: &[(u32, char)]) -> Result { + let n = self.n; + let mut op: Vec<[C; 4]> = vec![[C::ONE, C::ZERO, C::ZERO, C::ONE]; n]; + let mut seen = vec![false; n]; + for &(q, p) in pauli { + let q = q as usize; + if q >= n || seen[q] { + return Err(MpsError::BadQuery(format!("qubit {q} is outside the chain or named twice"))); + } + seen[q] = true; + let z = C::ZERO; + let one = C::ONE; + op[q] = match p.to_ascii_uppercase() { + 'X' => [z, one, one, z], + 'Y' => [z, C { re: 0.0, im: -1.0 }, C { re: 0.0, im: 1.0 }, z], + 'Z' => [one, z, z, C { re: -1.0, im: 0.0 }], + 'I' => op[q], + other => return Err(MpsError::BadQuery(format!("{other:?} is not a Pauli factor"))), + }; + } + // E[l, l'] over the bond, from the left: Σ conj(A[l,s,r]) P[s,s'] A[l',s',r']. + let mut e = vec![C::ONE]; + let mut d = 1usize; + for i in 0..n { + let (dl, dr) = (self.dl[i], self.dr[i]); + debug_assert_eq!(dl, d); + let a = &self.a[i]; + let p = op[i]; + let mut ne = vec![C::ZERO; dr * dr]; + // T[l', s, r] = Σ_l E[l, l'] conj(A[l, s, r])... done as two steps. + let mut t = vec![C::ZERO; dl * 2 * dr]; + for l in 0..dl { + for lp in 0..dl { + let el = e[l * dl + lp]; + if el.re == 0.0 && el.im == 0.0 { + continue; + } + for s in 0..2 { + for r in 0..dr { + let idx = (lp * 2 + s) * dr + r; + t[idx] = t[idx].add(el.mul(a[at(l, s, r, dr)].conj())); + } + } + } + } + for lp in 0..dl { + for s in 0..2 { + for sp in 0..2 { + let ps = p[s * 2 + sp]; + if ps.re == 0.0 && ps.im == 0.0 { + continue; + } + for r in 0..dr { + let tv = t[(lp * 2 + s) * dr + r].mul(ps); + for rp in 0..dr { + ne[r * dr + rp] = ne[r * dr + rp].add(tv.mul(a[at(lp, sp, rp, dr)])); + } + } + } + } + } + e = ne; + d = dr; + } + Ok(e[0].re) + } + + /// `shots` measurements of every qubit from `seed`: one bit per qubit, + /// qubit 0 first, for each shot. + pub fn sample(&mut self, shots: u64, seed: u64) -> Vec> { + self.move_center(0); + let n = self.n; + let mut rng = seed ^ 0x3A5D_0C1E_5EED_0002; + let mut out = Vec::with_capacity(shots as usize); + for _ in 0..shots { + let mut bits = vec![0u8; n]; + let mut v = vec![C::ONE]; + for i in 0..n { + let (dl, dr) = (self.dl[i], self.dr[i]); + let mut w = [vec![C::ZERO; dr], vec![C::ZERO; dr]]; + for s in 0..2 { + for l in 0..dl { + if v[l].re == 0.0 && v[l].im == 0.0 { + continue; + } + for r in 0..dr { + w[s][r] = w[s][r].add(v[l].mul(self.a[i][at(l, s, r, dr)])); + } + } + } + let p0: f64 = w[0].iter().map(|x| x.norm2()).sum(); + let p1: f64 = w[1].iter().map(|x| x.norm2()).sum(); + let u = ((splitmix(&mut rng) >> 11) as f64 + 0.5) / 9_007_199_254_740_992.0; + let s = if u * (p0 + p1) < p0 { 0 } else { 1 }; + bits[i] = s as u8; + let ps = if s == 0 { p0 } else { p1 }; + let norm = ps.sqrt().max(1e-300); + v = w[s].iter().map(|x| x.scale(1.0 / norm)).collect(); + } + out.push(bits); + } + out + } + + /// `⟨ψ|ψ⟩`, 1 up to rounding. + pub fn norm2(&self) -> f64 { + self.expectation(&[]).unwrap_or(0.0) + } +} + +fn splitmix(s: &mut u64) -> u64 { + *s = s.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *s; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::quantum_sv::StateVector; + + fn random_circuit(n: u32, depth: usize, seed: u64) -> Vec { + let mut s = seed; + let mut next = || splitmix(&mut s); + let mut gates = Vec::new(); + for _ in 0..depth { + for q in 0..n { + let t = (next() % 1000) as f64 / 159.0; + gates.push(match next() % 4 { + 0 => Gate::rx(q, t), + 1 => Gate::ry(q, t), + 2 => Gate::rz(q, t), + _ => Gate::h(q), + }); + } + for _ in 0..n / 2 { + let a = (next() % n as u64) as u32; + let mut b = (next() % n as u64) as u32; + if b == a { + b = (a + 1 + (next() % (n as u64 - 1)) as u32) % n; + } + let t = (next() % 1000) as f64 / 211.0; + gates.push(match next() % 4 { + 0 => Gate::cx(a, b), + 1 => Gate::cz(a, b), + 2 => Gate::rzz(a, b, t), + _ => Gate::fsim(a, b, t, t / 3.0), + }); + } + } + gates + } + + fn dense(n: u32, gates: &[Gate]) -> StateVector { + let mut sv = StateVector::zero(n).unwrap(); + sv.run(gates, 1).unwrap(); + sv + } + + /// The dense index of a bit string (qubit 0 the most significant bit). + fn index(bits: &[u8]) -> usize { + bits.iter().fold(0, |x, &b| x << 1 | b as usize) + } + + #[test] + fn without_truncation_it_is_the_dense_state() { + for seed in 0..8 { + let n = 3 + (seed % 6) as u32; + let gates = random_circuit(n, 6, seed); + let chain = Chain::run(n, &gates, MpsConfig { max_bond: 1 << 12, cutoff: 0.0 }).unwrap(); + let sv = dense(n, &gates); + let r = chain.report(); + assert_eq!(r.truncations, 0, "seed {seed}"); + assert!((chain.norm2() - 1.0).abs() < 1e-10, "seed {seed}: norm {}", chain.norm2()); + for x in 0..1usize << n { + let bits: Vec = (0..n).map(|q| (x >> (n - 1 - q) & 1) as u8).collect(); + let a = chain.amplitude(&bits).unwrap(); + let (wr, wi) = (sv.re[index(&bits)], sv.im[index(&bits)]); + assert!((a.re - wr).abs() < 1e-10 && (a.im - wi).abs() < 1e-10, "seed {seed}: {bits:?}: {a:?} vs ({wr}, {wi})"); + } + let mut s = seed; + for _ in 0..12 { + let string: Vec<(u32, char)> = (0..n).filter_map(|q| match splitmix(&mut s) % 4 { 0 => None, 1 => Some((q, 'X')), 2 => Some((q, 'Y')), _ => Some((q, 'Z')) }).collect(); + let want = sv.expectation(&[(1.0, string.clone())]); + let got = chain.expectation(&string).unwrap(); + assert!((got - want).abs() < 1e-10, "seed {seed}: {string:?}: {got} vs {want}"); + } + } + } + + #[test] + fn its_samples_follow_the_state() { + let n = 6; + let gates = random_circuit(n, 4, 77); + let mut chain = Chain::run(n, &gates, MpsConfig::default()).unwrap(); + let sv = dense(n, &gates); + let shots = 40_000; + let mut counts = vec![0u64; 1 << n]; + for b in chain.sample(shots, 5) { + counts[index(&b)] += 1; + } + // Each outcome's frequency within five standard errors of its probability. + for x in 0..1usize << n { + let p = sv.re[x] * sv.re[x] + sv.im[x] * sv.im[x]; + let f = counts[x] as f64 / shots as f64; + let se = (p * (1.0 - p) / shots as f64).sqrt().max(1e-4); + assert!((f - p).abs() < 5.0 * se, "outcome {x}: {f} vs {p}"); + } + } + + #[test] + fn truncation_is_reported_and_the_estimate_tracks_the_fidelity() { + let n = 12; + let gates = random_circuit(n, 8, 3); + let sv = dense(n, &gates); + for chi in [4usize, 8, 16] { + let chain = Chain::run(n, &gates, MpsConfig { max_bond: chi, cutoff: 0.0 }).unwrap(); + let r = chain.report(); + assert!(r.max_bond_used <= chi && r.truncations > 0, "chi {chi}: {r:?}"); + // |⟨exact|mps⟩|² + let (mut re, mut im) = (0.0, 0.0); + for x in 0..1usize << n { + let bits: Vec = (0..n).map(|q| (x >> (n - 1 - q) & 1) as u8).collect(); + let a = chain.amplitude(&bits).unwrap(); + re += sv.re[x] * a.re + sv.im[x] * a.im; + im += sv.re[x] * a.im - sv.im[x] * a.re; + } + let fidelity = re * re + im * im; + assert!(fidelity <= 1.0 + 1e-9); + assert!((fidelity - r.fidelity_estimate).abs() < 0.15, "chi {chi}: fidelity {fidelity}, estimate {}", r.fidelity_estimate); + } + } + + #[test] + fn a_thousand_qubit_ghz_chain_has_bond_two() { + let n = 1000; + let mut gates = vec![Gate::h(0)]; + gates.extend((0..n - 1).map(|q| Gate::cx(q, q + 1))); + let mut chain = Chain::run(n, &gates, MpsConfig::default()).unwrap(); + let r = chain.report(); + assert_eq!((r.max_bond_used, r.truncations), (2, 0)); + assert!((chain.expectation(&[(0, 'Z'), (n - 1, 'Z')]).unwrap() - 1.0).abs() < 1e-10); + assert!(chain.expectation(&[(3, 'Z')]).unwrap().abs() < 1e-10); + for b in chain.sample(50, 1) { + assert!(b.iter().all(|&x| x == b[0])); + } + // A long-range gate routes through swaps and keeps the state right. + let mut gates = vec![Gate::h(0), Gate::cx(0, 39)]; + gates.push(Gate::x(20)); + let chain = Chain::run(40, &gates, MpsConfig::default()).unwrap(); + assert!((chain.expectation(&[(0, 'Z'), (39, 'Z')]).unwrap() - 1.0).abs() < 1e-10); + assert!((chain.expectation(&[(20, 'Z')]).unwrap() + 1.0).abs() < 1e-10); + } + + #[test] + fn refusals_are_typed() { + assert_eq!(Chain::new(MAX_QUBITS + 1, MpsConfig::default()).unwrap_err(), MpsError::TooManyQubits(MAX_QUBITS + 1)); + let mut c = Chain::new(3, MpsConfig::default()).unwrap(); + assert!(c.apply(&Gate::h(3)).is_err()); + assert!(c.apply(&Gate::cx(1, 1)).is_err()); + assert!(c.amplitude(&[0, 1]).is_err()); + assert!(c.expectation(&[(0, 'Q')]).is_err()); + } + + /// A brickwork of nearest-neighbour two-qubit rotations between layers + /// of one-qubit rotations: the circuits a chain is for. + fn brickwork(n: u32, depth: usize, seed: u64) -> Vec { + let mut s = seed; + let mut gates = Vec::new(); + for layer in 0..depth { + for q in 0..n { + let t = (splitmix(&mut s) % 1000) as f64 / 700.0; + gates.push(if q % 2 == 0 { Gate::ry(q, t) } else { Gate::rx(q, t) }); + } + for q in (layer as u32 % 2..n - 1).step_by(2) { + let t = (splitmix(&mut s) % 1000) as f64 / 900.0; + gates.push(Gate::rzz(q, q + 1, t)); + gates.push(Gate::cx(q, q + 1)); + } + } + gates + } + + /// Times at scale; run with `--ignored --nocapture`. + #[test] + #[ignore] + fn timings_at_scale() { + for (n, depth, chi) in [(100u32, 10usize, 32usize), (100, 10, 64), (100, 20, 64), (500, 10, 64), (100, 20, 128)] { + let gates = brickwork(n, depth, n as u64); + let t0 = std::time::Instant::now(); + let mut chain = Chain::run(n, &gates, MpsConfig { max_bond: chi, cutoff: 1e-14 }).unwrap(); + let run = t0.elapsed(); + let r = chain.report(); + let t1 = std::time::Instant::now(); + let s = chain.sample(1000, 1); + let shots = t1.elapsed(); + println!( + "n={n} depth={depth} chi={chi}: {} gates in {run:?} ({} updates, {} truncated); max bond {}, fidelity estimate {:.4}; 1000 shots in {shots:?} ({} bits)", + gates.len(), + r.updates, + r.truncations, + r.max_bond_used, + r.fidelity_estimate, + s[0].len() + ); + } + } +} diff --git a/wai/wai-quantum/src/quantum_noisy.rs b/wai/wai-quantum/src/quantum_noisy.rs index b25e6b870..91211cb0e 100644 --- a/wai/wai-quantum/src/quantum_noisy.rs +++ b/wai/wai-quantum/src/quantum_noisy.rs @@ -24,6 +24,14 @@ //! //! Averages over trajectories converge to the noisy expectation, with a //! standard error. +//! - **Branching trajectories.** [`branching_trajectories`] runs many shots +//! with the same law, but shots that took the same turns share one state. +//! At each noise event a group splits as independent shots would: binomial +//! counts err, and damping jumps by the group's own probability. The +//! error-free prefix and the error-free majority therefore run once. On a +//! 16-core desktop processor, 300 shots of an 18-qubit brickwork circuit +//! run 28 times faster than independent trajectories at two-qubit error +//! 10⁻³, 5.6 times at 5·10⁻³, and 1.9 times at 2·10⁻² with damping. //! - **The exact density matrix.** [`DensityMatrix`] holds `ρ` as a state on //! `2n` qubits: rows are qubits `0..n`, columns `n..2n`. A gate `U` acts as //! `U ⊗ U*`, and a channel as its Kraus sum. It reuses the state vector's @@ -43,6 +51,11 @@ //! standard errors of the exact expectations. //! - **Sampling.** Sampled bitstrings, with readout flips, follow the exact //! distribution, and the sampled XEB converges to [`exact_xeb`]. +//! - **Branching trajectories** land within four standard errors of the exact +//! expectations for three seeds. Their counts, with readout flips, follow the +//! exact distribution. They give the same estimates, counts and histories +//! at any thread count and any state limit. The binomial sampler has the +//! binomial mean and variance from 6 trials to a million. //! //! # What the digital error model gets wrong //! @@ -292,7 +305,7 @@ pub fn trajectories(gates: &[Gate], n: u32, noise: &NoiseModel, observables: &[O if t >= count { break; } - *slots[t].lock().unwrap() = Some(run(t)); + *slots[t].lock().unwrap() = Some(crate::quantum_sv::on_one_thread(|| run(t))); }); } }); @@ -310,6 +323,396 @@ pub fn trajectories(gates: &[Gate], n: u32, noise: &NoiseModel, observables: &[O Ok(TrajectoryRun { expectations: per_obs.iter().map(|v| estimate(v)).collect(), samples }) } +// --------------------------------------------------------------------------- +// Branching trajectories: shots that share a history share a state +// --------------------------------------------------------------------------- + +/// One step of a noisy run: a gate, or one noise channel after it, in the +/// order [`trajectories`] applies them. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Event { + Gate(usize), + Depol2(u32, u32), + Depol1(u32), + Dephase(u32), + Damp(u32), +} + +fn events(gates: &[Gate], noise: &NoiseModel) -> Vec { + let mut out = Vec::new(); + for (i, g) in gates.iter().enumerate() { + out.push(Event::Gate(i)); + let k = g.qubits.len(); + if k == 2 && noise.depol2 > 0.0 { + out.push(Event::Depol2(g.qubits[0], g.qubits[1])); + } + for &q in &g.qubits { + if k != 2 && noise.depol1 > 0.0 { + out.push(Event::Depol1(q)); + } + if noise.dephasing > 0.0 { + out.push(Event::Dephase(q)); + } + if noise.damping > 0.0 { + out.push(Event::Damp(q)); + } + } + } + out +} + +/// The number of successes in `c` trials of probability `p`, exactly +/// distributed: successes are placed by geometric gaps, so the cost is the +/// number of successes (of the rarer outcome), not of trials. +fn binomial(c: u64, p: f64, rng: &mut Rng) -> u64 { + if c == 0 || p <= 0.0 { + return 0; + } + if p >= 1.0 { + return c; + } + if p > 0.5 { + return c - binomial(c, 1.0 - p, rng); + } + if c <= 8 { + return (0..c).filter(|_| rng.unit() < p).count() as u64; + } + let lq = crate::repro::ln(1.0 - p); + let (mut k, mut at) = (0u64, 0u64); + loop { + // A uniform on (0, 1]; the gap to the next success is ≥ 1. + let u = 1.0 - rng.unit(); + let gap = (crate::repro::ln(u) / lq).floor(); + at = at.saturating_add(if gap >= 1.8e19 { u64::MAX } else { gap as u64 + 1 }); + if at > c { + return k; + } + k += 1; + } +} + +/// A turn a history took at a noise event other than the event's default (no +/// error, or no jump): the event and the branch (a Pauli's index, or 1 for a +/// jump). +type Turn = (usize, u8); + +/// Each observable's weighted mean and spread, Welford's update with weights. +#[derive(Clone, Copy, Debug, Default)] +struct Moments { + weight: f64, + mean: f64, + m2: f64, +} + +impl Moments { + fn add(&mut self, v: f64, w: f64) { + let total = self.weight + w; + let d = v - self.mean; + self.mean += d * w / total; + self.m2 += w * d * (v - self.mean); + self.weight = total; + } + fn estimate(&self) -> Estimate { + let var = if self.weight > 1.0 { self.m2 / (self.weight - 1.0) } else { 0.0 }; + Estimate { mean: self.mean, stderr: (var / self.weight).sqrt() } + } +} + +/// What a branching run found. +#[derive(Clone, Debug, PartialEq)] +pub struct BranchingRun { + /// Each observable's mean over the shots, each shot a trajectory, and its + /// standard error. + pub expectations: Vec, + /// Each measured bitstring (readout noise included) and its count, + /// ascending. + pub counts: Vec<(u64, u64)>, + /// The shots. + pub shots: u64, + /// Distinct histories run to the end: one state vector each. + pub histories: u64, + /// Gates applied, replays included (so this alone depends on the state + /// limit); independent trajectories apply `shots × gates`. + pub gate_applications: u64, +} + +/// One leaf: its history, its shots and its observable values. +type Leaf = (Vec, u64, Vec); + +struct Tree<'a> { + gates: &'a [Gate], + events: Vec, + noise: &'a NoiseModel, + observables: &'a [Observable], + n: u32, + max_states: usize, +} + +/// A group of shots waiting to run: its history, the event it resumes at, its +/// shots and its stream. +type Pending = (Vec, usize, u64, Rng); + +#[derive(Default)] +struct Tally { + counts: std::collections::BTreeMap, + leaves: Vec, + applications: u64, +} + +impl Tree<'_> { + /// Run the shots of one tree from `|0…0⟩`. + fn run(&self, shots: u64, seed: u64) -> Result { + let mut tally = Tally::default(); + if shots == 0 { + return Ok(tally); + } + let mut pending: Vec = vec![(Vec::new(), 0, shots, trajectory_rng(seed, 0))]; + while let Some((path, resume, shots, rng)) = pending.pop() { + let sv = self.replay(&path, resume, &mut tally)?; + self.descend(sv, resume, shots, rng, path, 1, &mut pending, &mut tally)?; + } + Ok(tally) + } + + /// The state a history has reached before event `end`: every gate up to + /// there, each recorded turn, and each other event's default. + fn replay(&self, path: &[Turn], end: usize, tally: &mut Tally) -> Result { + let mut sv = StateVector::zero(self.n)?; + let mut turns = path.iter().peekable(); + for e in 0..end { + let branch = match turns.peek() { + Some(&&(at, b)) if at == e => { + turns.next(); + b + } + _ => 0, + }; + if let Event::Gate(i) = self.events[e] { + sv.apply(&self.gates[i])?; + tally.applications += 1; + } else { + let jump = self.jump(&sv, self.events[e]); + self.take(&mut sv, self.events[e], branch, jump)?; + } + } + Ok(sv) + } + + /// A damping event's jump probability in this state (0 for others). + fn jump(&self, sv: &StateVector, ev: Event) -> f64 { + match ev { + Event::Damp(q) => self.noise.damping * prob_one(sv, q), + _ => 0.0, + } + } + + /// Apply branch `b` of a noise event. + fn take(&self, sv: &mut StateVector, ev: Event, b: u8, jump: f64) -> Result<(), SvError> { + match ev { + Event::Gate(_) => {} + Event::Depol2(a, c) => { + if b != 0 { + sv.apply(&pauli(a, b >> 2))?; + sv.apply(&pauli(c, b & 3))?; + } + } + Event::Depol1(q) => { + if b != 0 { + sv.apply(&pauli(q, b))?; + } + } + Event::Dephase(q) => { + if b != 0 { + sv.apply(&Gate::z(q))?; + } + } + Event::Damp(q) => { + let [k0, k1] = damping_kraus(q, self.noise.damping); + if b != 0 { + sv.apply(&k1)?; + scale(sv, 1.0 / jump.sqrt()); + } else { + sv.apply(&k0)?; + scale(sv, 1.0 / (1.0 - jump).sqrt()); + } + } + } + Ok(()) + } + + /// How a group's `shots` split at a noise event: shots per branch, branch + /// 0 the default. + fn split(&self, ev: Event, shots: u64, jump: f64, rng: &mut Rng) -> Vec<(u8, u64)> { + let mut by = [0u64; 16]; + match ev { + Event::Gate(_) => {} + Event::Depol2(..) => { + for _ in 0..binomial(shots, self.noise.depol2, rng) { + by[1 + (rng.next() % 15) as usize] += 1; + } + } + Event::Depol1(_) => { + for _ in 0..binomial(shots, self.noise.depol1, rng) { + by[1 + (rng.next() % 3) as usize] += 1; + } + } + Event::Dephase(_) => by[1] = binomial(shots, self.noise.dephasing, rng), + Event::Damp(_) => by[1] = binomial(shots, jump, rng), + } + by[0] = shots - by[1..].iter().sum::(); + (0..16u8).zip(by).filter(|&(_, c)| c > 0).collect() + } + + /// Run a group of `shots` from event `e` to the end. At each split the + /// largest branch carries on in this state, and each other branch (at most + /// half the shots) runs in a copy first, while fewer than `max_states` + /// states are alive; past that it waits, and is replayed from `|0…0⟩`. + #[allow(clippy::too_many_arguments)] + fn descend(&self, mut sv: StateVector, mut e: usize, mut shots: u64, mut rng: Rng, mut path: Vec, live: usize, pending: &mut Vec, tally: &mut Tally) -> Result<(), SvError> { + while e < self.events.len() { + let ev = self.events[e]; + if let Event::Gate(i) = ev { + sv.apply(&self.gates[i])?; + tally.applications += 1; + e += 1; + continue; + } + let jump = self.jump(&sv, ev); + let branches = self.split(ev, shots, jump, &mut rng); + // The largest carries on; ties go to the lower branch. + let (keep, kept) = branches.iter().copied().fold((0u8, 0u64), |best, b| if b.1 > best.1 { b } else { best }); + for &(b, c) in &branches { + if b == keep { + continue; + } + let child_rng = Rng(rng.next()); + let mut child_path = path.clone(); + if b != 0 { + child_path.push((e, b)); + } + if live < self.max_states { + let mut child = sv.clone(); + self.take(&mut child, ev, b, jump)?; + self.descend(child, e + 1, c, child_rng, child_path, live + 1, pending, tally)?; + } else { + pending.push((child_path, e + 1, c, child_rng)); + } + } + self.take(&mut sv, ev, keep, jump)?; + if keep != 0 { + path.push((e, keep)); + } + shots = kept; + e += 1; + } + // The end of a history: its observables, its shots' outcomes, and + // readout flips placed by geometric gaps over the shots' bits. + let values = self.observables.iter().map(|o| sv.expectation(o)).collect(); + let mut xs = sv.sample(shots as usize, rng.next()); + let n = u64::from(self.n); + if self.noise.readout > 0.0 { + let trials = shots * n; + let mut at = 0u64; + loop { + let skip = binomial_gap(self.noise.readout, &mut rng); + at = at.saturating_add(skip); + if at > trials { + break; + } + let bit = at - 1; + xs[(bit / n) as usize] ^= 1 << (n - 1 - bit % n); + } + } + for x in xs { + *tally.counts.entry(x).or_insert(0) += 1; + } + tally.leaves.push((path, shots, values)); + Ok(()) + } +} + +/// The gap (at least 1) to the next success in trials of probability `p`. +fn binomial_gap(p: f64, rng: &mut Rng) -> u64 { + if p >= 1.0 { + return 1; + } + let u = 1.0 - rng.unit(); + let gap = (crate::repro::ln(u) / crate::repro::ln(1.0 - p)).floor(); + if gap >= 1.8e19 { u64::MAX } else { gap as u64 + 1 } +} + +/// `shots` noisy shots of `gates` on `n` qubits, each an independent +/// trajectory in law, run so that shots sharing a history share one state. +/// +/// At each noise event a group of shots splits as independent shots would: +/// the number that err is binomial, and the erring ones choose their Pauli +/// uniformly; a damping event's jumps are binomial in the group's own jump +/// probability. Shots that took the same turns hold the same state, so a +/// circuit's error-free prefix, and its error-free majority, run once. At the +/// end each history is measured for its shots. The counts and the means +/// therefore have the law of `shots` independent trajectories +/// ([`trajectories`]), at a fraction of the work when errors are rare. +/// +/// At most `max_states` state vectors are alive at once (at least one); a +/// branch that finds the limit reached waits and is replayed from +/// `|0…0⟩`. Below 16 qubits the shots are divided among 32 trees, run on up to +/// `threads` threads; from 16 qubits up, one tree, and the state vector's own +/// threads. Neither `threads` nor `max_states` changes the estimates, the +/// counts or the histories; replays add only to `gate_applications`. +#[allow(clippy::too_many_arguments)] +pub fn branching_trajectories(gates: &[Gate], n: u32, noise: &NoiseModel, observables: &[Observable], shots: u64, seed: u64, max_states: usize, threads: usize) -> Result { + for g in gates { + if g.qubits.iter().any(|&q| q >= n) { + return Err(SvError::BadGate(0)); + } + } + let tree = Tree { gates, events: events(gates, noise), noise, observables, n, max_states: max_states.max(1) }; + let trees = if n >= 16 { 1 } else { shots.clamp(1, 32) }; + let part = |t: u64| shots / trees + u64::from(t < shots % trees); + let seed_of = |t: u64| seed ^ t.wrapping_mul(0x9e6c_63d0_676a_9a99); + let threads = if cfg!(target_arch = "wasm32") { 1 } else { threads.max(1).min(trees as usize) }; + let tallies: Vec> = if threads == 1 { + (0..trees).map(|t| tree.run(part(t), seed_of(t))).collect() + } else { + // A word-sized counter: some targets have no 64-bit atomics. + let next = std::sync::atomic::AtomicUsize::new(0); + let slots: Vec>>> = (0..trees).map(|_| std::sync::Mutex::new(None)).collect(); + std::thread::scope(|scope| { + for _ in 0..threads { + scope.spawn(|| loop { + let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if i as u64 >= trees { + break; + } + let t = i as u64; + *slots[i].lock().unwrap() = Some(crate::quantum_sv::on_one_thread(|| tree.run(part(t), seed_of(t)))); + }); + } + }); + slots.into_iter().map(|m| m.into_inner().unwrap().expect("every tree ran")).collect() + }; + let mut counts = std::collections::BTreeMap::new(); + let mut moments = vec![Moments::default(); observables.len()]; + let (mut histories, mut gate_applications) = (0u64, 0u64); + for t in tallies { + let mut t = t?; + for (x, c) in t.counts { + *counts.entry(x).or_insert(0) += c; + } + // Leaves in the order of their histories, so the sums do not depend + // on which branch ran first. + t.leaves.sort_by(|a, b| a.0.cmp(&b.0)); + for (_, c, values) in &t.leaves { + for (m, &v) in moments.iter_mut().zip(values) { + m.add(v, *c as f64); + } + } + histories += t.leaves.len() as u64; + gate_applications += t.applications; + } + Ok(BranchingRun { expectations: moments.iter().map(Moments::estimate).collect(), counts: counts.into_iter().collect(), shots, histories, gate_applications }) +} + /// Linear cross-entropy fidelity of `samples` against the ideal state: /// `2ⁿ · mean(p_ideal(x)) − 1`. pub fn linear_xeb(ideal: &StateVector, samples: &[u64]) -> Estimate { @@ -614,6 +1017,86 @@ mod tests { assert_eq!(a, b); } + #[test] + fn the_binomial_sampler_has_the_binomial_moments() { + for (c, p, draws) in [(1000u64, 0.003f64, 20_000usize), (50, 0.7, 20_000), (6, 0.2, 20_000), (1_000_000, 1e-4, 4_000)] { + let mut rng = Rng(c ^ p.to_bits()); + let xs: Vec = (0..draws).map(|_| binomial(c, p, &mut rng) as f64).collect(); + let mean = xs.iter().sum::() / draws as f64; + let var = xs.iter().map(|x| (x - mean) * (x - mean)).sum::() / (draws - 1) as f64; + let (m, v) = (c as f64 * p, c as f64 * p * (1.0 - p)); + assert!((mean - m).abs() < 5.0 * (v / draws as f64).sqrt(), "c {c} p {p}: mean {mean} vs {m}"); + assert!((var / v - 1.0).abs() < 0.08, "c {c} p {p}: variance {var} vs {v}"); + } + } + + #[test] + fn branching_trajectories_converge_to_the_density_matrix() { + let n = 5; + let g = random_circuit(n, 5, 7); + let noise = NoiseModel { depol1: 0.01, depol2: 0.03, damping: 0.02, dephasing: 0.01, readout: 0.0 }; + let rho = DensityMatrix::run(&g, n, &noise).unwrap(); + for seed in [11u64, 12, 13] { + let tr = branching_trajectories(&g, n, &noise, &observables(n), 4000, seed, 8, 4).unwrap(); + for (k, o) in observables(n).iter().enumerate() { + let exact = rho.expectation(o); + let e = tr.expectations[k]; + assert!((e.mean - exact).abs() < 4.0 * e.stderr + 1e-9, "seed {seed} observable {k}: {} ± {} vs {exact}", e.mean, e.stderr); + } + } + } + + #[test] + fn branching_counts_match_the_exact_distribution() { + let n = 4; + let g = random_circuit(n, 3, 3); + let noise = NoiseModel { depol1: 0.01, depol2: 0.02, damping: 0.03, dephasing: 0.01, readout: 0.05 }; + let p = DensityMatrix::run(&g, n, &noise).unwrap().probabilities(noise.readout); + let shots = 40_000u64; + let tr = branching_trajectories(&g, n, &noise, &[], shots, 5, 8, 4).unwrap(); + assert_eq!(tr.counts.iter().map(|c| c.1).sum::(), shots); + let mut counts = vec![0u64; 1 << n]; + for &(x, c) in &tr.counts { + counts[x as usize] = c; + } + for (x, &c) in counts.iter().enumerate() { + let expected = p[x] * shots as f64; + let sd = (expected * (1.0 - p[x])).sqrt().max(1.0); + assert!((c as f64 - expected).abs() < 5.0 * sd, "x={x}: {c} vs {expected:.1}"); + } + } + + /// Threads, the state limit and the order branches run in change nothing: + /// below 16 qubits (many trees) and above (one tree, replays). + #[test] + fn branching_runs_depend_on_neither_threads_nor_the_state_limit() { + let noise = NoiseModel { depol1: 0.02, depol2: 0.05, damping: 0.01, dephasing: 0.01, readout: 0.02 }; + for (n, shots) in [(6u32, 300u64), (16, 40)] { + let g = random_circuit(n, 3, 9); + let a = branching_trajectories(&g, n, &noise, &observables(n), shots, 3, 64, 1).unwrap(); + for (states, threads) in [(1usize, 1usize), (2, 5), (64, 5)] { + let b = branching_trajectories(&g, n, &noise, &observables(n), shots, 3, states, threads).unwrap(); + // Replays redo gates, so only the work differs. + assert_eq!((&b.expectations, &b.counts, b.shots, b.histories), (&a.expectations, &a.counts, a.shots, a.histories), "n {n}: {states} states, {threads} threads"); + assert!(b.gate_applications >= a.gate_applications); + } + } + } + + /// With rare errors most shots share the error-free history. + #[test] + fn rare_errors_share_the_work() { + let n = 8; + let g = random_circuit(n, 6, 4); + let noise = NoiseModel { depol1: 1e-4, depol2: 1e-3, ..NoiseModel::default() }; + let shots = 1000u64; + let tr = branching_trajectories(&g, n, &noise, &observables(n), shots, 1, 16, 1).unwrap(); + let independent = shots * g.len() as u64; + assert!(tr.gate_applications * 10 < independent, "{} gate applications against {independent}", tr.gate_applications); + assert!(tr.histories < shots / 4, "{} histories", tr.histories); + assert_eq!(tr.counts.iter().map(|c| c.1).sum::(), shots); + } + fn self_xeb(ideal: &StateVector) -> f64 { ideal_xeb(ideal) } diff --git a/wai/wai-quantum/src/quantum_spd.rs b/wai/wai-quantum/src/quantum_spd.rs index 6c6e19729..79317a633 100644 --- a/wai/wai-quantum/src/quantum_spd.rs +++ b/wai/wai-quantum/src/quantum_spd.rs @@ -25,8 +25,22 @@ //! expectation is at most `|δ|` in magnitude. So `|exact − value|` is at most //! the bound, up to floating-point rounding. Clifford gates and exact //! quarter-turn rotations drop nothing. +//! - **Threaded, with the same bits.** A large sum is updated by a pool of +//! workers, started once per run, each over pieces of the table. A run of +//! consecutive Clifford gates is applied in one pass, each term through +//! the gates in order. A rotation's new terms have distinct strings, so each +//! existing coefficient receives at most one addition. The rest are +//! appended in table order, and dropped magnitudes are summed in table +//! order. The thread count therefore changes the time and never a bit. On a +//! 16-core desktop processor the 127-qubit kicked-Ising run (20 steps, +//! threshold 10⁻⁴, 2.2 million terms at its peak) takes 9.4 s, against 49 s +//! on one thread. use std::fmt; +use std::sync::atomic::AtomicU32; +use std::sync::atomic::Ordering::Relaxed; + +use crate::pool::Pool; pub use crate::repro::sin_cos; @@ -309,10 +323,16 @@ fn mix(mut z: u64) -> u64 { /// table is compacted, in order, when dead slots outnumber live ones, and /// before a Clifford gate rewrites every key anyway. When it happens depends /// only on the computation, so the result stays the same everywhere. +/// +/// Large tables are updated by a pool of workers, each over a contiguous +/// share of the terms. Where a term sits in the hash index depends on which +/// thread inserted it first, but the index only finds terms; the order of the +/// terms and every coefficient's arithmetic are the sequential ones, so the +/// result is the same bits at any thread count. struct Terms { keys: Vec>, coef: Vec, - index: Vec, + index: Vec, stale: bool, /// Slots whose coefficient is `0.0`. dead: usize, @@ -320,9 +340,64 @@ struct Terms { const EMPTY: u32 = u32::MAX; +/// Below this many terms a table is updated on one thread. Tests share far +/// smaller tables, so that every test runs the threaded paths too. +const PARALLEL_TERMS: usize = if cfg!(test) { 64 } else { 1 << 15 }; + +/// The coefficients, written by several threads at once in [`rotate`]'s +/// merge. Each new term's string is distinct from every other's, so each +/// coefficient is written by one thread at most. +#[derive(Clone, Copy)] +struct Coefs(*mut f64); + +// SAFETY: see `Coefs`: the writes go to distinct indices. +unsafe impl Send for Coefs {} +unsafe impl Sync for Coefs {} + +impl Coefs { + // Taken through a method, so a closure captures the whole `Coefs` (which + // is `Sync`) rather than its pointer (which is not). + fn ptr(self) -> *mut f64 { + self.0 + } +} + impl Terms { fn new() -> Self { - Terms { keys: Vec::new(), coef: Vec::new(), index: vec![EMPTY; 16], stale: false, dead: 0 } + Terms { keys: Vec::new(), coef: Vec::new(), index: (0..16).map(|_| AtomicU32::new(EMPTY)).collect(), stale: false, dead: 0 } + } + + /// Shares for a pass over this table: one below [`PARALLEL_TERMS`], + /// otherwise four a worker (so a busy core holds up less of the pass), + /// each at least a quarter of that size. + fn parts(&self, pool: &Pool) -> usize { + if self.keys.len() < PARALLEL_TERMS || pool.threads() == 1 { 1 } else { (self.keys.len() / (PARALLEL_TERMS / 4)).clamp(1, 4 * pool.threads()) } + } + + /// The slot of `k`, if present. The index must be current. + fn find(&self, k: &Key) -> Option { + let cap = self.index.len(); + let mut slot = (k.hash() & (cap as u64 - 1)) as usize; + loop { + let i = self.index[slot].load(Relaxed); + if i == EMPTY { + return None; + } + if self.keys[i as usize] == *k { + return Some(i as usize); + } + slot = (slot + 1) & (cap - 1); + } + } + + /// Enter term `i` in an index that does not hold it, by compare-and-swap + /// on the first empty slot, so threads can enter different terms at once. + fn enter(index: &[AtomicU32], k: &Key, i: u32) { + let cap = index.len(); + let mut slot = (k.hash() & (cap as u64 - 1)) as usize; + while index[slot].compare_exchange(EMPTY, i, Relaxed, Relaxed).is_err() { + slot = (slot + 1) & (cap - 1); + } } /// Terms with a nonzero coefficient. @@ -346,36 +421,50 @@ impl Terms { self.stale = true; } - fn reindex(&mut self) { + /// Rebuild the index for at least `extra` more terms than the table holds. + /// An index of the right size is cleared and reused; one far too large + /// for the table, or too small, is replaced. + fn reindex_for(&mut self, extra: usize, pool: &Pool) { let mut cap = 16usize; - while cap < 2 * self.keys.len() + 2 { + while cap < 2 * (self.keys.len() + extra) + 2 { cap *= 2; } - self.index.clear(); - self.index.resize(cap, EMPTY); - let mask = (cap - 1) as u64; - for (i, k) in self.keys.iter().enumerate() { - let mut slot = (k.hash() & mask) as usize; - while self.index[slot] != EMPTY { - slot = (slot + 1) & (cap - 1); - } - self.index[slot] = i as u32; + let parts = self.parts(pool); + if self.index.len() < cap || self.index.len() > 8 * cap { + self.index = (0..cap).map(|_| AtomicU32::new(EMPTY)).collect(); + } else { + let index = &self.index; + pool.split(index.len(), parts, &|r| { + for slot in &index[r] { + slot.store(EMPTY, Relaxed); + } + }); } + let (keys, index) = (&self.keys, &self.index); + pool.split(keys.len(), parts, &|r| { + for i in r { + Self::enter(index, &keys[i], i as u32); + } + }); self.stale = false; } + fn reindex(&mut self, pool: &Pool) { + self.reindex_for(0, pool); + } + /// Add `c` to the coefficient of `k`, inserting it at the end if absent. - fn add(&mut self, k: Key, c: f64) { + fn add(&mut self, k: Key, c: f64, pool: &Pool) { debug_assert!(!self.stale); if 2 * (self.keys.len() + 1) > self.index.len() { - self.reindex(); + self.reindex(pool); } let cap = self.index.len(); let mut slot = (k.hash() & (cap as u64 - 1)) as usize; loop { - let i = self.index[slot]; + let i = self.index[slot].load(Relaxed); if i == EMPTY { - self.index[slot] = self.keys.len() as u32; + self.index[slot].store(self.keys.len() as u32, Relaxed); self.keys.push(k); self.coef.push(c); if c == 0.0 { @@ -399,19 +488,31 @@ impl Terms { } /// Drop terms with `|c| < threshold`, or heavier than `max_weight`. - /// Returns the sum of dropped `|c|`. - fn truncate(&mut self, threshold: f64, max_weight: Option) -> f64 { + /// Returns the sum of dropped `|c|`, added in table order. + fn truncate(&mut self, threshold: f64, max_weight: Option, pool: &Pool) -> f64 { + let parts = self.parts(pool); + let per = self.keys.len().div_ceil(parts).max(1); + let keys = &self.keys; + // Each share zeroes its dropped terms and keeps their magnitudes in + // order; the magnitudes are then summed in table order. + let drop_share = |at: usize, coef: &mut [f64]| -> Vec { + let mut out = Vec::new(); + for (j, c) in coef.iter_mut().enumerate() { + if *c == 0.0 { + continue; + } + if c.abs() < threshold || max_weight.is_some_and(|m| keys[at + j].weight() > m) { + out.push(c.abs()); + *c = 0.0; + } + } + out + }; + let shares = pool.chunks(&mut self.coef, per, &drop_share); let mut dropped = 0.0; - for i in 0..self.keys.len() { - let c = self.coef[i]; - if c == 0.0 { - continue; - } - if c.abs() < threshold || max_weight.is_some_and(|m| self.keys[i].weight() > m) { - dropped += c.abs(); - self.coef[i] = 0.0; - self.dead += 1; - } + for v in shares.iter().flatten() { + dropped += v; + self.dead += 1; } if 2 * self.dead > self.keys.len() { self.compact(); @@ -448,102 +549,213 @@ fn conj1(op: &Op, x: bool, z: bool) -> (bool, bool, bool) { } } -/// Apply a Clifford gate to every term in place. Conjugation by a unitary is a -/// bijection on Pauli strings, so no two terms collide. -fn clifford(t: &mut Terms, op: &Op) { +/// A gate that maps each string to one signed string: a fixed Clifford gate, +/// or a quarter-turn rotation about a string. +enum Clifford<'a, const W: usize> { + Gate(&'a Op), + Quarter(Key, u8), +} + +/// Conjugate one term by a fixed Clifford gate. +#[inline] +fn clifford_term(op: &Op, k: &mut Key, c: &mut f64) { + let neg = match *op { + Op::H(q) | Op::S(q) | Op::Sdg(q) | Op::SX(q) | Op::SXdg(q) | Op::X(q) | Op::Y(q) | Op::Z(q) => { + let (x, z) = k.get(q); + let (nx, nz, neg) = conj1(op, x, z); + k.set(q, nx, nz); + neg + } + Op::CX(c, tq) => { + let ((xc, zc), (xt, zt)) = (k.get(c), k.get(tq)); + k.set(tq, xt ^ xc, zt); + k.set(c, xc, zc ^ zt); + xc && zt && !(xt ^ zc) + } + Op::CZ(a, b) => { + let ((xa, za), (xb, zb)) = (k.get(a), k.get(b)); + k.set(a, xa, za ^ xb); + k.set(b, xb, zb ^ xa); + xa && xb && (za ^ zb) + } + Op::Swap(a, b) => { + let (fa, fb) = (k.get(a), k.get(b)); + k.set(a, fb.0, fb.1); + k.set(b, fa.0, fa.1); + false + } + _ => unreachable!("not a fixed Clifford"), + }; + if neg { + *c = -*c; + } +} + +/// Conjugate one term by `exp(−i(kπ/2)P/2)`, exactly. A string anticommuting +/// with `P` becomes `±(−iQP)` for `k = 1` or `3`, and `−Q` for `k = 2`. +#[inline] +fn quarter_term(p: &Key, k: u8, q: &mut Key, c: &mut f64) { + if q.commutes(p) { + return; + } + if k == 2 { + *c = -*c; + return; + } + let (r, e) = q.mul(p); + // −i·QP = i^{e+3}·R; e is odd here, so the factor is ±1. + let mut neg = (e + 3) & 3 == 2; + if k == 3 { + neg = !neg; + } + *q = r; + if neg { + *c = -*c; + } +} + +/// Apply consecutive Clifford gates to every term in one pass: each term +/// goes through the gates in order. Conjugation by a unitary is a bijection on +/// Pauli strings, so no two terms collide, and each term's result is what the +/// gates one pass at a time would give it. +fn cliffords(t: &mut Terms, gates: &[Clifford<'_, W>], pool: &Pool) { if t.dead > 0 { t.compact(); } - for i in 0..t.keys.len() { - let k = &mut t.keys[i]; - let neg = match *op { - Op::H(q) | Op::S(q) | Op::Sdg(q) | Op::SX(q) | Op::SXdg(q) | Op::X(q) | Op::Y(q) | Op::Z(q) => { - let (x, z) = k.get(q); - let (nx, nz, neg) = conj1(op, x, z); - k.set(q, nx, nz); - neg + let parts = t.parts(pool); + map_terms(&mut t.keys, &mut t.coef, pool, parts, &|k, c| { + for g in gates { + match g { + Clifford::Gate(op) => clifford_term(op, k, c), + Clifford::Quarter(p, q) => quarter_term(p, *q, k, c), } - Op::CX(c, tq) => { - let ((xc, zc), (xt, zt)) = (k.get(c), k.get(tq)); - k.set(tq, xt ^ xc, zt); - k.set(c, xc, zc ^ zt); - xc && zt && !(xt ^ zc) - } - Op::CZ(a, b) => { - let ((xa, za), (xb, zb)) = (k.get(a), k.get(b)); - k.set(a, xa, za ^ xb); - k.set(b, xb, zb ^ xa); - xa && xb && (za ^ zb) - } - Op::Swap(a, b) => { - let (fa, fb) = (k.get(a), k.get(b)); - k.set(a, fb.0, fb.1); - k.set(b, fa.0, fa.1); - false - } - _ => unreachable!("not a fixed Clifford"), - }; - if neg { - t.coef[i] = -t.coef[i]; } - } + }); t.stale = true; } -/// Conjugate by `exp(−i(kπ/2)P/2)`, exactly. A string anticommuting with `P` -/// becomes `±(−iQP)` for `k = 1` or `3`, and `−Q` for `k = 2`. -fn quarter(t: &mut Terms, p: &Key, k: u8) { - if t.dead > 0 { - t.compact(); - } - for i in 0..t.keys.len() { - let q = t.keys[i]; - if q.commutes(p) { - continue; +/// Apply `f` to every term, in `parts` shares of the table. +fn map_terms(keys: &mut [Key], coef: &mut [f64], pool: &Pool, parts: usize, f: &(dyn Fn(&mut Key, &mut f64) + Sync)) { + let per = keys.len().div_ceil(parts.max(1)).max(1); + pool.chunks2(keys, coef, per, &|ks, cs| { + for (k, c) in ks.iter_mut().zip(cs.iter_mut()) { + f(k, c); } - if k == 2 { - t.coef[i] = -t.coef[i]; - continue; - } - let (r, e) = q.mul(p); - // −i·QP = i^{e+3}·R; e is odd here, so the factor is ±1. - let mut neg = (e + 3) & 3 == 2; - if k == 3 { - neg = !neg; - } - t.keys[i] = r; - if neg { - t.coef[i] = -t.coef[i]; - } - } - t.stale = true; + }); } /// Conjugate by `exp(−iθP/2)`: `Q ↦ cos θ·Q + sin θ·(−iQP)` for every `Q` that /// anticommutes with `P`. The new terms are merged after every old one has been /// scaled, so each old coefficient is read before it changes. -fn rotate(t: &mut Terms, p: &Key, sin: f64, cos: f64, pending: &mut Vec<(Key, f64)>) { +/// +/// On a large table the work is shared: each thread scans its share and +/// lists its new terms in order, and the lists are joined in share order. +/// Multiplying by `P` maps distinct strings to distinct strings, so no two new +/// terms share a string. Each existing coefficient therefore receives at most +/// one addition, and the strings not yet in the table are appended in list +/// order. The result is the sequential merge's, bit for bit. +fn rotate(t: &mut Terms, p: &Key, sin: f64, cos: f64, pending: &mut Vec<(Key, f64)>, pool: &Pool) { if t.stale { - t.reindex(); + t.reindex(pool); } pending.clear(); - for i in 0..t.keys.len() { - let c = t.coef[i]; - if c == 0.0 { - continue; + let parts = t.parts(pool); + if parts == 1 { + for i in 0..t.keys.len() { + let c = t.coef[i]; + if c == 0.0 { + continue; + } + let q = t.keys[i]; + if q.commutes(p) { + continue; + } + let (r, e) = q.mul(p); + let v = c * sin; + pending.push((r, if (e + 3) & 3 == 2 { -v } else { v })); + t.coef[i] = c * cos; } - let q = t.keys[i]; - if q.commutes(p) { - continue; + for &(r, v) in pending.iter() { + t.add(r, v, pool); } - let (r, e) = q.mul(p); - let v = c * sin; - pending.push((r, if (e + 3) & 3 == 2 { -v } else { v })); - t.coef[i] = c * cos; + return; } - for &(r, v) in pending.iter() { - t.add(r, v); + // The scan: each share scales its anticommuting terms and lists the new ones. + let per = t.keys.len().div_ceil(parts); + let keys = &t.keys; + let shares: Vec, f64)>> = pool.chunks(&mut t.coef, per, &|at, coef| { + let mut out = Vec::new(); + for (j, c) in coef.iter_mut().enumerate() { + if *c == 0.0 { + continue; + } + let q = keys[at + j]; + if q.commutes(p) { + continue; + } + let (r, e) = q.mul(p); + let v = *c * sin; + out.push((r, if (e + 3) & 3 == 2 { -v } else { v })); + *c *= cos; + } + out + }); + // The merge, share by share: add each new term to its string's + // coefficient where the string is in the table, and list the rest in order. + let coefs = Coefs(t.coef.as_mut_ptr()); + let table: &Terms = t; + let len = table.keys.len(); + let merged: Vec<(isize, Vec)> = pool.split(shares.len(), shares.len(), &|r| { + let (mut revived_less_killed, mut absent) = (0isize, Vec::new()); + let share = &shares[r.start]; + for (j, &(k, v)) in share.iter().enumerate() { + match table.find(&k) { + Some(i) => { + debug_assert!(i < len); + // SAFETY: `i` is a slot of the table, and no other new + // term has this string, so no other thread writes slot `i`. + let slot = unsafe { &mut *coefs.ptr().add(i) }; + let before = *slot; + let after = before + v; + *slot = after; + match (before == 0.0, after == 0.0) { + (true, false) => revived_less_killed += 1, + (false, true) => revived_less_killed -= 1, + _ => {} + } + } + None => absent.push(j), + } + } + (revived_less_killed, absent) + }); + let mut fresh = 0; + for (d, absent) in &merged { + t.dead = (t.dead as isize - d) as usize; + fresh += absent.len(); } + if 2 * (t.keys.len() + fresh) > t.index.len() { + t.reindex_for(fresh, pool); + } + let start = t.keys.len(); + t.keys.reserve(fresh); + t.coef.reserve(fresh); + for (share, (_, absent)) in shares.iter().zip(&merged) { + for &j in absent { + let (k, v) = share[j]; + t.keys.push(k); + t.coef.push(v); + if v == 0.0 { + t.dead += 1; + } + } + } + let (keys, index) = (&t.keys, &t.index); + pool.split(keys.len() - start, parts, &|r| { + for i in r { + Terms::::enter(index, &keys[start + i], (start + i) as u32); + } + }); } // --------------------------------------------------------------------------- @@ -586,11 +798,29 @@ pub struct SpdResult { /// `⟨b| U† O U |b⟩` for the computational basis state `|b⟩` whose set qubits /// are `ones` (empty for `|0…0⟩`), with `O` a weighted sum of Pauli strings. +/// Large sums are updated on every available thread; the result is the +/// same bits at any thread count ([`expectation_threaded`]). pub fn expectation( circuit: &RotCircuit, observable: &[(f64, PauliString)], ones: &[u32], cfg: &SpdConfig, +) -> Result { + #[cfg(not(target_arch = "wasm32"))] + let threads = std::thread::available_parallelism().map_or(1, |t| t.get()); + #[cfg(target_arch = "wasm32")] + let threads = 1; + expectation_threaded(circuit, observable, ones, cfg, threads) +} + +/// [`expectation`] on at most `threads` threads (wasm32 runs one). The thread +/// count changes the time, never a bit of the result. +pub fn expectation_threaded( + circuit: &RotCircuit, + observable: &[(f64, PauliString)], + ones: &[u32], + cfg: &SpdConfig, + threads: usize, ) -> Result { circuit.validate()?; for (_, s) in observable { @@ -602,11 +832,11 @@ pub fn expectation( } } Ok(match circuit.n.div_ceil(64) { - 1 => run::<1>(circuit, observable, ones, cfg), - 2 => run::<2>(circuit, observable, ones, cfg), - 3 | 4 => run::<4>(circuit, observable, ones, cfg), - 5..=8 => run::<8>(circuit, observable, ones, cfg), - _ => run::<16>(circuit, observable, ones, cfg), + 1 => run::<1>(circuit, observable, ones, cfg, threads), + 2 => run::<2>(circuit, observable, ones, cfg, threads), + 3 | 4 => run::<4>(circuit, observable, ones, cfg, threads), + 5..=8 => run::<8>(circuit, observable, ones, cfg, threads), + _ => run::<16>(circuit, observable, ones, cfg, threads), }) } @@ -620,28 +850,44 @@ fn run( observable: &[(f64, PauliString)], ones: &[u32], cfg: &SpdConfig, + threads: usize, ) -> SpdResult { + crate::pool::scoped(threads, |pool| propagate::(circuit, observable, ones, cfg, pool)) +} + +fn propagate(circuit: &RotCircuit, observable: &[(f64, PauliString)], ones: &[u32], cfg: &SpdConfig, pool: &Pool) -> SpdResult { let mut t = Terms::::new(); for (c, s) in observable { - t.add(Key::from_string(s), *c); + t.add(Key::from_string(s), *c, pool); } - let mut bound = t.truncate(0.0, None); + let mut bound = t.truncate(0.0, None, pool); let mut peak = t.live(); let mut work = 0u64; let mut pending = Vec::new(); + // Consecutive Clifford gates wait in `batch` and are applied in one pass, + // before the next rotation and at the end. A Clifford gate keeps the + // number of terms, so the work count and the peak are as gate by gate. + let mut batch: Vec> = Vec::new(); for op in circuit.ops.iter().rev() { work += t.live() as u64; match op { Op::Rot { axis, theta } => { + if !batch.is_empty() { + cliffords(&mut t, &batch, pool); + batch.clear(); + } let (s, c) = sin_cos(*theta); - rotate(&mut t, &Key::from_string(axis), s, c, &mut pending); - bound += t.truncate(cfg.threshold, cfg.max_weight); + rotate(&mut t, &Key::from_string(axis), s, c, &mut pending, pool); + bound += t.truncate(cfg.threshold, cfg.max_weight, pool); } - Op::QuarterRot { axis, k } => quarter(&mut t, &Key::from_string(axis), *k), - other => clifford(&mut t, other), + Op::QuarterRot { axis, k } => batch.push(Clifford::Quarter(Key::from_string(axis), *k)), + other => batch.push(Clifford::Gate(other)), } peak = peak.max(t.live()); } + if !batch.is_empty() { + cliffords(&mut t, &batch, pool); + } let b = Key::::from_string(&ones.iter().map(|&q| (q, Pauli::Z)).collect::>()); let mut value = 0.0; for i in 0..t.keys.len() { @@ -1186,6 +1432,32 @@ mod tests { } } + #[test] + fn threads_do_not_change_a_bit() { + let mut rng = Rng(77); + for trial in 0..6 { + let n = [9u32, 40, 70, 130, 200, 300][trial]; + let c = random_circuit(&mut rng, n, 120); + let obs = vec![(0.7, rng.string(n, 4)), (-0.4, rng.string(n, 3)), (0.25, rng.string(n, 2))]; + // Exactly (threshold 0) only where the sum stays small. + for threshold in if n <= 9 { vec![0.0, 1e-3] } else { vec![1e-5, 1e-3] } { + let cfg = SpdConfig { threshold, max_weight: None }; + let one = expectation_threaded(&c, &obs, &[1, 2], &cfg, 1).unwrap(); + for threads in [2usize, 3, 8] { + let many = expectation_threaded(&c, &obs, &[1, 2], &cfg, threads).unwrap(); + assert_eq!((many.value.to_bits(), many.truncation_bound.to_bits(), many.peak_terms, many.final_terms, many.term_updates), (one.value.to_bits(), one.truncation_bound.to_bits(), one.peak_terms, one.final_terms, one.term_updates), "trial {trial}, threshold {threshold}, {threads} threads"); + } + } + } + // The utility circuit, past the size at which shares go to threads. + let ki = kicked_ising(127, &heavy_hex_127(), 0.7, 6); + let cfg = SpdConfig { threshold: 1e-4, max_weight: None }; + let one = expectation_threaded(&ki, &[(1.0, vec![(62, Pauli::Z)])], &[], &cfg, 1).unwrap(); + let many = expectation_threaded(&ki, &[(1.0, vec![(62, Pauli::Z)])], &[], &cfg, 6).unwrap(); + assert!(one.peak_terms > 64); + assert_eq!((one.value.to_bits(), one.truncation_bound.to_bits(), one.peak_terms), (many.value.to_bits(), many.truncation_bound.to_bits(), many.peak_terms)); + } + #[test] fn word_width_does_not_change_the_answer() { // The same circuit, placed in a 30-qubit and a 900-qubit register, is diff --git a/wai/wai-quantum/src/quantum_sv.rs b/wai/wai-quantum/src/quantum_sv.rs index a93b60913..495bafa2d 100644 --- a/wai/wai-quantum/src/quantum_sv.rs +++ b/wai/wai-quantum/src/quantum_sv.rs @@ -20,6 +20,18 @@ //! state are what bound a state vector's speed. Fusion changes the rounding, //! so the width is part of a run's definition. A run's bits are fixed by its //! gates and its width. +//! - **Tiles.** A gate that reads and writes the whole state costs one trip +//! through memory, and on a large register that trip, not the arithmetic, +//! is the time. [`StateVector::run`] therefore groups consecutive gates into +//! passes ([`Schedule`]). Each tile is the set of amplitudes that agree on +//! every bit outside a pass's local bits. It is copied into a buffer the +//! size of a core's cache, every gate of the pass is applied to it there, +//! and it is copied back. A pass's dense gates act on its local bits; +//! diagonal gates may act on any bit, since a tile fixes the others. Gates +//! keep their order, and each amplitude goes through the same expressions +//! as without tiles, so the schedule never changes a bit. A gate of three +//! or more qubits on the top qubits, which leaves the direct kernels too +//! few blocks to share between threads, runs in tiles too. //! - **Threads.** Every amplitude group is updated independently, so a gate's //! result does not depend on how the work is split. Reductions (norms, //! expectations, sampling) are summed in a fixed number of chunks, each in @@ -39,9 +51,14 @@ //! 2·10⁻¹⁶ at every fusion width. //! - **Amplitudes** contracted as tensor networks agree with this state vector //! to 10⁻¹² on 18 qubits. -//! - **Speed.** A 26-qubit, depth-20 brickwork circuit of 770 gates runs in -//! about nine seconds at fusion width 2, in double precision, on one -//! machine. +//! - **Tiles change no bit.** Untiled, and tiled at several tile and run +//! sizes, on one thread and many, random circuits with one-, two- and +//! three-qubit gates and far-apart diagonal gates give identical states, bit +//! for bit, at every fusion width. +//! - **Speed.** On a 16-core desktop processor, at 26 qubits in double +//! precision, tiling cuts a depth-20 brickwork circuit of random rotations +//! and fSim gates from 5.2 s to 2.3 s at fusion width 2. It cuts a QFT from +//! 2.9 s to 1.6 s at width 3, and a QAOA circuit from 2.9 s to 0.95 s. use crate::gates::Gate; pub use crate::gates::C; @@ -95,6 +112,145 @@ fn par_slices(re: &mut [f64], im: &mut [f64], unit: usize, f: &SliceFn) { f(re, im, 0); } +/// How [`StateVector::run`] groups gates into passes over the state. It +/// changes the time a run takes, never its bits. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Schedule { + /// A tile holds `2^tile_bits` amplitudes, copied into a buffer that should + /// fit one core's cache. 0 gives every gate its own pass over the state. + pub tile_bits: u32, + /// The bottom `run_bits` bits are local to every tile, so a tile is copied + /// in contiguous runs of at least `2^run_bits` amplitudes. + pub run_bits: u32, +} + +impl Schedule { + /// One pass per gate. + pub const UNTILED: Schedule = Schedule { tile_bits: 0, run_bits: 0 }; + /// Tiles of 2¹⁵ amplitudes (512 KiB), copied in runs of at least 32. + pub const DEFAULT: Schedule = Schedule { tile_bits: 15, run_bits: 5 }; +} + +/// One pass of a run: a gate applied to the whole state, or a group of gates +/// applied tile by tile, with the local bits their dense gates act on. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Stage { + Direct(usize), + Tiled { gates: Vec, local: Vec }, +} + +/// Group `list` into passes, in order: a pass takes gates while the bits its +/// dense gates act on (above the bottom `low`) number at most +/// `tile − low`. Diagonal gates join any pass. A gate on more bits than a tile +/// holds gets a pass of its own. The order of the gates never changes, so +/// neither does any amplitude's arithmetic. +fn plan(list: &[Gate], n: u32, tile: u32, low: u32) -> Vec { + let room = (tile - low) as usize; + let mut out = Vec::new(); + let (mut gates, mut local): (Vec, Vec) = (Vec::new(), Vec::new()); + for (i, g) in list.iter().enumerate() { + if g.is_diagonal() { + gates.push(i); + continue; + } + let mut need: Vec = g.qubits.iter().map(|&q| n - 1 - q).filter(|&p| p >= low && !local.contains(&p)).collect(); + need.sort_unstable(); + if need.len() > room { + if !gates.is_empty() { + out.push(Stage::Tiled { gates: core::mem::take(&mut gates), local: core::mem::take(&mut local) }); + } + out.push(Stage::Direct(i)); + continue; + } + if local.len() + need.len() > room { + out.push(Stage::Tiled { gates: core::mem::take(&mut gates), local: core::mem::take(&mut local) }); + need = g.qubits.iter().map(|&q| n - 1 - q).filter(|&p| p >= low).collect(); + } + local.extend(need); + gates.push(i); + } + if !gates.is_empty() { + out.push(Stage::Tiled { gates, local }); + } + // Fill each pass's local bits up to the tile with the lowest others, which + // lengthens its contiguous runs and makes every tile full size. + for st in &mut out { + if let Stage::Tiled { local, .. } = st { + let mut p = low; + while local.len() < room && p < n { + if !local.contains(&p) { + local.push(p); + } + p += 1; + } + } + } + out +} + +/// A gate ready to apply to a tile buffer. +enum Prepared<'a> { + /// Offsets and sorted positions inside the buffer, as [`kernel_block`] + /// takes them. + Dense { matrix: &'a [C], offsets: Vec, sorted: Vec }, + /// The diagonal, and for each of the gate's qubits its position in the + /// buffer (if local) and in the state. + Diagonal { diag: Vec, parts: Vec<(Option, u32)> }, +} + +impl Prepared<'_> { + /// Apply to the tile whose first amplitude is `base`. + fn apply(&self, re: &mut [f64], im: &mut [f64], base: usize) { + match self { + Prepared::Dense { matrix, offsets, sorted } => kernel_block(re, im, matrix, offsets, sorted), + Prepared::Diagonal { diag, parts } => { + let k = parts.len(); + // The outer bits are the same across the tile. + let fixed = parts.iter().enumerate().fold(0usize, |l, (i, &(t, p))| if t.is_none() { l | (((base >> p) & 1) << (k - 1 - i)) } else { l }); + for (s, (a, b)) in re.iter_mut().zip(im.iter_mut()).enumerate() { + let mut local = fixed; + for (i, &(t, _)) in parts.iter().enumerate() { + if let Some(t) = t { + local |= ((s >> t) & 1) << (k - 1 - i); + } + } + let c = diag[local]; + if c.re == 1.0 && c.im == 0.0 { + continue; + } + let (ar, ai) = (*a, *b); + *a = c.re * ar - c.im * ai; + *b = c.re * ai + c.im * ar; + } + } + } + } +} + +/// The state's two arrays, shared between the threads of a tiled pass. Each +/// thread reads and writes only the tiles it takes, and no two tiles share +/// an index, so no element is touched by two threads. +#[derive(Clone, Copy)] +struct Raw { + re: *mut f64, + im: *mut f64, +} + +// SAFETY: see `Raw`: access is partitioned by tile. +unsafe impl Send for Raw {} +unsafe impl Sync for Raw {} + +impl Raw { + // Taken through methods, so a closure captures the whole `Raw` (which is + // `Sync`) rather than its pointer fields (which are not). + fn re(self) -> *mut f64 { + self.re + } + fn im(self) -> *mut f64 { + self.im + } +} + /// Insert a zero bit at each position in `pos` (ascending) into `t`. #[inline] fn spread(mut t: usize, pos: &[u32]) -> usize { @@ -125,11 +281,30 @@ thread_local! { static FORCE_THREADS: core::cell::Cell> = const { core::cell::Cell::new(None) }; } +thread_local! { + /// Set while a caller that already runs many states in parallel wants + /// each one's kernels on its own thread. + static ONE_THREAD: core::cell::Cell = const { core::cell::Cell::new(false) }; +} + +/// Run `f` with this thread's state-vector kernels on this thread alone. For +/// callers that run many small states in parallel; the result is the same +/// bits either way. +pub(crate) fn on_one_thread(f: impl FnOnce() -> R) -> R { + let before = ONE_THREAD.with(|o| o.replace(true)); + let r = f(); + ONE_THREAD.with(|o| o.set(before)); + r +} + fn available_threads() -> usize { #[cfg(test)] if let Some(t) = FORCE_THREADS.with(|f| f.get()) { return t; } + if ONE_THREAD.with(|o| o.get()) { + return 1; + } #[cfg(not(target_arch = "wasm32"))] { std::thread::available_parallelism().map_or(1, |t| t.get()) @@ -140,24 +315,84 @@ fn available_threads() -> usize { } } -/// Apply a gate to a slice holding whole groups (block-aligned). One- and -/// two-qubit gates take unrolled loops whose inner stride is contiguous. +/// Groups updated together by [`kernel_lanes`]: one vector register's worth +/// of each member, or two. +const LANES: usize = 8; + +/// The one-qubit update of a pair `(a, b)`, written out. Every one-qubit path +/// computes each amplitude through these four expressions. +#[inline(always)] +fn pair(m: &[C], ar: f64, ai: f64, br: f64, bi: f64) -> (f64, f64, f64, f64) { + let (m00, m01, m10, m11) = (m[0], m[1], m[2], m[3]); + ( + m00.re * ar - m00.im * ai + m01.re * br - m01.im * bi, + m00.re * ai + m00.im * ar + m01.re * bi + m01.im * br, + m10.re * ar - m10.im * ai + m11.re * br - m11.im * bi, + m10.re * ai + m10.im * ar + m11.re * bi + m11.im * br, + ) +} + +/// A gate on two or more qubits whose lowest target stride is at least +/// [`LANES`]: the groups come in contiguous runs, so [`LANES`] consecutive +/// groups are loaded member by member and updated side by side. Each output is +/// the [`row_dot`] sum, term for term, so this path and the scalar one give +/// the same bits; it only lets the compiler use vector registers. +fn kernel_lanes(re: &mut [f64], im: &mut [f64], m: &[C], offsets: &[usize], sorted: &[u32]) { + let k = sorted.len(); + let d = offsets.len(); + let run = 1usize << sorted[0]; + let groups = re.len() >> k; + let mut vr = vec![[0.0f64; LANES]; d]; + let mut vi = vec![[0.0f64; LANES]; d]; + let mut t = 0; + while t < groups { + let start = spread(t, sorted); + for lane0 in (0..run).step_by(LANES) { + let b = start + lane0; + for (j, &o) in offsets.iter().enumerate() { + vr[j].copy_from_slice(&re[b + o..b + o + LANES]); + vi[j].copy_from_slice(&im[b + o..b + o + LANES]); + } + for (r, &o) in offsets.iter().enumerate() { + let (mut sr, mut si) = ([0.0f64; LANES], [0.0f64; LANES]); + for (e, (xr, xi)) in m[r * d..(r + 1) * d].iter().zip(vr.iter().zip(&vi)) { + for l in 0..LANES { + sr[l] += e.re * xr[l] - e.im * xi[l]; + si[l] += e.re * xi[l] + e.im * xr[l]; + } + } + re[b + o..b + o + LANES].copy_from_slice(&sr); + im[b + o..b + o + LANES].copy_from_slice(&si); + } + } + t += run; + } +} + +/// Apply a gate to a slice holding whole groups (block-aligned). One-qubit +/// gates run over the two halves of each block side by side; wider gates take +/// [`kernel_lanes`] when their groups come in runs, unrolled loops otherwise. fn kernel_block(re: &mut [f64], im: &mut [f64], m: &[C], offsets: &[usize], sorted: &[u32]) { let k = sorted.len(); if k == 1 { let s = offsets[1]; - let (m00, m01, m10, m11) = (m[0], m[1], m[2], m[3]); - let mut base = 0; - while base < re.len() { - for i in base..base + s { - let (ar, ai, br, bi) = (re[i], im[i], re[i + s], im[i + s]); - re[i] = m00.re * ar - m00.im * ai + m01.re * br - m01.im * bi; - im[i] = m00.re * ai + m00.im * ar + m01.re * bi + m01.im * br; - re[i + s] = m10.re * ar - m10.im * ai + m11.re * br - m11.im * bi; - im[i + s] = m10.re * ai + m10.im * ar + m11.re * bi + m11.im * br; + if s == 1 { + for (r, i) in re.as_chunks_mut::<2>().0.iter_mut().zip(im.as_chunks_mut::<2>().0.iter_mut()) { + (r[0], i[0], r[1], i[1]) = pair(m, r[0], i[0], r[1], i[1]); } - base += 2 * s; + return; } + for (r, i) in re.chunks_exact_mut(2 * s).zip(im.chunks_exact_mut(2 * s)) { + let (rl, rh) = r.split_at_mut(s); + let (il, ih) = i.split_at_mut(s); + for (((a, b), c), e) in rl.iter_mut().zip(il.iter_mut()).zip(rh.iter_mut()).zip(ih.iter_mut()) { + (*a, *b, *c, *e) = pair(m, *a, *b, *c, *e); + } + } + return; + } + if 1usize << sorted[0] >= LANES { + kernel_lanes(re, im, m, offsets, sorted); return; } if k == 2 { @@ -204,15 +439,11 @@ fn kernel_block(re: &mut [f64], im: &mut [f64], m: &[C], offsets: &[usize], sort fn kernel_pair(lre: &mut [f64], lim: &mut [f64], hre: &mut [f64], him: &mut [f64], m: &[C], members: &[(bool, usize)], rest: &[u32]) { let d = members.len(); if d == 2 { - let (m00, m01, m10, m11) = (m[0], m[1], m[2], m[3]); // Which of the two is in the upper half decides the matrix orientation. let lower_first = !members[0].0; for i in 0..lre.len() { let (ar, ai, br, bi) = if lower_first { (lre[i], lim[i], hre[i], him[i]) } else { (hre[i], him[i], lre[i], lim[i]) }; - let nr0 = m00.re * ar - m00.im * ai + m01.re * br - m01.im * bi; - let ni0 = m00.re * ai + m00.im * ar + m01.re * bi + m01.im * br; - let nr1 = m10.re * ar - m10.im * ai + m11.re * br - m11.im * bi; - let ni1 = m10.re * ai + m10.im * ar + m11.re * bi + m11.im * br; + let (nr0, ni0, nr1, ni1) = pair(m, ar, ai, br, bi); if lower_first { (lre[i], lim[i], hre[i], him[i]) = (nr0, ni0, nr1, ni1); } else { @@ -279,8 +510,15 @@ impl StateVector { } /// Apply gates in order, fused to `width` qubits first (`width` 0 or 1 - /// applies them as given). + /// applies them as given), in tiled passes ([`Schedule::DEFAULT`]). pub fn run(&mut self, gates: &[Gate], width: usize) -> Result<(), SvError> { + self.run_scheduled(gates, width, Schedule::DEFAULT) + } + + /// [`run`](Self::run) with the passes scheduled by `schedule`. The + /// schedule never changes a bit of the state: every amplitude goes through + /// the same operations, in the same order, whatever it is. + pub fn run_scheduled(&mut self, gates: &[Gate], width: usize, schedule: Schedule) -> Result<(), SvError> { for (i, g) in gates.iter().enumerate() { self.check(g, i)?; } @@ -291,12 +529,137 @@ impl StateVector { } else { gates }; - for g in list { - self.apply_unchecked(g); + // Tiles must outnumber the threads several times over, so a small + // register takes smaller tiles, and the smallest none. + let threads = available_threads(); + let spare = (4 * threads).next_power_of_two().trailing_zeros(); + let tile = schedule.tile_bits.min(self.n.saturating_sub(spare)); + let low = schedule.run_bits.min(tile); + if schedule.tile_bits == 0 || tile < low + 3 { + for g in list { + self.apply_unchecked(g); + } + return Ok(()); + } + for stage in plan(list, self.n, tile, low) { + match stage { + Stage::Direct(i) => self.apply_unchecked(&list[i]), + Stage::Tiled { gates, local } => { + // One gate the direct kernels thread well needs no tile. + let g = &list[gates[0]]; + if gates.len() == 1 && (g.qubits.len() <= 2 || g.is_diagonal()) { + self.apply_unchecked(g); + } else { + let stage: Vec<&Gate> = gates.iter().map(|&i| &list[i]).collect(); + self.tiled_pass(&stage, &local, low, threads); + } + } + } } Ok(()) } + /// Apply `gates` in one pass over the state: each tile (the amplitudes + /// that agree on every bit outside `local` and the `low` bottom bits) is + /// copied into a buffer the size of a core's cache, every gate is applied + /// to it there, and it is copied back. Dense gates act only on local bits; + /// diagonal ones may act on any, since a tile fixes the others. + fn tiled_pass(&mut self, gates: &[&Gate], local: &[u32], low: u32, threads: usize) { + let n = self.n; + // The local bits above the bottom run, ascending; bits that extend the + // run join it, so each copy is as long as it can be. + let mut run = low; + let mut free: Vec = local.to_vec(); + free.sort_unstable(); + while let Some(i) = free.iter().position(|&p| p == run) { + free.remove(i); + run += 1; + } + let tile_len = 1usize << (run as usize + free.len()); + let run_len = 1usize << run; + let outer: Vec = (run..n).filter(|p| !free.contains(p)).collect(); + let tiles = 1usize << outer.len(); + // Where each run of a tile starts, relative to the tile's first index. + let run_at: Vec = (0..1usize << free.len()).map(|r| free.iter().enumerate().fold(0, |o, (i, &p)| o | (((r >> i) & 1) << p))).collect(); + // A bit's position inside the tile buffer. + let in_tile = |p: u32| -> Option { + if p < run { + Some(p) + } else { + free.iter().position(|&f| f == p).map(|i| run + i as u32) + } + }; + let prepared: Vec = gates + .iter() + .map(|g| { + let k = g.qubits.len(); + let d = 1usize << k; + let bitpos: Vec = g.qubits.iter().map(|&q| n - 1 - q).collect(); + if g.is_diagonal() { + let diag = (0..d).map(|i| g.matrix[i * d + i]).collect(); + let parts = bitpos.iter().map(|&p| (in_tile(p), p)).collect(); + Prepared::Diagonal { diag, parts } + } else { + let pos: Vec = bitpos.iter().map(|&p| in_tile(p).expect("a dense gate on local bits")).collect(); + let mut sorted = pos.clone(); + sorted.sort_unstable(); + let offsets = (0..d).map(|l| pos.iter().enumerate().fold(0usize, |o, (i, &p)| o | (((l >> (k - 1 - i)) & 1) << p))).collect(); + Prepared::Dense { matrix: &g.matrix, offsets, sorted } + } + }) + .collect(); + let raw = Raw { re: self.re.as_mut_ptr(), im: self.im.as_mut_ptr() }; + let work = |first: usize, last: usize, buf_re: &mut [f64], buf_im: &mut [f64]| { + for t in first..last { + let base = outer.iter().enumerate().fold(0usize, |o, (i, &p)| o | (((t >> i) & 1) << p)); + for (r, &at) in run_at.iter().enumerate() { + // SAFETY: the indices `base | at .. + run_len` lie inside + // the state (every bit is below n), and they belong to tile + // `t` alone: tiles differ in the outer bits, and runs of a + // tile in the free bits. Each tile is taken by one thread. + unsafe { + core::ptr::copy_nonoverlapping(raw.re().add(base | at), buf_re.as_mut_ptr().add(r * run_len), run_len); + core::ptr::copy_nonoverlapping(raw.im().add(base | at), buf_im.as_mut_ptr().add(r * run_len), run_len); + } + } + for p in &prepared { + p.apply(buf_re, buf_im, base); + } + for (r, &at) in run_at.iter().enumerate() { + // SAFETY: as for the copy in, in the other direction. + unsafe { + core::ptr::copy_nonoverlapping(buf_re.as_ptr().add(r * run_len), raw.re().add(base | at), run_len); + core::ptr::copy_nonoverlapping(buf_im.as_ptr().add(r * run_len), raw.im().add(base | at), run_len); + } + } + } + }; + let workers = threads.min(tiles).max(1); + if workers == 1 || cfg!(target_arch = "wasm32") { + let (mut br, mut bi) = (vec![0.0; tile_len], vec![0.0; tile_len]); + work(0, tiles, &mut br, &mut bi); + return; + } + // Tiles go out in small batches, so faster cores take more of them. + let batch = (tiles / (8 * workers)).max(1); + let next = std::sync::atomic::AtomicUsize::new(0); + std::thread::scope(|sc| { + for _ in 0..workers { + let (work, next) = (&work, &next); + sc.spawn(move || { + let (mut br, mut bi) = (vec![0.0; tile_len], vec![0.0; tile_len]); + loop { + let first = next.fetch_add(batch, std::sync::atomic::Ordering::Relaxed); + if first >= tiles { + break; + } + work(first, (first + batch).min(tiles), &mut br, &mut bi); + } + }); + } + }); + } + fn apply_unchecked(&mut self, g: &Gate) { let n = self.n; let k = g.qubits.len(); @@ -800,6 +1163,92 @@ mod tests { } } + /// Circuits that reach every kind of pass: one- and two-qubit gates + /// anywhere, diagonal gates on far-apart qubits, three-qubit gates, and + /// (fused) gates wider than a small tile's room. + fn mixed_circuit(n: u32, depth: usize, seed: u64) -> Vec { + let mut u = rng(seed); + let mut g = random_circuit(n, depth, seed); + for _ in 0..depth { + let a = (u() * n as f64) as u32; + let b = (a + 1 + (u() * (n - 1) as f64) as u32) % n; + let c = (b + 1 + (u() * (n - 2) as f64) as u32) % n; + g.push(Gate::cphase(a, b, u() * 3.0)); + g.push(Gate::rzz(a, b, u() * 3.0)); + if c != a { + // A Toffoli-like three-qubit gate: dense, with a phase. + let mut m = vec![C { re: 0.0, im: 0.0 }; 64]; + for i in 0..8 { + let j = if i >= 6 { i ^ 1 } else { i }; + m[i * 8 + j] = C { re: (u() * 3.0).cos(), im: (u() * 3.0).sin() }; + } + g.push(Gate { qubits: vec![a, b, c], matrix: m }); + } + g.push(Gate::fsim(c, a, u(), u())); + } + g + } + + /// Tiled or not, with tiles of any size and runs of any length, the state + /// is the same bits: the schedule only regroups passes. + #[test] + fn the_schedule_never_changes_a_bit() { + let bits = |sv: &StateVector| -> Vec { sv.re.iter().chain(&sv.im).map(|x| x.to_bits()).collect() }; + let schedules = [ + Schedule::DEFAULT, + Schedule { tile_bits: 6, run_bits: 2 }, + Schedule { tile_bits: 8, run_bits: 3 }, + Schedule { tile_bits: 9, run_bits: 0 }, + Schedule { tile_bits: 11, run_bits: 5 }, + ]; + for (n, seed) in [(12u32, 1u64), (14, 2), (17, 3)] { + let gates = mixed_circuit(n, 6, seed); + for width in [0usize, 2, 3, 4] { + let mut want = StateVector::zero(n).unwrap(); + want.run_scheduled(&gates, width, Schedule::UNTILED).unwrap(); + let want = bits(&want); + for s in schedules { + for threads in [Some(1), None] { + FORCE_THREADS.with(|f| f.set(threads)); + let mut sv = StateVector::zero(n).unwrap(); + sv.run_scheduled(&gates, width, s).unwrap(); + FORCE_THREADS.with(|f| f.set(None)); + assert!(bits(&sv) == want, "n {n} width {width} {s:?} threads {threads:?}"); + } + } + } + } + } + + /// The planner keeps gate order, gives each pass at most a tile's room, + /// and sends a gate wider than the room to a pass of its own. + #[test] + fn passes_keep_order_and_room() { + let n = 20; + let gates = fuse(&mixed_circuit(n, 5, 4), 4); + for (tile, low) in [(8u32, 3u32), (10, 5), (15, 5)] { + let stages = plan(&gates, n, tile, low); + let mut order = Vec::new(); + for st in &stages { + match st { + Stage::Direct(i) => { + assert!(gates[*i].qubits.iter().filter(|&&q| n - 1 - q >= low).count() > (tile - low) as usize); + order.push(*i); + } + Stage::Tiled { gates: ids, local } => { + assert!(local.len() <= (tile - low) as usize && local.iter().all(|&p| p >= low && p < n)); + for &i in ids { + let g = &gates[i]; + assert!(g.is_diagonal() || g.qubits.iter().all(|&q| n - 1 - q < low || local.contains(&(n - 1 - q)))); + } + order.extend(ids); + } + } + } + assert_eq!(order, (0..gates.len()).collect::>()); + } + } + #[test] fn named_gates_are_unitary_and_correct() { // √X² = X, √Y² = Y, √W² = W, T² = S, S² = Z, fSim(π/2, 0) = iSWAP up to sign. diff --git a/wai/wai-quantum/src/quantum_tableau.rs b/wai/wai-quantum/src/quantum_tableau.rs new file mode 100644 index 000000000..bfd50df9c --- /dev/null +++ b/wai/wai-quantum/src/quantum_tableau.rs @@ -0,0 +1,751 @@ +//! Wide stabilizer simulation — `wai.quantum.tableau`. +//! +//! A Clifford circuit on `n` qubits, held as the Aaronson–Gottesman tableau +//! (`n` destabilizers and `n` stabilizers, each a signed Pauli string), packed +//! qubit by qubit: the X bits of one qubit across all `2n` rows are one run of +//! words, and so are its Z bits. A gate then touches one or two such runs, a +//! few word operations per 64 rows, so a circuit costs `O(gates · n / 64)`. +//! +//! Two readouts, both exact: +//! +//! - **Outcomes** ([`Tableau::support`]). The computational-basis outcomes of a +//! stabilizer state are uniform over an affine subspace `x₀ + span(v₁…v_k)` +//! of `GF(2)^n`. Gaussian elimination on the stabilizers (with their signs +//! tracked through every product) leaves `k` generators with independent X +//! parts and `n − k` that are `±Z`-strings; each of the latter is one linear +//! constraint `z·x = r` on the outcomes, and the constraints' solutions are +//! the subspace. It is found once, in `O(n³/64)`; a shot is then `x₀` plus a +//! random combination of the `v`, `O(k · m / 64)` for `m` measured qubits +//! ([`Sampler`]). +//! - **Expectation values** ([`Tableau::expectation`]). For a Pauli string `P`, +//! `⟨P⟩` is `0` if `P` anticommutes with a stabilizer; otherwise `P` is, up to +//! sign, the product of the stabilizers whose destabilizers anticommute with +//! it, and the sign of that product is `⟨P⟩`. `O(n²/64)`. +//! +//! Signs follow Aaronson and Gottesman's `g` (the power of `i` when two Pauli +//! letters multiply), evaluated a word at a time. + +/// The most qubits a tableau holds: the tableau is `4n²` bits, so 32768 +/// qubits take 512 MiB, and the outcome subspace about as much again. +pub const MAX_QUBITS: u32 = 1 << 15; + +/// One Clifford gate. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Clifford { + H(u32), + S(u32), + Sdg(u32), + X(u32), + Y(u32), + Z(u32), + /// √X. + SX(u32), + /// √X†. + SXdg(u32), + /// `CX(control, target)`. + CX(u32, u32), + /// `CY(control, target)`. + CY(u32, u32), + CZ(u32, u32), + Swap(u32, u32), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TableauError { + TooManyQubits(u32), + QubitOutOfRange { qubit: u32, n: u32 }, + /// A two-qubit gate or a Pauli string naming one qubit twice. + RepeatedQubit(u32), + /// A Pauli factor that is not `X`, `Y`, `Z` or `I`. + BadPauli(char), +} + +impl std::fmt::Display for TableauError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TableauError::TooManyQubits(n) => write!(f, "{n} qubits; a tableau holds at most {MAX_QUBITS}"), + TableauError::QubitOutOfRange { qubit, n } => write!(f, "qubit {qubit} of a {n}-qubit tableau"), + TableauError::RepeatedQubit(q) => write!(f, "qubit {q} named twice"), + TableauError::BadPauli(c) => write!(f, "{c:?} is not a Pauli factor"), + } + } +} + +impl std::error::Error for TableauError {} + +/// The tableau of a stabilizer state: rows `0..n` the destabilizers, rows +/// `n..2n` the stabilizers, packed qubit by qubit. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Tableau { + n: usize, + /// Words per qubit's run of `2n` row bits. + rw: usize, + xs: Vec, + zs: Vec, + /// The rows' signs, `2n` bits. + rs: Vec, +} + +fn words(bits: usize) -> usize { + bits.div_ceil(64) +} + +fn get(v: &[u64], i: usize) -> bool { + v[i / 64] >> (i % 64) & 1 == 1 +} + +fn set(v: &mut [u64], i: usize) { + v[i / 64] |= 1 << (i % 64); +} + +fn splitmix(s: &mut u64) -> u64 { + *s = s.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *s; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +/// Rows held one after another, `wn` words of X then of Z each, with signs. +struct Rows { + wn: usize, + x: Vec, + z: Vec, + r: Vec, +} + +impl Rows { + fn new(count: usize, n: usize) -> Rows { + let wn = words(n); + Rows { wn, x: vec![0; count * wn], z: vec![0; count * wn], r: vec![false; count] } + } + + fn xr(&self, i: usize) -> &[u64] { + &self.x[i * self.wn..(i + 1) * self.wn] + } + + fn zr(&self, i: usize) -> &[u64] { + &self.z[i * self.wn..(i + 1) * self.wn] + } + + fn swap(&mut self, a: usize, b: usize) { + if a == b { + return; + } + for w in 0..self.wn { + self.x.swap(a * self.wn + w, b * self.wn + w); + self.z.swap(a * self.wn + w, b * self.wn + w); + } + self.r.swap(a, b); + } + + /// Row `h` ← row `i` · row `h`, sign included (the rows commute). + fn mul_into(&mut self, h: usize, i: usize) { + let (wn, mut acc) = (self.wn, 0i64); + for w in 0..wn { + let (x1, z1) = (self.x[i * wn + w], self.z[i * wn + w]); + let (x2, z2) = (self.x[h * wn + w], self.z[h * wn + w]); + acc += phase_word(x1, z1, x2, z2); + self.x[h * wn + w] = x1 ^ x2; + self.z[h * wn + w] = z1 ^ z2; + } + let total = (2 * self.r[h] as i64 + 2 * self.r[i] as i64 + acc).rem_euclid(4); + self.r[h] = total == 2; + } +} + +/// `Σ g(x1, z1, x2, z2)` over one word's 64 qubits: the power of `i` the +/// product of the two Paulis' letters carries. +fn phase_word(x1: u64, z1: u64, x2: u64, z2: u64) -> i64 { + let y1 = x1 & z1; + let xo = x1 & !z1; + let zo = !x1 & z1; + let plus = (y1 & z2 & !x2) | (xo & z2 & x2) | (zo & x2 & !z2); + let minus = (y1 & x2 & !z2) | (xo & z2 & !x2) | (zo & x2 & z2); + plus.count_ones() as i64 - minus.count_ones() as i64 +} + +impl Tableau { + /// `|0…0⟩` on `n` qubits. + pub fn new(n: u32) -> Result { + if n > MAX_QUBITS { + return Err(TableauError::TooManyQubits(n)); + } + let n = n as usize; + let rw = words(2 * n); + let mut t = Tableau { n, rw, xs: vec![0; n * rw], zs: vec![0; n * rw], rs: vec![0; rw] }; + for q in 0..n { + // Destabilizer q is X_q; stabilizer q is Z_q. + set(&mut t.xs[q * rw..(q + 1) * rw], q); + set(&mut t.zs[q * rw..(q + 1) * rw], n + q); + } + Ok(t) + } + + /// The circuit `ops` on `n` qubits from `|0…0⟩`. + pub fn run(n: u32, ops: &[Clifford]) -> Result { + let mut t = Tableau::new(n)?; + for &op in ops { + t.apply(op)?; + } + Ok(t) + } + + pub fn qubits(&self) -> u32 { + self.n as u32 + } + + fn check(&self, q: u32) -> Result { + if (q as usize) < self.n { Ok(q as usize) } else { Err(TableauError::QubitOutOfRange { qubit: q, n: self.n as u32 }) } + } + + fn pair(&self, a: u32, b: u32) -> Result<(usize, usize), TableauError> { + let (a, b) = (self.check(a)?, self.check(b)?); + if a == b { + return Err(TableauError::RepeatedQubit(a as u32)); + } + Ok((a, b)) + } + + fn h(&mut self, a: usize) { + let rw = self.rw; + for w in 0..rw { + let (x, z) = (self.xs[a * rw + w], self.zs[a * rw + w]); + self.rs[w] ^= x & z; + self.xs[a * rw + w] = z; + self.zs[a * rw + w] = x; + } + } + + fn s(&mut self, a: usize) { + let rw = self.rw; + for w in 0..rw { + let (x, z) = (self.xs[a * rw + w], self.zs[a * rw + w]); + self.rs[w] ^= x & z; + self.zs[a * rw + w] = z ^ x; + } + } + + fn sdg(&mut self, a: usize) { + let rw = self.rw; + for w in 0..rw { + let (x, z) = (self.xs[a * rw + w], self.zs[a * rw + w]); + self.rs[w] ^= x & !z; + self.zs[a * rw + w] = z ^ x; + } + } + + fn cx(&mut self, a: usize, b: usize) { + let rw = self.rw; + for w in 0..rw { + let (xa, za, xb, zb) = (self.xs[a * rw + w], self.zs[a * rw + w], self.xs[b * rw + w], self.zs[b * rw + w]); + self.rs[w] ^= xa & zb & !(xb ^ za); + self.xs[b * rw + w] = xb ^ xa; + self.zs[a * rw + w] = za ^ zb; + } + } + + /// Applies one gate. + pub fn apply(&mut self, op: Clifford) -> Result<(), TableauError> { + let rw = self.rw; + match op { + Clifford::H(a) => { + let a = self.check(a)?; + self.h(a); + } + Clifford::S(a) => { + let a = self.check(a)?; + self.s(a); + } + Clifford::Sdg(a) => { + let a = self.check(a)?; + self.sdg(a); + } + Clifford::X(a) => { + let a = self.check(a)?; + for w in 0..rw { + self.rs[w] ^= self.zs[a * rw + w]; + } + } + Clifford::Z(a) => { + let a = self.check(a)?; + for w in 0..rw { + self.rs[w] ^= self.xs[a * rw + w]; + } + } + Clifford::Y(a) => { + let a = self.check(a)?; + for w in 0..rw { + self.rs[w] ^= self.xs[a * rw + w] ^ self.zs[a * rw + w]; + } + } + Clifford::SX(a) => { + let a = self.check(a)?; + self.h(a); + self.s(a); + self.h(a); + } + Clifford::SXdg(a) => { + let a = self.check(a)?; + self.h(a); + self.sdg(a); + self.h(a); + } + Clifford::CX(a, b) => { + let (a, b) = self.pair(a, b)?; + self.cx(a, b); + } + Clifford::CZ(a, b) => { + let (a, b) = self.pair(a, b)?; + self.h(b); + self.cx(a, b); + self.h(b); + } + Clifford::CY(a, b) => { + let (a, b) = self.pair(a, b)?; + self.sdg(b); + self.cx(a, b); + self.s(b); + } + Clifford::Swap(a, b) => { + let (a, b) = self.pair(a, b)?; + for w in 0..rw { + self.xs.swap(a * rw + w, b * rw + w); + self.zs.swap(a * rw + w, b * rw + w); + } + } + } + Ok(()) + } + + /// Rows `from..to` of the tableau, held row by row. + fn rows(&self, from: usize, to: usize) -> Rows { + let (n, rw) = (self.n, self.rw); + let mut out = Rows::new(to - from, n); + let wn = out.wn; + for q in 0..n { + for (bits, dst) in [(&self.xs, &mut out.x), (&self.zs, &mut out.z)] { + let col = &bits[q * rw..(q + 1) * rw]; + for (w, &bits_w) in col.iter().enumerate().take(to.div_ceil(64)).skip(from / 64) { + let mut word = bits_w; + while word != 0 { + let row = w * 64 + word.trailing_zeros() as usize; + word &= word - 1; + if (from..to).contains(&row) { + dst[(row - from) * wn + q / 64] |= 1 << (q % 64); + } + } + } + } + } + for row in from..to { + out.r[row - from] = get(&self.rs, row); + } + out + } + + /// `⟨ψ|P|ψ⟩` for the Pauli string `P` (`(qubit, 'X'|'Y'|'Z'|'I')`): + /// `1`, `−1`, or `0`. + pub fn expectation(&self, pauli: &[(u32, char)]) -> Result { + let n = self.n; + let wn = words(n); + let (mut px, mut pz) = (vec![0u64; wn], vec![0u64; wn]); + let mut seen = vec![false; n]; + for &(q, c) in pauli { + let q = self.check(q)?; + if seen[q] { + return Err(TableauError::RepeatedQubit(q as u32)); + } + seen[q] = true; + match c.to_ascii_uppercase() { + 'X' => set(&mut px, q), + 'Z' => set(&mut pz, q), + 'Y' => { + set(&mut px, q); + set(&mut pz, q); + } + 'I' => {} + other => return Err(TableauError::BadPauli(other)), + } + } + let rows = self.rows(0, 2 * n); + let anticommutes = |i: usize| -> bool { + let (rx, rz) = (rows.xr(i), rows.zr(i)); + let mut c = 0u32; + for w in 0..wn { + c += ((px[w] & rz[w]) ^ (pz[w] & rx[w])).count_ones(); + } + c % 2 == 1 + }; + if (n..2 * n).any(anticommutes) { + return Ok(0); + } + // P is ± the product of the stabilizers whose destabilizers it + // anticommutes with. + let mut q = Rows::new(1, n); + let mut scratch = Rows::new(2, n); + for i in 0..n { + if anticommutes(i) { + scratch.x[..wn].copy_from_slice(&q.x[..wn]); + scratch.z[..wn].copy_from_slice(&q.z[..wn]); + scratch.r[0] = q.r[0]; + scratch.x[wn..].copy_from_slice(rows.xr(n + i)); + scratch.z[wn..].copy_from_slice(rows.zr(n + i)); + scratch.r[1] = rows.r[n + i]; + scratch.mul_into(0, 1); + q.x.copy_from_slice(&scratch.x[..wn]); + q.z.copy_from_slice(&scratch.z[..wn]); + q.r[0] = scratch.r[0]; + } + } + debug_assert!(q.x == px && q.z == pz, "the product of stabilizers is P up to sign"); + Ok(if q.r[0] { -1 } else { 1 }) + } + + /// The computational-basis outcomes, as the affine subspace they are + /// uniform over. + pub fn support(&self) -> Support { + let n = self.n; + let mut rows = self.rows(n, 2 * n); + let wn = rows.wn; + // Eliminate the X parts: pivot rows first, ±Z-strings after. + let mut k = 0; + for q in 0..n { + let Some(p) = (k..n).find(|&i| get(rows.xr(i), q)) else { continue }; + rows.swap(k, p); + for i in 0..n { + if i != k && get(rows.xr(i), q) { + rows.mul_into(i, k); + } + } + k += 1; + } + // Each ±Z-string is a constraint z·x = r; reduce them to echelon form. + let mut eqs: Vec<(Vec, bool)> = (k..n).map(|i| (rows.zr(i).to_vec(), rows.r[i])).collect(); + let mut pivots: Vec = Vec::new(); + let mut rank = 0; + for q in 0..n { + let Some(p) = (rank..eqs.len()).find(|&i| get(&eqs[i].0, q)) else { continue }; + eqs.swap(rank, p); + let (pz, pr) = eqs[rank].clone(); + for (i, e) in eqs.iter_mut().enumerate() { + if i != rank && get(&e.0, q) { + for (a, b) in e.0.iter_mut().zip(&pz) { + *a ^= b; + } + e.1 ^= pr; + } + } + pivots.push(q); + rank += 1; + } + let mut is_pivot = vec![false; n]; + for &c in &pivots { + is_pivot[c] = true; + } + // x₀: the free variables 0, each pivot variable its equation's sign. + let mut x0 = vec![0u64; wn]; + for (i, &c) in pivots.iter().enumerate() { + if eqs[i].1 { + set(&mut x0, c); + } + } + // One basis vector per free variable. + let mut basis = Vec::with_capacity(n - rank); + for f in (0..n).filter(|&c| !is_pivot[c]) { + let mut v = vec![0u64; wn]; + set(&mut v, f); + for (i, &c) in pivots.iter().enumerate() { + if get(&eqs[i].0, f) { + set(&mut v, c); + } + } + basis.push(v); + } + eqs.truncate(rank); + Support { n, x0, basis, constraints: eqs } + } +} + +/// The computational-basis outcomes of a stabilizer state: uniform over +/// `x₀ + span(basis)`. Bit `q` of an outcome is qubit `q`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Support { + n: usize, + x0: Vec, + basis: Vec>, + /// The constraints `z·x = r` the outcomes satisfy, in echelon form. + constraints: Vec<(Vec, bool)>, +} + +impl Support { + /// `k`: the outcomes are `2^k` equally likely bit strings. + pub fn dimension(&self) -> usize { + self.basis.len() + } + + /// Whether `bits` (bit `q` = qubit `q`, `n` bits) is an outcome: every + /// constraint `z·x = r` holds. + pub fn contains(&self, bits: &[u64]) -> bool { + self.constraints.iter().all(|(z, r)| { + let parity = z.iter().zip(bits.iter().chain(std::iter::repeat(&0))).map(|(a, b)| (a & b).count_ones()).sum::() % 2 == 1; + parity == *r + }) + } + + /// A sampler for the qubits `measured`, in that order. + pub fn sampler(&self, measured: &[u32]) -> Sampler { + let m = measured.len(); + let wm = words(m); + let project = |v: &[u64]| -> Vec { + let mut out = vec![0u64; wm]; + for (j, &q) in measured.iter().enumerate() { + if (q as usize) < self.n && get(v, q as usize) { + set(&mut out, j); + } + } + out + }; + let x0 = project(&self.x0); + let basis: Vec> = self.basis.iter().map(|v| project(v)).filter(|v| v.iter().any(|&w| w != 0)).collect(); + Sampler { m, x0, basis } + } +} + +/// Shots of the measured qubits: `x₀` plus a uniformly random combination of +/// the basis, projected onto them. +#[derive(Clone, Debug)] +pub struct Sampler { + m: usize, + x0: Vec, + basis: Vec>, +} + +impl Sampler { + /// The measured qubits' count. + pub fn width(&self) -> usize { + self.m + } + + /// The independent directions the measured outcomes vary in (at most the + /// support's dimension). + pub fn dimension(&self) -> usize { + self.basis.len() + } + + /// One shot into `out` (bit `j` = the `j`-th measured qubit), drawing from + /// `rng`. + pub fn shot(&self, rng: &mut u64, out: &mut Vec) { + out.clear(); + out.extend_from_slice(&self.x0); + let mut bits = 0u64; + for (j, v) in self.basis.iter().enumerate() { + if j % 64 == 0 { + bits = splitmix(rng); + } + if bits >> (j % 64) & 1 == 1 { + for (o, w) in out.iter_mut().zip(v) { + *o ^= w; + } + } + } + } + + /// `shots` shots from `seed`, counted: `(outcome, count)`, most frequent + /// first, then by outcome. + pub fn counts(&self, shots: u64, seed: u64) -> Vec<(Vec, u64)> { + let mut rng = seed ^ 0x7AB1_EA00_5EED_0001; + let mut map: std::collections::HashMap, u64> = std::collections::HashMap::new(); + let mut buf = Vec::new(); + for _ in 0..shots { + self.shot(&mut rng, &mut buf); + *map.entry(buf.clone()).or_insert(0) += 1; + } + let mut v: Vec<(Vec, u64)> = map.into_iter().collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + v + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn next(s: &mut u64) -> u64 { + splitmix(s) + } + + fn random_clifford(n: u32, ops: usize, seed: u64) -> Vec { + let mut s = seed; + (0..ops) + .map(|_| { + let a = (next(&mut s) % n as u64) as u32; + let mut b = (next(&mut s) % n as u64) as u32; + if b == a { + b = (a + 1) % n; + } + match next(&mut s) % 12 { + 0 => Clifford::H(a), + 1 => Clifford::S(a), + 2 => Clifford::Sdg(a), + 3 => Clifford::X(a), + 4 => Clifford::Y(a), + 5 => Clifford::Z(a), + 6 => Clifford::SX(a), + 7 => Clifford::SXdg(a), + 8 => Clifford::CX(a, b), + 9 => Clifford::CY(a, b), + 10 => Clifford::CZ(a, b), + _ => Clifford::Swap(a, b), + } + }) + .collect() + } + + #[test] + fn the_zero_state_and_a_wide_ghz_state_read_exactly() { + let t = Tableau::new(5).unwrap(); + assert_eq!(t.support().dimension(), 0); + assert_eq!(t.expectation(&[(3, 'Z')]).unwrap(), 1); + assert_eq!(t.expectation(&[(3, 'X')]).unwrap(), 0); + let n = 2000u32; + let mut ops = vec![Clifford::H(0)]; + ops.extend((0..n - 1).map(|q| Clifford::CX(q, q + 1))); + let t = Tableau::run(n, &ops).unwrap(); + let s = t.support(); + assert_eq!(s.dimension(), 1); + assert_eq!(t.expectation(&[(0, 'Z'), (n - 1, 'Z')]).unwrap(), 1); + assert_eq!(t.expectation(&[(7, 'Z')]).unwrap(), 0); + let all_x: Vec<(u32, char)> = (0..n).map(|q| (q, 'X')).collect(); + assert_eq!(t.expectation(&all_x).unwrap(), 1); + // Y on every qubit of an even GHZ: ⟨Y^{⊗n}⟩ = Re(i^n) = 1 for n ≡ 0 mod 4. + let all_y: Vec<(u32, char)> = (0..n).map(|q| (q, 'Y')).collect(); + assert_eq!(t.expectation(&all_y).unwrap(), 1); + let measured: Vec = (0..n).collect(); + let c = s.sampler(&measured).counts(1000, 3); + assert_eq!(c.len(), 2); + assert!(c.iter().all(|(o, k)| *k > 400 && (o.iter().all(|&w| w == 0) || o.iter().map(|w| w.count_ones()).sum::() == n))); + } + + #[test] + fn a_gate_and_its_inverse_leave_the_tableau_as_it_was() { + let base = Tableau::run(6, &random_clifford(6, 40, 9)).unwrap(); + for a in [ + vec![Clifford::H(2), Clifford::H(2)], + vec![Clifford::S(1), Clifford::Sdg(1)], + vec![Clifford::SX(4), Clifford::SXdg(4)], + vec![Clifford::CX(0, 3), Clifford::CX(0, 3)], + vec![Clifford::CY(5, 1), Clifford::CY(5, 1)], + vec![Clifford::CZ(2, 4), Clifford::CZ(4, 2)], + vec![Clifford::Swap(0, 5), Clifford::Swap(5, 0)], + vec![Clifford::S(3), Clifford::S(3), Clifford::S(3), Clifford::S(3)], + ] { + let mut t = base.clone(); + for op in a.clone() { + t.apply(op).unwrap(); + } + assert_eq!(t, base, "{a:?}"); + } + } + + #[test] + fn refusals_are_typed() { + assert_eq!(Tableau::new(MAX_QUBITS + 1).unwrap_err(), TableauError::TooManyQubits(MAX_QUBITS + 1)); + let mut t = Tableau::new(3).unwrap(); + assert_eq!(t.apply(Clifford::H(3)).unwrap_err(), TableauError::QubitOutOfRange { qubit: 3, n: 3 }); + assert_eq!(t.apply(Clifford::CX(1, 1)).unwrap_err(), TableauError::RepeatedQubit(1)); + assert_eq!(t.expectation(&[(0, 'Q')]).unwrap_err(), TableauError::BadPauli('Q')); + assert_eq!(t.expectation(&[(0, 'Z'), (0, 'X')]).unwrap_err(), TableauError::RepeatedQubit(0)); + } + + /// Times at scale; run with `--ignored --nocapture`. + #[test] + #[ignore] + fn timings_at_scale() { + for n in [1000u32, 2000, 5000, 10000] { + let ops = random_clifford(n, 10 * n as usize, n as u64); + let t0 = std::time::Instant::now(); + let t = Tableau::run(n, &ops).unwrap(); + let gates = t0.elapsed(); + let t1 = std::time::Instant::now(); + let s = t.support(); + let support = t1.elapsed(); + let measured: Vec = (0..n).collect(); + let t2 = std::time::Instant::now(); + let c = s.sampler(&measured).counts(1000, 1); + let shots = t2.elapsed(); + let t3 = std::time::Instant::now(); + let e = t.expectation(&[(0, 'Z'), (n / 2, 'X')]).unwrap(); + let expect = t3.elapsed(); + println!("n={n}: {} gates {gates:?}; support dim {} in {support:?}; 1000 shots ({} distinct) {shots:?}; one expectation ({e}) {expect:?}", ops.len(), s.dimension(), c.len()); + } + } + + #[cfg(feature = "quantum_sv")] + mod against_the_state_vector { + use super::*; + use crate::gates::{Gate, complex}; + use crate::quantum_sv::StateVector; + + fn matrix(op: Clifford) -> Gate { + let c = |re: f64, im: f64| complex(re, im); + let o = c(0.0, 0.0); + let one = c(1.0, 0.0); + let ctrl = |a: u32, b: u32, u: [crate::gates::C; 4]| Gate { qubits: vec![a, b], matrix: vec![one, o, o, o, o, one, o, o, o, o, u[0], u[1], o, o, u[2], u[3]] }; + match op { + Clifford::H(a) => Gate::h(a), + Clifford::S(a) => Gate::s(a), + Clifford::Sdg(a) => Gate::sdg(a), + Clifford::X(a) => Gate::x(a), + Clifford::Y(a) => Gate::y(a), + Clifford::Z(a) => Gate::z(a), + Clifford::SX(a) => Gate { qubits: vec![a], matrix: vec![c(0.5, 0.5), c(0.5, -0.5), c(0.5, -0.5), c(0.5, 0.5)] }, + Clifford::SXdg(a) => Gate { qubits: vec![a], matrix: vec![c(0.5, -0.5), c(0.5, 0.5), c(0.5, 0.5), c(0.5, -0.5)] }, + Clifford::CX(a, b) => Gate::cx(a, b), + Clifford::CY(a, b) => ctrl(a, b, [o, c(0.0, -1.0), c(0.0, 1.0), o]), + Clifford::CZ(a, b) => Gate::cz(a, b), + Clifford::Swap(a, b) => Gate::swap(a, b), + } + } + + #[test] + fn outcomes_and_expectations_are_the_state_vectors_on_random_circuits() { + for seed in 0..40 { + let n = 2 + (seed % 9) as u32; + let ops = random_clifford(n, 60, seed); + let t = Tableau::run(n, &ops).unwrap(); + let mut sv = StateVector::zero(n).unwrap(); + sv.run(&ops.iter().map(|&o| matrix(o)).collect::>(), 1).unwrap(); + let s = t.support(); + let p = 1.0 / (1u64 << s.dimension()) as f64; + let mut support_size = 0; + for x in 0..1usize << n { + let prob = sv.re[x] * sv.re[x] + sv.im[x] * sv.im[x]; + // The state vector's index has qubit 0 as its most significant bit. + let mut bits = vec![0u64; 1]; + for q in 0..n as usize { + if x >> (n as usize - 1 - q) & 1 == 1 { + bits[0] |= 1 << q; + } + } + let inside = s.contains(&bits); + if prob > 1e-9 { + support_size += 1; + assert!(inside, "seed {seed}: outcome {x:b} has probability {prob} but is outside the support"); + assert!((prob - p).abs() < 1e-9, "seed {seed}: probability {prob}, expected {p}"); + } else { + assert!(!inside, "seed {seed}: outcome {x:b} is in the support with probability {prob}"); + } + } + assert_eq!(support_size, 1 << s.dimension(), "seed {seed}"); + let mut r = seed ^ 0xabc; + for _ in 0..20 { + let string: Vec<(u32, char)> = (0..n).filter_map(|q| match next(&mut r) % 4 { 0 => None, 1 => Some((q, 'X')), 2 => Some((q, 'Y')), _ => Some((q, 'Z')) }).collect(); + let want = sv.expectation(&[(1.0, string.clone())]); + let got = t.expectation(&string).unwrap() as f64; + assert!((got - want).abs() < 1e-9, "seed {seed}: {string:?}: {got} vs {want}"); + } + } + } + } +} diff --git a/wai/wai-quantum/src/repro.rs b/wai/wai-quantum/src/repro.rs index f0099dbf0..687e6a10e 100644 --- a/wai/wai-quantum/src/repro.rs +++ b/wai/wai-quantum/src/repro.rs @@ -38,28 +38,28 @@ pub use trig::sin_cos; // Natural logarithm // --------------------------------------------------------------------------- -#[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg"))] +#[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg", feature = "quantum_noisy"))] const LN2_HI: f64 = 6.931_471_803_691_238_164_90e-1; -#[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg"))] +#[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg", feature = "quantum_noisy"))] const LN2_LO: f64 = 1.908_214_929_270_587_700_02e-10; -#[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg"))] +#[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg", feature = "quantum_noisy"))] const LG1: f64 = 6.666_666_666_666_735_130e-1; -#[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg"))] +#[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg", feature = "quantum_noisy"))] const LG2: f64 = 3.999_999_999_940_941_908e-1; -#[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg"))] +#[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg", feature = "quantum_noisy"))] const LG3: f64 = 2.857_142_874_366_239_149e-1; -#[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg"))] +#[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg", feature = "quantum_noisy"))] const LG4: f64 = 2.222_219_843_214_978_396e-1; -#[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg"))] +#[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg", feature = "quantum_noisy"))] const LG5: f64 = 1.818_357_216_161_805_012e-1; -#[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg"))] +#[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg", feature = "quantum_noisy"))] const LG6: f64 = 1.531_383_769_920_937_332e-1; -#[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg"))] +#[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg", feature = "quantum_noisy"))] const LG7: f64 = 1.479_819_860_511_658_591e-1; /// The natural logarithm, within an ulp of the true value: `−∞` at zero, NaN /// below it. -#[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg"))] +#[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg", feature = "quantum_noisy"))] pub fn ln(x: f64) -> f64 { let mut bits = x.to_bits(); let mut hx = (bits >> 32) as u32; @@ -359,7 +359,7 @@ mod tests { }) } - #[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg"))] + #[cfg(any(feature = "quantum_vml", feature = "quantum_kernel", feature = "quantum_resource", feature = "quantum_frame", feature = "quantum_tn", feature = "quantum_gbs", feature = "quantum_arch", feature = "quantum_qio", feature = "quantum_dmrg", feature = "quantum_noisy"))] #[test] fn ln_is_within_an_ulp_of_the_platform() { for x in inputs().filter(|&x| x > 0.0) { diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock index c1ec42dd9..06f163452 100644 --- a/wai/wai-rs/Cargo.lock +++ b/wai/wai-rs/Cargo.lock @@ -4310,7 +4310,7 @@ version = "0.1.0" [[package]] name = "wai-quantum" -version = "0.6.2" +version = "0.6.3" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-web/Cargo.lock b/wai/wai-web/Cargo.lock index 7e93c5ad2..c462a4e7d 100644 --- a/wai/wai-web/Cargo.lock +++ b/wai/wai-web/Cargo.lock @@ -1241,7 +1241,7 @@ version = "0.1.0" [[package]] name = "wai-quantum" -version = "0.6.2" +version = "0.6.3" dependencies = [ "blake3", "ed25519-dalek",