PRISM-Q is an open-source quantum circuit simulator written in Rust, with Python bindings. It reads OpenQASM 3.0 (with backward-compatible 2.0 syntax), selects a simulation backend from the circuit's structure, runs a multi-pass gate fusion pipeline, and executes on SIMD kernels: AVX2, FMA and BMI2 on x86-64, NEON on ARM64. Two optional features extend the dense path: CUDA acceleration for the statevector, stabilizer (experimental) and density-matrix backends, and an MPI-distributed statevector for registers larger than one host's memory.
The simulation methods are a dense statevector, a stabilizer tableau, a sparse amplitude map, matrix product states (MPS), a product state, a tensor network, an exact density matrix, and two factored variants that split independent registers. Beside the backends sit Clifford+T engines (stabilizer rank and Pauli propagation) and a native quantum error-correction (QEC) program path with detector sampling and decoding. Circuits export back to OpenQASM 3.0.
██████╗ ██████╗ ██╗███████╗███╗ ███╗ ██████╗
██╔══██╗██╔══██╗██║██╔════╝████╗ ████║ ██╔═══██╗
██████╔╝██████╔╝██║███████╗██╔████╔██║█████╗██║ ██║
██╔═══╝ ██╔══██╗██║╚════██║██║╚██╔╝██║╚════╝██║▄▄ ██║
██║ ██║ ██║██║███████║██║ ╚═╝ ██║ ╚██████╔╝
╚═╝ ╚═╝ ╚═╝╚═╝╚══════╝╚═╝ ╚═╝ ╚══▀▀═╝
- Documentation: https://abecoull.github.io/prism-q/ (machine-readable index at
llms.txt) - API reference: docs.rs
- Measured timings: Benchmarks, with the method in Benchmark Methodology
- Reproducible measurements beside Qiskit Aer, qsim, QuEST, Spinoza and RustQIP: Comparative Measurements
- Design: Architecture reference
- What each backend supports on CPU and GPU: capability matrix
- Changes by release:
CHANGELOG.md
cargo add prism-q # Rayon parallelism and faer SVD (default)
cargo add prism-q --no-default-features # single-threaded, minimal dependencies
pip install prism-q # Python
pip install "prism-q[cuda12]" # Python, plus NVRTC for the CUDA backendsThe gpu feature builds without a CUDA toolkit and loads the NVIDIA driver and NVRTC at
run time; running it needs a CUDA device, a CUDA 12 driver, and NVRTC from a CUDA 12
toolkit or the cuda12 Python extra. Build with
cargo build --release --features "parallel gpu". Building from source and pinning a
git revision are covered in CONTRIBUTING.md.
use prism_q::run_qasm;
let qasm = r#"
OPENQASM 3.0;
include "stdgates.inc";
qubit[2] q;
bit[2] c;
h q[0];
cx q[0], q[1];
c[0] = measure q[0];
c[1] = measure q[1];
"#;
let result = run_qasm(qasm, 42).unwrap();
println!("{:?}", result.probabilities);
// Bell state: ~50% |00⟩, ~50% |11⟩The Python bindings in bindings/python expose simulation, noise,
QEC and the CUDA backends, with NumPy output:
import prism_q
circuit = prism_q.CircuitBuilder(2).h(0).cx(0, 1).build()
print(prism_q.simulate(circuit).seed(42).run().probabilities) # [0.5, 0, 0, 0.5]Count keys and measurement bits are LSB-first (q[0] is the least significant qubit),
the reverse of Qiskit. See the
Python guide.
use prism_q::{bitstring, circuit::openqasm, simulate};
let circuit = openqasm::parse(qasm).unwrap();
let result = simulate(&circuit).seed(42).shots(1024).unwrap();
println!("{result}");
// 00: 512
// 11: 512
let counts = simulate(&circuit)
.seed(42)
.sample_counts(1024)
.unwrap();
for (bits, count) in counts.into_counts() {
println!("{}: {count}", bitstring(&bits, circuit.num_classical_bits));
}use prism_q::{simulate, CircuitBuilder, PauliTerm};
let bell = CircuitBuilder::new(2).h(0).cx(0, 1).build();
let observables = [
vec![PauliTerm::z(0), PauliTerm::z(1)],
vec![PauliTerm::x(0), PauliTerm::x(1)],
];
let values = simulate(&bell).seed(42).expectation_values(&observables).unwrap();
// [1.0, 1.0]: ⟨ZZ⟩ and ⟨XX⟩ on the Bell state.
let marginals = simulate(&bell).seed(42).marginals().unwrap();
// Per-qubit (P(0), P(1)) pairs: [(0.5, 0.5), (0.5, 0.5)].An observable is a product of single-qubit Paulis with identity factors omitted.
Clifford circuits propagate it exactly, and past the statevector memory budget the
selected backend answers from its own representation. To start somewhere other than
|0...0⟩, pass initial_state a normalized amplitude vector of length 2^n, qubit 0 in
the least significant bit.
Mark angles as parameters while building, then rebind without rebuilding.
PreparedCircuit reuses one fusion plan across bindings and falls back to a full
fusion pass when a binding changes what fusion would emit.
use prism_q::{simulate, CircuitBuilder, PauliTerm, PreparedCircuit};
let (template, params) = CircuitBuilder::new(2)
.ry(0.0, 0)
.param(0)
.cx(0, 1)
.rz(0.0, 1)
.param(1)
.build_parametric();
let bound = params.bind(&template, &[0.3, 1.1]).unwrap();
let result = simulate(&bound).seed(42).run().unwrap();
let mut prepared = PreparedCircuit::new(template, params.clone()).unwrap();
let fused = prepared.bind_fused(&[0.4, 1.2]).unwrap();
let hamiltonian = vec![(1.0, vec![PauliTerm::z(0)])];
let g = simulate(&bound)
.seed(42)
.expectation_gradient(&hamiltonian, ¶ms)
.unwrap();
println!("<H> = {}, gradient = {:?}", g.value, g.gradient);expectation_gradient uses the adjoint method on the statevector backend.
expectation_gradient_shift uses the parameter-shift rule and covers the backends and
circuit shapes the adjoint declines.
The Tutorials walk through
sampling, variational gradients, noise, a QEC memory experiment, dynamic circuits and
wide structured circuits, in Python with the Rust equivalents. Runnable scripts sit in
bindings/python/examples and examples:
python bindings/python/examples/qec_memory.py
cargo run --release --example qec_memory| Backend | Best for | Scaling | Key property |
|---|---|---|---|
| Statevector | General circuits | O(2ⁿ) | SIMD, tiled L2/L3 kernels, optional CUDA path |
| Stabilizer | Clifford only | O(n²) | SIMD, thousands of qubits |
| Factored Stabilizer | Clifford with independent blocks | O(n²) per cluster | Per-cluster tableaux, dynamic merge and split |
| Sparse | Few live amplitudes | O(k) | HashMap with parallel measurement |
| MPS | Low entanglement or 1D | O(nχ²) | Hybrid faer / Jacobi SVD |
| Product State | No entanglement | O(n) | Per qubit |
| Tensor Network | Low treewidth | Depends on contraction order | Greedy min size heuristic |
| Factored | Partial entanglement | Dynamic | Tracks independent sub-states |
| Density Matrix | Exact noisy evolution | O(4ⁿ) | Explicit dispatch only, reuses statevector kernels |
| Distributed Statevector | Beyond single-host memory | O(2ⁿ) over MPI ranks | distributed feature, exact results |
BackendKind::Auto is the default: PRISM-Q selects a backend from the circuit's
structure and the host's memory, and the run metadata reports which one ran. The
choice is a shape rule, not a cost model, so an explicit backend can still win for a
circuit the rules misjudge. Circuits with no entangling gates go to Product
State. All-Clifford circuits go to Stabilizer, or to Factored Stabilizer when a large
circuit splits into independent blocks. Circuits past the statevector memory budget go
to Sparse when the state never holds more than 128 basis states and to MPS with bond
dimension 256 otherwise; partially independent circuits go to Factored, and the rest run
on Statevector. Before that tree, a Clifford+T circuit with few T gates can take the
stabilizer-rank sampler for shots and Pauli propagation for marginals.
The budget is half the machine's physical memory, read once and cached.
Limits overrides it or any single cap, per request with .limits(...) or for the
process with set_default_limits, and the PRISM_MAX_* variables set the same caps
from the environment. Density Matrix and Distributed Statevector are never
selected automatically; name them.
To choose a backend yourself, call .backend(BackendKind::Stabilizer) (or
BackendKind::Mps { max_bond_dim: 64 }, BackendKind::Sparse, and so on) on the
simulate builder. Choosing a backend
and the backends deep dive
cover when each one wins.
The parser accepts the stdgates.inc set, common controlled and multi-controlled
variants, Qiskit exporter gates, IonQ and Google/Cirq native gate names, decomposed
multi-instruction gates, IBM legacy u1/u2/u3, and user-defined gate declarations.
The inv @, ctrl @ and pow(k) @ modifiers chain on direct gates.
qasm_export::to_qasm3 writes a Circuit back out as OpenQASM 3.0 that re-parses to
the same instruction stream with inline angles exact;
fused payloads have no OpenQASM spelling and are rejected with an error naming the
instruction.
The OpenQASM guide lists the
accepted subset. The parser itself, src/circuit/openqasm.rs
(resolve_gate() and resolve_decomposed_gate()), is the authoritative gate list.
The gpu feature compiles PTX at runtime through NVRTC for the device's compute
capability. Opt in through the simulation builder:
use prism_q::{gpu::GpuContext, simulate};
let ctx = GpuContext::new(0)?;
let result = simulate(&circuit).gpu(ctx).seed(42).run()?;The circuit still goes through fusion and subsystem decomposition, and a size crossover
keeps small sub-circuits on the CPU. simulate(&circuit).gpu_auto(ctx) runs automatic
dispatch with the device opted in, BackendKind::StabilizerGpu runs Clifford circuits
on the device, and CompiledSampler::with_gpu(ctx) moves large compiled BTS shot
counts onto it. The GPU guide
covers the kernels, the crossover thresholds and their environment overrides.
The distributed feature shards one exact statevector across 2^p ranks: the top p
qubits select the rank and the rest index a local slice that runs on the same SIMD
kernels and fusion pipeline as the single-host backend. distributed-mpi adds the MPI
transport over rsmpi and needs a system MPI installation.
use prism_q::{distributed::DistributedContext, simulate};
let context = DistributedContext::world()?; // MPI_Init at MPI_THREAD_FUNNELED
let result = simulate(&circuit).distributed(context).seed(42).run()?;Results do not depend on the rank count. The distributed guide covers the layout, the qubit relabeling that keeps busy qubits local, the launch scripts, and the limits (power-of-two ranks, no noise models, CPU only).
cargo bench --bench circuits --features parallel # circuit macrobenchmarks
cargo bench --bench bench_driver --features parallel # gate microbenchmarks
cargo bench --bench bench_gpu --features "parallel gpu" # GPU dispatch benchmarksBaselines were taken with parallel enabled, so keep it on. Run one cargo bench at a
time: concurrent Rayon pools contend for cores and skew results. RAYON_NUM_THREADS
caps the thread count. The published timings, the circuit definitions, the warmup and
timing rules, and how to run a like-for-like comparison against another simulator are in
Benchmark Methodology.
The A/B and regression workflow used for PRs is in CONTRIBUTING.md
and benches/README.md.
| Task | Page |
|---|---|
| Install the crate or the wheel | Installation |
| Work through a task end to end | Tutorials |
| Let the dispatcher pick a backend, or override it | Choosing a Backend |
| Compare the backends' memory and scaling | Backends Deep Dive |
| Use the simulator from Python | Python Bindings |
| Check which OpenQASM constructs parse | OpenQASM Support |
| Tune threads, tiles and memory caps | Performance and SIMD |
| Run on a CUDA device | GPU Backend |
| Run across MPI ranks | Distributed Statevector |
| Simulate Clifford+T circuits past the dense limit | Clifford+T Simulation |
| Add noise, detectors and decoding | Noise and QEC |
| Reproduce or extend the benchmarks | Benchmark Methodology |
| See how it measures beside other simulators | Comparative Measurements, harness in comparison/ |
| Read how the layers fit together | Architecture |
- Multi-GPU and distributed GPU execution. A GPU context binds one device and the distributed backend is CPU only; sharding one statevector across devices also needs peer access, since a host-staged exchange costs far more than the gate it serves.
- ROCm ports of the CUDA statevector and stabilizer kernels.
- Noisy shots on the distributed backend. It rejects noise models today because
trajectories run as Rayon tasks whose order differs per rank, so their collectives
would not line up. The planned route runs shots in lockstep: every rank takes the shots
in the same order and seeds each shot's noise stream from the run seed and the shot
index, as the host trajectory loop already does, so all ranks draw the same Pauli and
Kraus branches without exchanging them. Pauli and depolarizing events read nothing
from the state. A damping or thermal-relaxation event weighs its branches by the
qubit's excited population, one
Allreduceof rank-local sums per event, and a custom Kraus channel also needs the qubit's coherence, so a qubit sitting in a rank bit is relabeled local before that reduction. Two-qubit Kraus channels stay out, since the backend has no two-qubit reduced density matrix, and shots run one at a time across all ranks rather than in parallel on each.
CONTRIBUTING.md has the build, test, coverage, profiling and
benchmark workflow. The architecture reference covers
the layered design, backend trait, SIMD strategy, fusion pipeline and compiled samplers.
CITATION.cff carries the citation metadata; GitHub renders it under
"Cite this repository". Name the release version used, since timings and dispatch
rules change between releases.
Dual-licensed under MIT or Apache-2.0, at your option.