From e08592b8dc84aeae58a6dff0777bf4c663d8aa33 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 17 Oct 2026 09:24:59 -0400 Subject: [PATCH 01/12] wai-quantum-service: a public job service over the WAI quantum stack (work in progress) An OpenQASM 3 front end (registers, broadcast, user gates, angle expressions, every terminal measurement form) lowered three ways: matrix gates, Pauli rotations, and the dyadic gate set, each held to the others by tests. A planner that picks the strongest method a circuit admits (exact, statevector, stabilizer, pauli, tensor_network, density_matrix, trajectories), with stated accuracy classes. Jobs run in child processes inside a memory budget at low priority; per-client limits and a bounded queue; results sealed with an Ed25519 key. 23 tests pass on Linux. Co-Authored-By: Claude Opus 5.5 --- wai/wai-quantum-service/Cargo.lock | 869 ++++++++++++++ wai/wai-quantum-service/Cargo.toml | 21 + wai/wai-quantum-service/src/catalog.rs | 408 +++++++ wai/wai-quantum-service/src/http.rs | 197 ++++ wai/wai-quantum-service/src/jobs.rs | 614 ++++++++++ wai/wai-quantum-service/src/main.rs | 111 ++ wai/wai-quantum-service/src/qasm.rs | 1466 ++++++++++++++++++++++++ wai/wai-quantum-service/src/receipt.rs | 143 +++ wai/wai-quantum-service/src/run.rs | 375 ++++++ 9 files changed, 4204 insertions(+) create mode 100644 wai/wai-quantum-service/Cargo.lock create mode 100644 wai/wai-quantum-service/Cargo.toml create mode 100644 wai/wai-quantum-service/src/catalog.rs create mode 100644 wai/wai-quantum-service/src/http.rs create mode 100644 wai/wai-quantum-service/src/jobs.rs create mode 100644 wai/wai-quantum-service/src/main.rs create mode 100644 wai/wai-quantum-service/src/qasm.rs create mode 100644 wai/wai-quantum-service/src/receipt.rs create mode 100644 wai/wai-quantum-service/src/run.rs diff --git a/wai/wai-quantum-service/Cargo.lock b/wai/wai-quantum-service/Cargo.lock new file mode 100644 index 000000000..61c30e26e --- /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.2" +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..213fb763e --- /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_stabilizer", "quantum_spd", "quantum_noisy"] } + +[profile.release] +lto = "thin" diff --git a/wai/wai-quantum-service/src/catalog.rs b/wai/wai-quantum-service/src/catalog.rs new file mode 100644 index 000000000..55e33f0d1 --- /dev/null +++ b/wai/wai-quantum-service/src/catalog.rs @@ -0,0 +1,408 @@ +//! 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, ≤ 255 qubits | counts | exact | +//! | `pauli` | any, ≤ 1024 qubits | expectations | bounded | +//! | `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`; for expectations only, +//! `exact`, `statevector`, `pauli`; for amplitudes only, `exact`, +//! `statevector`, `tensor_network`. 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 = 255; +pub const PAULI_MAX_QUBITS: u32 = 1024; +pub const DENSITY_MAX_QUBITS: u32 = 12; +pub const TRAJECTORY_MAX_QUBITS: u32 = 32; +/// Shots × measured qubits × qubits² a stabilizer job may take. +pub const STABILIZER_MAX_WORK: u64 = 40_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", + }) +} + +/// 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", "max_qubits": STABILIZER_MAX_QUBITS, "answers": ["counts"] }, + { "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" } }, + { "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 1000)" } }, + ]) +} + +/// The memory a dense state vector of `n` qubits takes with room to run. +pub fn statevector_bytes(n: u32) -> u64 { + let amps = 16u64 << n.min(40); + amps + amps / 32 + 256 * MIB +} + +/// 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) +} + +/// Whether `method` can take the job, and the plan if so; otherwise why not. +fn try_method(method: &str, r: &Request, dyadic: &Result, clifford: bool, 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 !clifford { + return Err("stabilizer needs a Clifford circuit".into()); + } + if n > STABILIZER_MAX_QUBITS { + return Err(format!("stabilizer holds at most {STABILIZER_MAX_QUBITS} qubits")); + } + if wants_amplitudes || wants_expectations { + return Err("stabilizer answers counts only".into()); + } + let measured = r.program.measured.len() as u64; + let work = r.shots.max(1) * measured * (n as u64) * (n as u64) / 64 + gates * n as u64; + if work > STABILIZER_MAX_WORK { + return Err(format!("{} shots of {measured} measured qubits is more stabilizer work than a job may take; ask for fewer shots", r.shots)); + } + Ok(plan(64 * MIB + r.shots * (r.program.n_bits as u64 + 8), work, format!("Clifford circuit: 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"))) + } + "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()); + } + let threads = threads_for(n, cap); + let bytes = statevector_bytes(n).saturating_mul(threads as u64); + 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); + Ok(plan(bytes, (gates << n) * count, format!("{count} noisy trajectories of {n} qubits on {threads} thread(s)"))) + } + other => Err(format!("unknown method {other}; GET /v1/methods lists them")), + } +} + +/// Threads for trajectories: as many state vectors as fit, at most 8. +pub fn threads_for(n: u32, cap: u64) -> usize { + ((cap / statevector_bytes(n).max(1)) as usize).clamp(1, 8) +} + +pub fn trajectory_count(r: &Request) -> u64 { + r.options.get("trajectories").and_then(Value::as_u64).unwrap_or(1000).clamp(1, 100_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 = dyadic.as_ref().is_ok_and(wai_quantum::quantum_toolchain::is_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"] + } else if !r.observables.is_empty() && r.amplitudes.is_empty() { + &["exact", "statevector", "pauli"] + } else { + &["exact", "statevector", "tensor_network"] + }; + 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"); + let e = plan(&job(wide, json!({ "shots": 10 })), CAP).unwrap_err(); + assert!(e.contains("no method takes this job"), "{e}"); + 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..b48d61d0a --- /dev/null +++ b/wai/wai-quantum-service/src/jobs.rs @@ -0,0 +1,614 @@ +//! 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 also waits while the host's own +//! free memory is under [`Config::min_free_pct`], 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 host's free memory, percent, under which no job starts. + 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 for the host's free memory too. + if mib >= 1024 { + while host_free_pct().is_some_and(|p| p < 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) +} + +/// 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); + } + } +} 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..8a933ae56 --- /dev/null +++ b/wai/wai-quantum-service/src/qasm.rs @@ -0,0 +1,1466 @@ +//! 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}; + +/// 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 } + } +} + +/// 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() + } + + /// 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 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}"); + } + } + + #[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..b061816aa --- /dev/null +++ b/wai/wai-quantum-service/src/run.rs @@ -0,0 +1,375 @@ +//! 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() +} + +/// A counts map: the most frequent outcomes (at most [`MAX_OUTCOMES`]), 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 omitted: u64 = v.iter().skip(MAX_OUTCOMES).map(|x| x.1).sum(); + let distinct = v.len(); + v.truncate(MAX_OUTCOMES); + 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" => { + let c = r.program.dyadic()?; + let base = wai_quantum::quantum_stabilizer::run(&c).map_err(|e| format!("not Clifford: {}", e.0))?; + out["accuracy"] = json!({ "class": "exact" }); + let mut rng = r.seed ^ 0x5DEE_CE66_D3A9_1B2C; + let mut map: HashMap = HashMap::new(); + let mut value = vec![false; n as usize]; + for _ in 0..r.shots { + let mut t = base.clone(); + for &(q, _) in &r.program.measured { + value[q as usize] = t.measure(q as usize, &mut rng) == 1; + } + *map.entry(r.program.bitstring(|q| value[q as usize])).or_insert(0) += 1; + } + merge(&mut out, counts(map)); + } + "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); + let cfg = SpdConfig { threshold, max_weight }; + 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 res = expectation(&rc, &[(*coeff, obs)], &[], &cfg).map_err(|e| e.to_string())?; + entries.push(json!({ "observable": text, "coeff": coeff, "value": res.value, "bound": res.truncation_bound, "peak_terms": res.peak_terms })); + } + out["expectations"] = 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::trajectories; + let noise = r.noise.ok_or("no noise model")?; + let count = catalog::trajectory_count(&r) as usize; + let threads = catalog::threads_for(n, plan.memory_bytes.max(catalog::statevector_bytes(n))); + let observables: Vec = r.observables.iter().map(|(_, c, s)| vec![(*c, s.clone())]).collect(); + let run = trajectories(&r.program.matrices(), n, &noise, &observables, count, r.seed, threads).map_err(|e| format!("{e:?}"))?; + out["accuracy"] = json!({ "class": "estimate", "trajectories": count, "note": "expectations are means over trajectories with their standard errors; counts are one shot per trajectory" }); + 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 in &run.samples { + *map.entry(r.program.bitstring(|q| x >> (n - 1 - q) & 1 == 1)).or_insert(0) += 1; + } + merge(&mut out, counts(map)); + out["shots"] = json!(run.samples.len()); + } + } + 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}"); + // 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 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 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); + } +} -- 2.54.0 (Apple Git-157) From 6a3b6bf369a286f61245bbf2f460295b83621d87 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 17 Oct 2026 09:38:43 -0400 Subject: [PATCH 02/12] wai-quantum-service: smoke checks over HTTP, and CI on linux and macos smoke.sh holds a running instance to its promises (methods, a 40-qubit GHZ by the stabilizer, receipts that verify and a changed result that does not, rotations by the state vector, a 100-qubit circuit's expectations by sparse Pauli dynamics with bounds, a refusal with its reason). CI runs the tests, clippy -D warnings and the smoke checks against a local instance. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-quantum-service.yml | 116 ++++++++++++++++++++++ wai/wai-quantum-service/smoke.sh | 83 ++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 .github/workflows/wai-quantum-service.yml create mode 100644 wai/wai-quantum-service/smoke.sh 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/wai-quantum-service/smoke.sh b/wai/wai-quantum-service/smoke.sh new file mode 100644 index 000000000..4869c9ef7 --- /dev/null +++ b/wai/wai-quantum-service/smoke.sh @@ -0,0 +1,83 @@ +#!/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" + +# 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" -- 2.54.0 (Apple Git-157) From 649587508887408a85414d077613f78127706102 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 17 Oct 2026 09:40:09 -0400 Subject: [PATCH 03/12] wai-quantum-service: a big job waits until the host's free memory covers it and the reserve Co-Authored-By: Claude Opus 5.5 --- wai/wai-quantum-service/src/jobs.rs | 42 ++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/wai/wai-quantum-service/src/jobs.rs b/wai/wai-quantum-service/src/jobs.rs index b48d61d0a..7e9655a19 100644 --- a/wai/wai-quantum-service/src/jobs.rs +++ b/wai/wai-quantum-service/src/jobs.rs @@ -8,9 +8,9 @@ //! ([`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 also waits while the host's own -//! free memory is under [`Config::min_free_pct`], so other work on the host -//! comes first. +//! 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 @@ -40,7 +40,8 @@ pub struct Config { pub job_mib: u64, /// Jobs running at once, at most. pub workers: usize, - /// The host's free memory, percent, under which no job starts. + /// 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, @@ -381,9 +382,10 @@ impl Service { 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 for the host's free memory too. + // A job of a gigabyte or more waits until the host's free memory + // covers it and the reserve. if mib >= 1024 { - while host_free_pct().is_some_and(|p| p < self.config.min_free_pct) { + while !host_has_room(mib << 20, self.config.min_free_pct) { tokio::time::sleep(Duration::from_secs(2)).await; } } @@ -522,6 +524,29 @@ fn rss_bytes(pid: u32) -> Option { 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") { @@ -609,6 +634,11 @@ mod tests { 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)); } } } -- 2.54.0 (Apple Git-157) From 49a7714de0d17ed45586e6abeae7e2eb2364f621 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 17 Oct 2026 09:51:27 -0400 Subject: [PATCH 04/12] wai-quantum-service: README and CHANGELOG; live at wai.transaction.science/quantum-jobs/v1 Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 47 +++++++++++++++ wai/wai-quantum-service/README.md | 98 +++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 wai/wai-quantum-service/README.md diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 6734836e3..833936120 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,53 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### 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, 255 qubits, every measured qubit); + - `pauli` (bounded, 1024 qubits, each value with its truncation bound); + - `tensor_network` (exact amplitudes, with the contraction's cost); + - `density_matrix` (exact noise, 12 qubits); + - `trajectories` (noise as an estimate with standard errors). + + `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:** 23 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, 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). + ### 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/wai-quantum-service/README.md b/wai/wai-quantum-service/README.md new file mode 100644 index 000000000..8ea65236d --- /dev/null +++ b/wai/wai-quantum-service/README.md @@ -0,0 +1,98 @@ +# 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` and `max_weight` (pauli), `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, up to 255 qubits | counts | exact | +| `pauli` | any circuit, up to 1024 qubits | expectations | bounded | +| `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 | + +`auto` picks the first method that takes the job. With noise, it tries `density_matrix` then `trajectories`. For counts, it tries `stabilizer`, `exact`, then `statevector`. For expectations alone, it tries `exact`, `statevector`, then `pauli`. For amplitudes alone, it tries `exact`, `statevector`, then `tensor_network`. 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`). + +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. -- 2.54.0 (Apple Git-157) From 60adc03d22f2044d9b591e077812369b04b94b3e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 17 Oct 2026 10:05:18 -0400 Subject: [PATCH 05/12] wai-quantum 0.6.3: the wide stabilizer tableau; the service's stabilizer method on it quantum_tableau (new, in full): Clifford circuits on up to 32768 qubits, the Aaronson-Gottesman tableau packed qubit by qubit with u32 indices. Tableau::support gives the affine subspace the computational-basis outcomes are uniform over (Gaussian elimination with signs tracked); a Sampler draws each shot as a random combination of its basis on the measured qubits. Tableau::expectation gives the exact +-1/0 of a Pauli string from the destabilizers. At 10,000 qubits: 100k gates in 49 ms, the subspace in 353 ms, 1000 full-width shots in 154 ms. Against the dense state vector on 40 random circuits: the outcome set, its uniform probabilities and 800 expectation values agree. The service lowers Clifford circuits to it (quarter-turn rotations, half-turn controlled phases), answers counts and expectations up to 32768 qubits, and caps a counts map's size. Live: a 2000-qubit GHZ with a phase flip reads = -1 in 260 ms. 25 service tests. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 19 +- wai/tools/quantum-meter/Cargo.lock | 2 +- wai/tools/quantum-wgsl-check/Cargo.lock | 2 +- wai/wai-pack/Cargo.lock | 2 +- wai/wai-quantum-qpu/Cargo.lock | 2 +- wai/wai-quantum-serve/Cargo.lock | 2 +- wai/wai-quantum-service/Cargo.lock | 2 +- wai/wai-quantum-service/Cargo.toml | 2 +- wai/wai-quantum-service/README.md | 4 +- wai/wai-quantum-service/src/catalog.rs | 46 +- wai/wai-quantum-service/src/qasm.rs | 188 ++++++ wai/wai-quantum-service/src/run.rs | 72 ++- wai/wai-quantum/Cargo.lock | 2 +- wai/wai-quantum/Cargo.toml | 5 +- wai/wai-quantum/src/lib.rs | 2 + wai/wai-quantum/src/quantum_tableau.rs | 751 ++++++++++++++++++++++++ wai/wai-rs/Cargo.lock | 2 +- wai/wai-web/Cargo.lock | 2 +- 18 files changed, 1058 insertions(+), 49 deletions(-) create mode 100644 wai/wai-quantum/src/quantum_tableau.rs diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 833936120..5e2104945 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,22 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### wai-quantum 0.6.3: the wide stabilizer tableau + +- **`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. +- **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. + ### 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 @@ -28,7 +44,8 @@ wire format may change and such changes are recorded here. - **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, 255 qubits, every measured qubit); + - `stabilizer` (Clifford, 32768 qubits, on the wide tableau below: counts and exact + expectation values); - `pauli` (bounded, 1024 qubits, each value with its truncation bound); - `tensor_network` (exact amplitudes, with the contraction's cost); - `density_matrix` (exact noise, 12 qubits); 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 index 61c30e26e..8dc8c3c70 100644 --- a/wai/wai-quantum-service/Cargo.lock +++ b/wai/wai-quantum-service/Cargo.lock @@ -815,7 +815,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-service/Cargo.toml b/wai/wai-quantum-service/Cargo.toml index 213fb763e..67c73c1a2 100644 --- a/wai/wai-quantum-service/Cargo.toml +++ b/wai/wai-quantum-service/Cargo.toml @@ -15,7 +15,7 @@ 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_stabilizer", "quantum_spd", "quantum_noisy"] } +wai-quantum = { path = "../wai-quantum", default-features = false, features = ["quantum", "quantum_toolchain", "quantum_receipt", "quantum_sv", "quantum_tn", "quantum_tableau", "quantum_spd", "quantum_noisy"] } [profile.release] lto = "thin" diff --git a/wai/wai-quantum-service/README.md b/wai/wai-quantum-service/README.md index 8ea65236d..a9bf3b135 100644 --- a/wai/wai-quantum-service/README.md +++ b/wai/wai-quantum-service/README.md @@ -34,13 +34,13 @@ Counts are keyed by the program's classical bits, the highest bit first. With no |---|---|---|---| | `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, up to 255 qubits | counts | 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 | | `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 | -`auto` picks the first method that takes the job. With noise, it tries `density_matrix` then `trajectories`. For counts, it tries `stabilizer`, `exact`, then `statevector`. For expectations alone, it tries `exact`, `statevector`, then `pauli`. For amplitudes alone, it tries `exact`, `statevector`, then `tensor_network`. The job's `why` names the method chosen and the ones that could not take it. +`auto` picks the first method that takes the job. With noise, it tries `density_matrix` then `trajectories`. For counts, it tries `stabilizer`, `exact`, then `statevector`. For expectations alone, it tries `stabilizer`, `exact`, `statevector`, then `pauli`. For amplitudes alone, it tries `exact`, `statevector`, then `tensor_network`. The job's `why` names the method chosen and the ones that could not take it. The accuracy classes: diff --git a/wai/wai-quantum-service/src/catalog.rs b/wai/wai-quantum-service/src/catalog.rs index 55e33f0d1..e735e5488 100644 --- a/wai/wai-quantum-service/src/catalog.rs +++ b/wai/wai-quantum-service/src/catalog.rs @@ -9,7 +9,7 @@ //! |---|---|---|---| //! | `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, ≤ 255 qubits | counts | exact | +//! | `stabilizer` | Clifford, ≤ 32768 qubits | counts, expectations | exact | //! | `pauli` | any, ≤ 1024 qubits | expectations | bounded | //! | `tensor_network` | any | amplitudes | exact | //! | `density_matrix` | any, noisy, ≤ 12 qubits | counts, expectations | exact | @@ -17,7 +17,7 @@ //! //! `auto` picks, in order: with noise, `density_matrix` then `trajectories`; //! for counts, `stabilizer`, `exact`, `statevector`; for expectations only, -//! `exact`, `statevector`, `pauli`; for amplitudes only, `exact`, +//! `stabilizer`, `exact`, `statevector`, `pauli`; for amplitudes only, `exact`, //! `statevector`, `tensor_network`. A method that cannot take the circuit is //! skipped, and the plan says why the one chosen was chosen. @@ -32,12 +32,13 @@ pub const MAX_AMPLITUDES: usize = 64; pub const MAX_OUTCOMES: usize = 65_536; pub const EXACT_MAX_QUBITS: u32 = 26; -pub const STABILIZER_MAX_QUBITS: u32 = 255; +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; -/// Shots × measured qubits × qubits² a stabilizer job may take. -pub const STABILIZER_MAX_WORK: u64 = 40_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; @@ -70,7 +71,7 @@ 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", "max_qubits": STABILIZER_MAX_QUBITS, "answers": ["counts"] }, + { "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" } }, { "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"] }, @@ -206,7 +207,7 @@ fn memory_option(r: &Request, default_gib: u64, cap: u64) -> u64 { } /// Whether `method` can take the job, and the plan if so; otherwise why not. -fn try_method(method: &str, r: &Request, dyadic: &Result, clifford: bool, cap: u64) -> Result { +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 }; @@ -235,21 +236,28 @@ fn try_method(method: &str, r: &Request, dyadic: &Result { - if !clifford { - return Err("stabilizer needs a Clifford circuit".into()); + 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 || wants_expectations { - return Err("stabilizer answers counts only".into()); + if wants_amplitudes { + return Err("stabilizer answers counts and expectations".into()); } - let measured = r.program.measured.len() as u64; - let work = r.shots.max(1) * measured * (n as u64) * (n as u64) / 64 + gates * n as u64; + 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!("{} shots of {measured} measured qubits is more stabilizer work than a job may take; ask for fewer shots", r.shots)); + 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")); } - Ok(plan(64 * MIB + r.shots * (r.program.n_bits as u64 + 8), work, format!("Clifford circuit: stabilizer tableau of {n} qubits"))) + // 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 { @@ -310,22 +318,22 @@ pub fn trajectory_count(r: &Request) -> u64 { pub fn plan(v: &Value, job_bytes: u64) -> Result { let r = read(v)?; let dyadic = r.program.dyadic(); - let clifford = dyadic.as_ref().is_ok_and(wai_quantum::quantum_toolchain::is_clifford); + let clifford = r.program.clifford(); if r.method != "auto" { - return try_method(&r.method, &r, &dyadic, clifford, job_bytes); + 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"] } else if !r.observables.is_empty() && r.amplitudes.is_empty() { - &["exact", "statevector", "pauli"] + &["stabilizer", "exact", "statevector", "pauli"] } else { &["exact", "statevector", "tensor_network"] }; let mut refusals = Vec::new(); for m in order { - match try_method(m, &r, &dyadic, clifford, job_bytes) { + match try_method(m, &r, &dyadic, &clifford, job_bytes) { Ok(mut p) => { if !refusals.is_empty() { p.why = format!("{}; not {}", p.why, refusals.join("; ")); diff --git a/wai/wai-quantum-service/src/qasm.rs b/wai/wai-quantum-service/src/qasm.rs index 8a933ae56..1d77c668c 100644 --- a/wai/wai-quantum-service/src/qasm.rs +++ b/wai/wai-quantum-service/src/qasm.rs @@ -26,6 +26,7 @@ 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; @@ -922,6 +923,39 @@ impl Op { } } +/// `θ` 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. @@ -1284,6 +1318,102 @@ impl Program { 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. @@ -1447,6 +1577,64 @@ mod tests { } } + /// 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_rotation_lowering_gives_the_matrices_expectation_values() { use wai_quantum::quantum_spd::{SpdConfig, expectation}; diff --git a/wai/wai-quantum-service/src/run.rs b/wai/wai-quantum-service/src/run.rs index b061816aa..462e08aed 100644 --- a/wai/wai-quantum-service/src/run.rs +++ b/wai/wai-quantum-service/src/run.rs @@ -11,14 +11,20 @@ fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } -/// A counts map: the most frequent outcomes (at most [`MAX_OUTCOMES`]), and -/// how many shots the omitted ones held. +/// 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 omitted: u64 = v.iter().skip(MAX_OUTCOMES).map(|x| x.1).sum(); + 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(MAX_OUTCOMES); + v.truncate(keep); let mut o = serde_json::Map::new(); for (k, c) in v { o.insert(k, json!(c)); @@ -150,20 +156,35 @@ pub fn run(request: &Value, plan: &Plan) -> Result { } } "stabilizer" => { - let c = r.program.dyadic()?; - let base = wai_quantum::quantum_stabilizer::run(&c).map_err(|e| format!("not Clifford: {}", e.0))?; + 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" }); - let mut rng = r.seed ^ 0x5DEE_CE66_D3A9_1B2C; - let mut map: HashMap = HashMap::new(); - let mut value = vec![false; n as usize]; - for _ in 0..r.shots { - let mut t = base.clone(); - for &(q, _) in &r.program.measured { - value[q as usize] = t.measure(q as usize, &mut rng) == 1; + 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; } - *map.entry(r.program.bitstring(|q| value[q as usize])).or_insert(0) += 1; + 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); } - merge(&mut out, counts(map)); } "pauli" => { use wai_quantum::quantum_spd::{Pauli, SpdConfig, expectation}; @@ -324,6 +345,9 @@ mod tests { 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"] })); @@ -363,6 +387,24 @@ mod tests { 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_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"] }); 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..8c3fd22e5 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,7 @@ quantum = [] quantum_toolchain = ["quantum"] quantum_mps = ["quantum"] quantum_stabilizer = ["quantum"] +quantum_tableau = [] 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 +200,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_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", diff --git a/wai/wai-quantum/src/lib.rs b/wai/wai-quantum/src/lib.rs index 1d56e98c3..16077b2b3 100644 --- a/wai/wai-quantum/src/lib.rs +++ b/wai/wai-quantum/src/lib.rs @@ -245,6 +245,8 @@ pub mod quantum_receipt; #[cfg(feature = "quantum_stabilizer")] pub mod quantum_stabilizer; +#[cfg(feature = "quantum_tableau")] +pub mod quantum_tableau; #[cfg(feature = "quantum_toolchain")] pub mod quantum_toolchain; 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-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", -- 2.54.0 (Apple Git-157) From 123c0579676c89f0562b9c5de68b2c1a81d19333 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 17 Oct 2026 10:36:24 -0400 Subject: [PATCH 06/12] wai-quantum 0.6.3: matrix product states at any angle; the service's mps method quantum_mpsim (new, in full): a chain in mixed-canonical form, the center moved by QR; each two-qubit gate a two-site update split by the crate's deterministic Jacobi SVD and truncated to max_bond and a cutoff; distant gates routed by swaps. Every truncation is reported (bonds, summed discarded weight, fidelity estimate). Amplitudes, Pauli expectations by transfer matrices, shots by sequential conditional sampling. Untruncated it agrees with the dense state vector to 1e-10; the estimate tracks the true fidelity under truncation. Brickwork depth 10: 100 qubits in 0.46 s, 500 in 2.4 s. The service lowers three-qubit gates to one- and two-qubit ones for it, adds mps (4096 qubits, bond up to 128 under a work estimate) to auto after the exact methods, and labels its results exact or heuristic with the fidelity estimate, warning below 0.9. Live: 300 qubits in 7 ms. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 19 +- wai/wai-quantum-service/Cargo.toml | 2 +- wai/wai-quantum-service/README.md | 6 +- wai/wai-quantum-service/src/catalog.rs | 54 +- wai/wai-quantum-service/src/qasm.rs | 53 ++ wai/wai-quantum-service/src/run.rs | 66 +++ wai/wai-quantum/Cargo.toml | 3 +- wai/wai-quantum/src/lib.rs | 2 + wai/wai-quantum/src/quantum_mpsim.rs | 763 +++++++++++++++++++++++++ 9 files changed, 955 insertions(+), 13 deletions(-) create mode 100644 wai/wai-quantum/src/quantum_mpsim.rs diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 5e2104945..2b567ff42 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,7 +6,7 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current -### wai-quantum 0.6.3: the wide stabilizer tableau +### wai-quantum 0.6.3: the wide stabilizer tableau, and matrix product states at any angle - **`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 @@ -17,6 +17,21 @@ wire format may change and such changes are recorded here. 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 two-qubit gate is a + two-site update split by the crate's deterministic Jacobi SVD and truncated to the bond + dimension and a cutoff. A gate on distant qubits is routed by swaps. + - 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 nearest-neighbour brickwork of + depth 10: 100 qubits in 0.46 s and 500 qubits in 2.4 s, each with a fidelity estimate of + 1.0000. At depth 20 the estimate reports what truncation cost: 0.04 at bond 64, 0.47 at + bond 128. - **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 @@ -47,6 +62,8 @@ wire format may change and such changes are recorded here. - `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 128: 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). diff --git a/wai/wai-quantum-service/Cargo.toml b/wai/wai-quantum-service/Cargo.toml index 67c73c1a2..0ffddfb82 100644 --- a/wai/wai-quantum-service/Cargo.toml +++ b/wai/wai-quantum-service/Cargo.toml @@ -15,7 +15,7 @@ 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_spd", "quantum_noisy"] } +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 index a9bf3b135..c9cb60532 100644 --- a/wai/wai-quantum-service/README.md +++ b/wai/wai-quantum-service/README.md @@ -24,7 +24,7 @@ A job is JSON: | `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` and `max_weight` (pauli), `fusion` (statevector), `trajectories`, `memory_gb` | +| `options` | per method: `threshold` and `max_weight` (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. @@ -36,11 +36,12 @@ Counts are keyed by the program's classical bits, the highest bit first. With no | `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; 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 | -`auto` picks the first method that takes the job. With noise, it tries `density_matrix` then `trajectories`. For counts, it tries `stabilizer`, `exact`, then `statevector`. For expectations alone, it tries `stabilizer`, `exact`, `statevector`, then `pauli`. For amplitudes alone, it tries `exact`, `statevector`, then `tensor_network`. The job's `why` names the method chosen and the ones that could not take it. +`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: @@ -48,6 +49,7 @@ The accuracy classes: - **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. diff --git a/wai/wai-quantum-service/src/catalog.rs b/wai/wai-quantum-service/src/catalog.rs index e735e5488..bab3944c2 100644 --- a/wai/wai-quantum-service/src/catalog.rs +++ b/wai/wai-quantum-service/src/catalog.rs @@ -11,14 +11,15 @@ //! | `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`; for expectations only, -//! `stabilizer`, `exact`, `statevector`, `pauli`; for amplitudes only, `exact`, -//! `statevector`, `tensor_network`. A method that cannot take the circuit is +//! 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}; @@ -36,6 +37,13 @@ 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 = 128; +/// The work an `mps` job may take, in the unit [`mps_work`] counts: about +/// ten minutes. +pub const MPS_MAX_WORK: u64 = 120_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; @@ -62,6 +70,7 @@ pub fn accuracy_classes() -> Value { "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", }) } @@ -73,6 +82,7 @@ pub fn describe(job_bytes: u64) -> Value { { "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" } }, + { "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 128)", "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 1000)" } }, @@ -206,6 +216,22 @@ fn memory_option(r: &Request, default_gib: u64, cap: u64) -> u64 { (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; @@ -268,6 +294,18 @@ fn try_method(method: &str, r: &Request, dyadic: &Result { + 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()); @@ -325,11 +363,11 @@ pub fn plan(v: &Value, job_bytes: u64) -> Result { let order: &[&str] = if r.noise.is_some() { &["density_matrix", "trajectories"] } else if r.shots > 0 { - &["stabilizer", "exact", "statevector"] + &["stabilizer", "exact", "statevector", "mps"] } else if !r.observables.is_empty() && r.amplitudes.is_empty() { - &["stabilizer", "exact", "statevector", "pauli"] + &["stabilizer", "exact", "statevector", "pauli", "mps"] } else { - &["exact", "statevector", "tensor_network"] + &["exact", "statevector", "tensor_network", "mps"] }; let mut refusals = Vec::new(); for m in order { @@ -372,8 +410,8 @@ mod tests { 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"); - let e = plan(&job(wide, json!({ "shots": 10 })), CAP).unwrap_err(); - assert!(e.contains("no method takes this job"), "{e}"); + // 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];"; diff --git a/wai/wai-quantum-service/src/qasm.rs b/wai/wai-quantum-service/src/qasm.rs index 1d77c668c..058ed05b6 100644 --- a/wai/wai-quantum-service/src/qasm.rs +++ b/wai/wai-quantum-service/src/qasm.rs @@ -1197,6 +1197,41 @@ impl Program { 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 { @@ -1635,6 +1670,24 @@ mod tests { 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}; diff --git a/wai/wai-quantum-service/src/run.rs b/wai/wai-quantum-service/src/run.rs index 462e08aed..c49ab15a7 100644 --- a/wai/wai-quantum-service/src/run.rs +++ b/wai/wai-quantum-service/src/run.rs @@ -201,6 +201,45 @@ pub fn run(request: &Value, plan: &Plan) -> Result { } 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(); @@ -405,6 +444,33 @@ mod tests { 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"] }); diff --git a/wai/wai-quantum/Cargo.toml b/wai/wai-quantum/Cargo.toml index 8c3fd22e5..5f5d4c942 100644 --- a/wai/wai-quantum/Cargo.toml +++ b/wai/wai-quantum/Cargo.toml @@ -77,6 +77,7 @@ 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. @@ -200,7 +201,7 @@ quantum_phasor_meter = ["_signing"] # Everything. full = [ - "quantum", "quantum_toolchain", "quantum_mps", "quantum_stabilizer", "quantum_tableau", "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", diff --git a/wai/wai-quantum/src/lib.rs b/wai/wai-quantum/src/lib.rs index 16077b2b3..fba1a49f6 100644 --- a/wai/wai-quantum/src/lib.rs +++ b/wai/wai-quantum/src/lib.rs @@ -247,6 +247,8 @@ pub mod quantum_receipt; 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/quantum_mpsim.rs b/wai/wai-quantum/src/quantum_mpsim.rs new file mode 100644 index 000000000..fcd907072 --- /dev/null +++ b/wai/wai-quantum/src/quantum_mpsim.rs @@ -0,0 +1,763 @@ +//! 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, split +//! again by SVD, and truncated to the bond dimension and to +//! [`MpsConfig::cutoff`]. With the center in the block, that truncation is the +//! best of its rank in the 2-norm, and the weight it drops (the discarded +//! squared singular values, over their total) is exactly how far the state +//! moved. 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 SVD is the crate's one-sided Jacobi, 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::jacobi_svd; + +/// 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; + } + } + } + } + let (u, s, v) = jacobi_svd(m, rows, cols); + let total: f64 = s.iter().map(|x| x * x).sum(); + // Keep the largest singular values, at most max_bond, dropping those + // whose share of the total is under the cutoff. + let mut order: Vec = (0..s.len()).collect(); + order.sort_by(|&x, &y| s[y].partial_cmp(&s[x]).unwrap().then(x.cmp(&y))); + let mut keep = Vec::new(); + for &k in &order { + if keep.len() >= self.cfg.max_bond { + break; + } + let share = if total > 0.0 { s[k] * s[k] / total } else { 0.0 }; + if share <= self.cfg.cutoff && !keep.is_empty() { + break; + } + if s[k] == 0.0 && !keep.is_empty() { + break; + } + keep.push(k); + } + let kept: f64 = keep.iter().map(|&k| s[k] * s[k]).sum(); + let eps = if total > 0.0 { (1.0 - kept / total).max(0.0) } else { 0.0 }; + self.updates += 1; + if eps > 0.0 && keep.len() < s.iter().filter(|&&x| x > 0.0).count() { + self.truncations += 1; + self.discarded += eps; + self.fidelity *= 1.0 - eps; + } + let chi = keep.len(); + 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 }; + let mut na = vec![C::ZERO; rows * chi]; + for (c, &k) in keep.iter().enumerate() { + for lt in 0..rows { + na[lt * chi + c] = u[k][lt]; + } + } + let mut nb = vec![C::ZERO; chi * cols]; + for (c, &k) in keep.iter().enumerate() { + for tr in 0..cols { + nb[c * cols + tr] = v[k][tr].conj().scale(s[k] * renorm); + } + } + 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() + ); + } + } +} -- 2.54.0 (Apple Git-157) From eb8df6cf590fb8c68eb9918ad1e5a1fcc7bbc76f Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 17 Oct 2026 11:21:27 -0400 Subject: [PATCH 07/12] wai-quantum: tiled multi-gate state-vector passes; vectorised wide-gate kernel Co-Authored-By: Claude Opus 5.5 --- wai/wai-quantum/Cargo.toml | 4 + wai/wai-quantum/examples/sv_bench.rs | 176 ++++++++++ wai/wai-quantum/src/quantum_sv.rs | 476 +++++++++++++++++++++++++-- 3 files changed, 633 insertions(+), 23 deletions(-) create mode 100644 wai/wai-quantum/examples/sv_bench.rs diff --git a/wai/wai-quantum/Cargo.toml b/wai/wai-quantum/Cargo.toml index 5f5d4c942..ebd3257ec 100644 --- a/wai/wai-quantum/Cargo.toml +++ b/wai/wai-quantum/Cargo.toml @@ -340,6 +340,10 @@ required-features = ["quantum_arch"] name = "sv_run" required-features = ["quantum_sv"] +[[example]] +name = "sv_bench" +required-features = ["quantum_sv"] + [[example]] name = "gbs_check" required-features = ["quantum_gbs"] 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/quantum_sv.rs b/wai/wai-quantum/src/quantum_sv.rs index a93b60913..f73fc9025 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 { @@ -140,24 +296,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 +420,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 +491,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 +510,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 +1144,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. -- 2.54.0 (Apple Git-157) From c5ebbeb622e0a58123571c8faef4554373ea25ae Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 17 Oct 2026 11:36:07 -0400 Subject: [PATCH 08/12] wai-quantum: branching noisy trajectories (shots that share a history share a state) Co-Authored-By: Claude Opus 5.5 --- wai/wai-quantum/Cargo.toml | 4 + wai/wai-quantum/examples/noisy_bench.rs | 61 +++ wai/wai-quantum/src/quantum_noisy.rs | 481 ++++++++++++++++++++++++ wai/wai-quantum/src/repro.rs | 22 +- 4 files changed, 557 insertions(+), 11 deletions(-) create mode 100644 wai/wai-quantum/examples/noisy_bench.rs diff --git a/wai/wai-quantum/Cargo.toml b/wai/wai-quantum/Cargo.toml index ebd3257ec..d0d02fff3 100644 --- a/wai/wai-quantum/Cargo.toml +++ b/wai/wai-quantum/Cargo.toml @@ -344,6 +344,10 @@ required-features = ["quantum_sv"] 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/src/quantum_noisy.rs b/wai/wai-quantum/src/quantum_noisy.rs index b25e6b870..200992752 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 //! @@ -310,6 +323,394 @@ 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 { + let next = std::sync::atomic::AtomicU64::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 t = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if t >= trees { + break; + } + *slots[t as usize].lock().unwrap() = Some(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 +1015,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/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) { -- 2.54.0 (Apple Git-157) From 4f945fc90d33c97144a792dc1127d9863fbdf331 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 17 Oct 2026 12:01:11 -0400 Subject: [PATCH 09/12] wai-quantum: the sparse-Pauli engine on a worker pool, bit-identical at any thread count Co-Authored-By: Claude Opus 5.5 --- wai/wai-quantum/src/lib.rs | 3 + wai/wai-quantum/src/pool.rs | 255 +++++++++++++++ wai/wai-quantum/src/quantum_spd.rs | 502 ++++++++++++++++++++++------- 3 files changed, 645 insertions(+), 115 deletions(-) create mode 100644 wai/wai-quantum/src/pool.rs diff --git a/wai/wai-quantum/src/lib.rs b/wai/wai-quantum/src/lib.rs index fba1a49f6..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. 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_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 -- 2.54.0 (Apple Git-157) From 9b8c70f6ef3c8de5f8cec8a4ea8f29f9a6fe1567 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 17 Oct 2026 12:07:42 -0400 Subject: [PATCH 10/12] wai-quantum: MPS two-site split by the Gram eigenvectors (Householder + QL), no division by singular values Co-Authored-By: Claude Opus 5.5 --- wai/wai-quantum/src/linalg.rs | 287 +++++++++++++++++++++++++++ wai/wai-quantum/src/quantum_mpsim.rs | 127 +++++++++--- 2 files changed, 381 insertions(+), 33 deletions(-) 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/quantum_mpsim.rs b/wai/wai-quantum/src/quantum_mpsim.rs index fcd907072..f95f4be2a 100644 --- a/wai/wai-quantum/src/quantum_mpsim.rs +++ b/wai/wai-quantum/src/quantum_mpsim.rs @@ -5,13 +5,18 @@ //! [`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, split -//! again by SVD, and truncated to the bond dimension and to -//! [`MpsConfig::cutoff`]. With the center in the block, that truncation is the -//! best of its rank in the 2-norm, and the weight it drops (the discarded -//! squared singular values, over their total) is exactly how far the state -//! moved. A gate on qubits further apart is applied by swapping them next to -//! each other and back. +//! 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 @@ -25,8 +30,9 @@ //! on the first qubit, each qubit's outcome given the earlier ones has //! probability `‖L·A[s]‖²`, `O(n χ²)` a shot. //! -//! Determinism: the SVD is the crate's one-sided Jacobi, and every operation -//! is IEEE `+ − × ÷ √`, so a run is the same bits on every machine. +//! 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. @@ -34,7 +40,16 @@ #![allow(clippy::needless_range_loop)] use crate::gates::{C, Gate}; -use crate::linalg::jacobi_svd; +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; @@ -342,48 +357,94 @@ impl Chain { } } } - let (u, s, v) = jacobi_svd(m, rows, cols); - let total: f64 = s.iter().map(|x| x * x).sum(); - // Keep the largest singular values, at most max_bond, dropping those - // whose share of the total is under the cutoff. - let mut order: Vec = (0..s.len()).collect(); - order.sort_by(|&x, &y| s[y].partial_cmp(&s[x]).unwrap().then(x.cmp(&y))); + // 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 share = if total > 0.0 { s[k] * s[k] / total } else { 0.0 }; - if share <= self.cfg.cutoff && !keep.is_empty() { - break; - } - if s[k] == 0.0 && !keep.is_empty() { + 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 kept: f64 = keep.iter().map(|&k| s[k] * s[k]).sum(); + 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 > 0.0 && keep.len() < s.iter().filter(|&&x| x > 0.0).count() { + if eps > ROUNDING_LOSS { self.truncations += 1; self.discarded += eps; self.fidelity *= 1.0 - eps; } - let chi = keep.len(); 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 }; - let mut na = vec![C::ZERO; rows * chi]; - for (c, &k) in keep.iter().enumerate() { - for lt in 0..rows { - na[lt * chi + c] = u[k][lt]; - } + for x in nb.iter_mut() { + *x = x.scale(renorm); } - let mut nb = vec![C::ZERO; chi * cols]; - for (c, &k) in keep.iter().enumerate() { - for tr in 0..cols { - nb[c * cols + tr] = v[k][tr].conj().scale(s[k] * 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; -- 2.54.0 (Apple Git-157) From 1da6614e09dd6df095fe0c8a9d58ca1e041f9802 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 17 Oct 2026 16:04:02 -0400 Subject: [PATCH 11/12] wai-quantum-service: branching trajectories, the Pauli ladder, bond 256; state-vector kernels on one thread inside parallel trajectories; 0.6.4 notes Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 67 ++++++++++++++++++++ wai/tools/quantum-meter/Cargo.lock | 2 +- wai/tools/quantum-wgsl-check/Cargo.lock | 2 +- wai/wai-pack/Cargo.lock | 2 +- wai/wai-quantum-qpu/Cargo.lock | 2 +- wai/wai-quantum-serve/Cargo.lock | 2 +- wai/wai-quantum-service/Cargo.lock | 2 +- wai/wai-quantum-service/README.md | 10 ++- wai/wai-quantum-service/smoke.sh | 22 +++++++ wai/wai-quantum-service/src/catalog.rs | 49 +++++++++++---- wai/wai-quantum-service/src/run.rs | 83 +++++++++++++++++++++---- wai/wai-quantum/Cargo.lock | 2 +- wai/wai-quantum/Cargo.toml | 2 +- wai/wai-quantum/src/quantum_noisy.rs | 4 +- wai/wai-quantum/src/quantum_sv.rs | 19 ++++++ wai/wai-rs/Cargo.lock | 2 +- wai/wai-web/Cargo.lock | 2 +- 17 files changed, 236 insertions(+), 38 deletions(-) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 2b567ff42..9f766e3af 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,73 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### wai-quantum 0.6.4: fewer passes, shared noisy histories, a threaded sparse-Pauli engine, and a faster split for matrix product states + +Every pinned digest is unchanged. The state vector and the sparse-Pauli engine give the same +bits as before. The matrix-product-state split is a different algorithm, so its results +move at rounding level. + +- **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 matrix-product-state split by Gram eigenvectors** (`quantum_mpsim`). A two-site + update 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 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 the eigensolver's rounding and are always + dropped. An update that loses under 10⁻¹³ of its weight is not counted as a truncation. + - **Measured** on the same machine against the Jacobi split, on nearest-neighbour + brickworks: depth 20 at bond 64 runs in 8.1 s against 35.9 s; 500 qubits at depth 10 in + 0.75 s against 3.1 s. The fidelity estimates are the same, 0.0426 at bond 64 and 0.4712 + at bond 128. +- **The service** (`wai-quantum-service`) uses all four: + - `statevector` runs tiled; + - `trajectories` runs every shot as a branching trajectory and reports `histories`; + - `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); + - `mps` takes bond dimensions up to 256. + ### wai-quantum 0.6.3: the wide stabilizer tableau, and matrix product states at any angle - **`quantum_tableau`** (new feature, in `full`): Clifford circuits on up to 32768 qubits. diff --git a/wai/tools/quantum-meter/Cargo.lock b/wai/tools/quantum-meter/Cargo.lock index 002097d96..aed470f60 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.3" +version = "0.6.4" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/tools/quantum-wgsl-check/Cargo.lock b/wai/tools/quantum-wgsl-check/Cargo.lock index 5d234c140..0398234a3 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.3" +version = "0.6.4" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-pack/Cargo.lock b/wai/wai-pack/Cargo.lock index 0e815b544..686cab658 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.3" +version = "0.6.4" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-qpu/Cargo.lock b/wai/wai-quantum-qpu/Cargo.lock index 2b01b5768..f7017f72d 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.3" +version = "0.6.4" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-serve/Cargo.lock b/wai/wai-quantum-serve/Cargo.lock index a4e96f869..dbefe1eff 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.3" +version = "0.6.4" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-service/Cargo.lock b/wai/wai-quantum-service/Cargo.lock index 8dc8c3c70..e246b055d 100644 --- a/wai/wai-quantum-service/Cargo.lock +++ b/wai/wai-quantum-service/Cargo.lock @@ -815,7 +815,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.3" +version = "0.6.4" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-service/README.md b/wai/wai-quantum-service/README.md index c9cb60532..22b81717d 100644 --- a/wai/wai-quantum-service/README.md +++ b/wai/wai-quantum-service/README.md @@ -24,7 +24,7 @@ A job is JSON: | `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` and `max_weight` (pauli), `max_bond` and `cutoff` (mps), `fusion` (statevector), `trajectories`, `memory_gb` | +| `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. @@ -36,11 +36,17 @@ Counts are keyed by the program's classical bits, the highest bit first. With no | `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; best when entanglement stays low | counts, expectations, amplitudes | exact when nothing is truncated, otherwise heuristic | +| `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: diff --git a/wai/wai-quantum-service/smoke.sh b/wai/wai-quantum-service/smoke.sh index 4869c9ef7..db3734590 100644 --- a/wai/wai-quantum-service/smoke.sh +++ b/wai/wai-quantum-service/smoke.sh @@ -74,6 +74,28 @@ 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") diff --git a/wai/wai-quantum-service/src/catalog.rs b/wai/wai-quantum-service/src/catalog.rs index bab3944c2..69d481221 100644 --- a/wai/wai-quantum-service/src/catalog.rs +++ b/wai/wai-quantum-service/src/catalog.rs @@ -40,10 +40,10 @@ 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 = 128; +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 = 120_000_000_000; +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; @@ -81,18 +81,23 @@ pub fn describe(job_bytes: u64) -> Value { { "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" } }, - { "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 128)", "cutoff": "singular values whose squared share is below it are dropped (default 1e-12)" } }, + { "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 1000)" } }, + { "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 + 256 * MIB + amps + amps / 32 } /// The most qubits the dense state vector holds in `job_bytes`. @@ -331,25 +336,43 @@ fn try_method(method: &str, r: &Request, dyadic: &Result 64 { return Err("trajectory counts are for at most 64 qubits".into()); } - let threads = threads_for(n, cap); - let bytes = statevector_bytes(n).saturating_mul(threads as u64); 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); - Ok(plan(bytes, (gates << n) * count, format!("{count} noisy trajectories of {n} qubits on {threads} thread(s)"))) + 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")), } } -/// Threads for trajectories: as many state vectors as fit, at most 8. -pub fn threads_for(n: u32, cap: u64) -> usize { - ((cap / statevector_bytes(n).max(1)) as usize).clamp(1, 8) +/// 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 { - r.options.get("trajectories").and_then(Value::as_u64).unwrap_or(1000).clamp(1, 100_000) + 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. diff --git a/wai/wai-quantum-service/src/run.rs b/wai/wai-quantum-service/src/run.rs index c49ab15a7..d6a59de41 100644 --- a/wai/wai-quantum-service/src/run.rs +++ b/wai/wai-quantum-service/src/run.rs @@ -191,13 +191,27 @@ pub fn run(request: &Value, plan: &Plan) -> Result { 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); - let cfg = SpdConfig { threshold, max_weight }; + // 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 res = expectation(&rc, &[(*coeff, obs)], &[], &cfg).map_err(|e| e.to_string())?; - entries.push(json!({ "observable": text, "coeff": coeff, "value": res.value, "bound": res.truncation_bound, "peak_terms": res.peak_terms })); + 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); } @@ -278,13 +292,21 @@ pub fn run(request: &Value, plan: &Plan) -> Result { } } "trajectories" => { - use wai_quantum::quantum_noisy::trajectories; + use wai_quantum::quantum_noisy::branching_trajectories; let noise = r.noise.ok_or("no noise model")?; - let count = catalog::trajectory_count(&r) as usize; - let threads = catalog::threads_for(n, plan.memory_bytes.max(catalog::statevector_bytes(n))); + 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 = trajectories(&r.program.matrices(), n, &noise, &observables, count, r.seed, threads).map_err(|e| format!("{e:?}"))?; - out["accuracy"] = json!({ "class": "estimate", "trajectories": count, "note": "expectations are means over trajectories with their standard errors; counts are one shot per trajectory" }); + 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 @@ -296,11 +318,11 @@ pub fn run(request: &Value, plan: &Plan) -> Result { } if r.shots > 0 { let mut map: HashMap = HashMap::new(); - for &x in &run.samples { - *map.entry(r.program.bitstring(|q| x >> (n - 1 - q) & 1 == 1)).or_insert(0) += 1; + 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!(run.samples.len()); + out["shots"] = json!(count); } } other => return Err(format!("unknown method {other}")), @@ -412,6 +434,45 @@ mod tests { 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];"; diff --git a/wai/wai-quantum/Cargo.lock b/wai/wai-quantum/Cargo.lock index 541174291..662dd7c4b 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.3" +version = "0.6.4" dependencies = [ "blake3", "block2", diff --git a/wai/wai-quantum/Cargo.toml b/wai/wai-quantum/Cargo.toml index d0d02fff3..a7e52951c 100644 --- a/wai/wai-quantum/Cargo.toml +++ b/wai/wai-quantum/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wai-quantum" -version = "0.6.3" +version = "0.6.4" edition = "2024" rust-version = "1.97" license = "Apache-2.0" diff --git a/wai/wai-quantum/src/quantum_noisy.rs b/wai/wai-quantum/src/quantum_noisy.rs index 200992752..cbb990cc9 100644 --- a/wai/wai-quantum/src/quantum_noisy.rs +++ b/wai/wai-quantum/src/quantum_noisy.rs @@ -305,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))); }); } }); @@ -683,7 +683,7 @@ pub fn branching_trajectories(gates: &[Gate], n: u32, noise: &NoiseModel, observ if t >= trees { break; } - *slots[t as usize].lock().unwrap() = Some(tree.run(part(t), seed_of(t))); + *slots[t as usize].lock().unwrap() = Some(crate::quantum_sv::on_one_thread(|| tree.run(part(t), seed_of(t)))); }); } }); diff --git a/wai/wai-quantum/src/quantum_sv.rs b/wai/wai-quantum/src/quantum_sv.rs index f73fc9025..495bafa2d 100644 --- a/wai/wai-quantum/src/quantum_sv.rs +++ b/wai/wai-quantum/src/quantum_sv.rs @@ -281,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()) diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock index 06f163452..47f996cd2 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.3" +version = "0.6.4" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-web/Cargo.lock b/wai/wai-web/Cargo.lock index c462a4e7d..2f02cf70e 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.3" +version = "0.6.4" dependencies = [ "blake3", "ed25519-dalek", -- 2.54.0 (Apple Git-157) From 7a1fc8d2c076ffab54bb43e4b831c82756e8b5df Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 18 Oct 2026 00:39:32 -0400 Subject: [PATCH 12/12] wai-quantum: a word-sized counter for branching trajectories' trees (no 64-bit atomics on some targets) The emulated signal-processor leg (a 32-bit target) failed to build: AtomicU64 does not exist there. Checked: cargo check for a 32-bit target without 64-bit atomics passes with the fix and fails without it. Co-Authored-By: Claude Opus 5.5 --- wai/wai-quantum/src/quantum_noisy.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/wai/wai-quantum/src/quantum_noisy.rs b/wai/wai-quantum/src/quantum_noisy.rs index cbb990cc9..91211cb0e 100644 --- a/wai/wai-quantum/src/quantum_noisy.rs +++ b/wai/wai-quantum/src/quantum_noisy.rs @@ -674,16 +674,18 @@ pub fn branching_trajectories(gates: &[Gate], n: u32, noise: &NoiseModel, observ let tallies: Vec> = if threads == 1 { (0..trees).map(|t| tree.run(part(t), seed_of(t))).collect() } else { - let next = std::sync::atomic::AtomicU64::new(0); + // 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 t = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if t >= trees { + let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if i as u64 >= trees { break; } - *slots[t as usize].lock().unwrap() = Some(crate::quantum_sv::on_one_thread(|| tree.run(part(t), seed_of(t)))); + let t = i as u64; + *slots[i].lock().unwrap() = Some(crate::quantum_sv::on_one_thread(|| tree.run(part(t), seed_of(t)))); }); } }); -- 2.54.0 (Apple Git-157)