Benchmarks
Comparisons against other propagation engines and internal regression benchmarks.
This page reports two sets of benchmarks: comparisons with other open-source Pauli and Majorana propagation engines, and an internal pytest suite for detecting performance regressions.
Results

Wall-clock time per propagation step on a logarithmic scale. At the final Pauli step, the
runtime ratios relative to monoprop are 267-325× for the other CPU implementations and 7.8×
for the GPU implementation. Over the complete Majorana evolution, the runtime ratio for
MajoranaPropagation.jl 0.4.1 is 27.7×.
All results were obtained on Leonardo (CINECA); the experimental conditions are specified under Methods.
Pauli propagation
The Pauli benchmark simulates the Trotterized time evolution of a two-dimensional transverse-field
Ising model and evaluates a ZZ correlator on a fixed pair of qubits. One experiment varies the
circuit depth at fixed lattice size; the other varies the lattice size at fixed depth.
Fixed lattice, increasing depth
This experiment uses a 12×12 lattice (144 qubits), 28 Trotter points, dt = 0.05, a coefficient
threshold of 1e-6, and no weight cutoff. The final operator contains approximately 33 million
terms.
| Engine | Final step | VS monoprop | Peak RSS | VS monoprop |
|---|---|---|---|---|
monoprop | 1.56 s | — | 4275 MB | — |
cuPauliProp (GPU) | 12.23 s | 7.8× | 11652 MB (device) | 2.7× |
QuEra ppvm | 417.29 s | 267× | 6956 MB | 1.6× |
PauliPropagation.jl | 433.16 s | 277× | 11102 MB | 2.6× |
Qiskit pauli-prop | 507.74 s | 325× | 34270 MB | 8.0× |
The observables produced by the coefficient-threshold implementations differ by at most 3.8e-4.
Qiskit pauli-prop, which truncates by term count instead, differs by 2.8e-3. The cuPauliProp
operator resides in GPU memory; consequently, the table reports its peak GPU allocation rather
than the host RSS (which we measured at 552 MB).

Runtime and peak RSS per Trotter step. Peak RSS is measured by the same procedure for every CPU
implementation; cuPauliProp reports peak GPU allocation.
Increasing lattice size
This experiment holds the circuit depth and truncation threshold fixed while increasing the lattice from 6×6 (36 qubits) to 18×18 (324 qubits). Each model size and implementation runs in a separate subprocess with a 300 s limit.

At the selected coefficient threshold, the operator size saturates at approximately 55.0
million terms above 100 qubits. Beyond this point, the experiment primarily measures the cost
per retained term. Across the full range, monoprop runtime increases from 6.35 s to 14.1 s
(2.2×), while the qubit count increases 9×.
The 300 s limit excludes QuEra ppvm and Qiskit pauli-prop at all lattice sizes, and excludes
PauliPropagation.jl above 10×10. We remark that this is not an implementation limit for the packages but a consequence of the timeout limit.

Working memory denotes peak host RSS for CPU implementations and peak device allocation for
cuPauliProp. Across the full range, monoprop increases from 5045 MB to 7835 MB (1.55×),
while cuPauliProp increases from 7.6 GB to 34.4 GB. At 324 qubits, the latter is 4.5× the
monoprop host footprint and occupies more than half of the A100's 64 GB device memory.

Relative to monoprop, the runtime ratio increases from 14× to 31× for
PauliPropagation.jl over its completed points, and from 4.2× to 20× for cuPauliProp over
the full range. A missing bar denotes a run that exceeded the 300 s limit.
Majorana propagation
The Majorana benchmark simulates 20 Trotter layers of a one-dimensional Fermi-Hubbard model with 60 spinful sites. Runtime, expectation value, term count, and memory are recorded after each layer. Both implementations use 24 threads.

The expectation values differ by at most 2.0e-6 at every layer. At equal truncation,
monoprop retains 109.4 million terms, compared with 79.4 million for
MajoranaPropagation.jl. The complete evolution takes 57.1 s and 1583.7 s, respectively, a
runtime ratio of 27.7×. At the final layer, the corresponding times are 22.9 s and 639.4 s
(27.9×). Peak RSS is 8.8 GB and 17.5 GB, respectively.

The linear scale preserves the absolute runtime ratio. The annotation gives the upper bound of
the monoprop curve, which otherwise lies close to the horizontal axis.
Caveats
- CPU parallelism differs among implementations.
QuEra ppvmand Qiskit'spauli-propuse one core during propagation (0.99 measured active cores), whereasmonopropandPauliPropagation.jluse multiple cores. TheppvmPython bindings expose the serialindexmapvariant rather than the Rayon-parallelconfig::dashmapvariant; Qiskit's_accelerateextension has no parallel propagation path. Runtime ratios are therefore wall-clock ratios, not per-core ratios. - The Julia benchmarks use the fastest documented containers. These are
VectorPauliSumwithPerformance.propagate!(development branch 0.8.0 or later) andVectorMajoranaSumwith AcceleratedKernels. Their dictionary-backed alternatives are serial. The vector Pauli path does not merge duplicate strings, so its reported term count is a storage count rather than the number of distinct operator terms. - The CPU and GPU measurements use different node types. Each scaling curve is obtained on a
single node. The
cuPauliPropruntime comparison nevertheless includes both hardware and implementation effects.
Methods
The benchmark drivers are maintained in
packages/bench-third-party,
a standalone uv project with its own lockfile. It is excluded from the repository workspace
because it requires CUDA-specific wheels and a narrower Python version range than monoprop.
Hardware
The measurements use exclusive nodes on Leonardo (CINECA). CPU and GPU measurements use different partitions because Leonardo's DCGP nodes do not contain GPUs.
| Partition | Hardware | Engines |
|---|---|---|
| DCGP (CPU) | 2× Intel Xeon Platinum 8480+, 112 cores, ~480 GB RAM | monoprop 0.8.1.dev61+gcb9a033aa (56 threads), PauliPropagation.jl 0.8.2 on Julia 1.12.6 (28 threads), QuEra ppvm 0.1.0, Qiskit pauli-prop 0.2.0 |
| Booster (GPU) | NVIDIA A100-SXM-64GB, 8 host cores | cuPauliProp via cuquantum-python-cu12 26.6.0 |
The benchmarks use each engine's best known configuration. monoprop uses all 56 cores of one
socket; PauliPropagation.jl uses 28 threads because higher thread counts reduce its throughput.
Each result record includes host, threads, and library_version; the plotting scripts report
these fields alongside derived metrics.
How memory is measured
CPU memory denotes peak resident set size (RSS), obtained from the Linux kernel's VmHWM
counter. RSS is the physical memory resident for one process; pages shared with other processes
are counted in full. For cuPauliProp, whose operator resides on the GPU, working memory
instead denotes peak device allocation.
- The measurement is exact. The kernel updates
VmHWMwhenever RSS increases, including during native code that does not hold the Python GIL. - The measurement interval is reset for each step. Before resetting
VmHWM, the harness runsgc.collect()andmalloc_trimfor Python, orGC.gc()andmalloc_trimfor Julia. The resulting value excludes peaks retained from earlier steps. - All CPU implementations use the same measurement procedure over the timed region.
RSS includes the interpreter and loaded libraries. Comparisons of growth across steps therefore carry less fixed overhead than comparisons of absolute values.
Implementation-specific estimates are also recorded as native_memory: operator_memory_bytes()
for monoprop, Base.summarysize for PauliPropagation.jl, and device-pool usage for
cuPauliProp. These estimates cover different allocations and are therefore suitable only for
comparison within a single implementation.
Reproducing
The Python implementations share one uv environment:
cd packages/bench-third-party
uv sync # monoprop (editable), qiskit, ppvm, pauli-prop, cuquantum-python-cu12Julia is required only for the Julia comparisons:
curl -fsSL https://install.julialang.org | sh # juliaup + latest stable Julia
julia -e 'using Pkg; Pkg.add(["JSON", "ProgressMeter"])'The benchmark and plotting commands are:
# Pauli, fixed lattice; edit settings.json to change the model
cd pauli_prop
uv run python run_model.py # CPU engines -> results.json
julia run_model.jl # optional; merges PauliPropagation.jl in
uv run python run_model.py --backends cupauliprop -o results_gpu.json # on a GPU node
uv run python plot_results.py
# Pauli, lattice sweep; 300 s limit per (size, engine)
uv run python run_scaling.py --output scaling_cpu.jsonl --timeout 300 \
--backends monoprop juliapp ppvm qiskit --threads monoprop=56 juliapp=28
uv run python run_scaling.py --output scaling_gpu.jsonl --timeout 300 \
--backends cupauliprop # on a GPU node
# The first input takes precedence when a backend occurs in both files
uv run python plot_scaling.py scaling_cpu.jsonl scaling_gpu.jsonl
uv run python plot_scaling.py scaling_cpu.jsonl scaling_gpu.jsonl \
--memory-key working_set_MB
uv run python plot_speedup.py scaling_cpu.jsonl scaling_gpu.jsonl
# Majorana
cd ../majorana_prop
monoprop_NUM_THREADS=24 JULIA_NUM_THREADS=24 bash run_benchmarks.sh
# Two-panel headline figure from both committed result sets
cd .. && uv run python plot_headline.pyCPU and GPU scaling results are stored separately and combined during plotting. If an
implementation occurs in more than one input, the first input takes precedence; placing the CPU
file first ensures that the monoprop curve contains only CPU-node measurements.
plot_scaling.py warns when a curve contains multiple hosts and does not label an axis as host RSS
unless all included records use that metric.
All Pauli model parameters are defined in
settings.json
and are shared by all implementations. Output schemas, field definitions, and implementation-specific
limitations are documented in
packages/bench-third-party/README.md.
Internal benchmarks
The internal pytest benchmark suite measures the runtime and peak RSS of core monoprop
operations. It is separate from the functional test suite and runs with just bench.
Running
Each run requires a label, which identifies its column in results/REPORT.md. This permits
direct comparison of serial, threaded, and MPI configurations. Additional arguments are forwarded
to pytest.
uv sync --group bench # once
just bench serial # serial run, column "serial"
just bench-smoke # quick sanity check (tiny sizes)
just bench serial --num-modes 64 --bench-rounds 10
monoprop_NUM_THREADS=10 just bench serial-t10 # cap the partition count
uv run --group bench monoprop-bench-report benches/results # rebuild report, no re-runThe suite contains two benchmark groups:
bench_random.pymeasuresbuild_graph,pare,energy,gradient, andinplaceon a random problem. Configurable with--gen-length(4),--obs-terms(10000),--num-generators(100),--num-modes(128),--cutoff(6),--seed(0) and--bench-rounds(1); defaults in parentheses.bench_models.pymeasures two fixed models markedslow: a 120-qubit Fermi-Hubbard 29-step Trotter run (test_model[hubbard]) and a 127-qubit kicked-Ising run over 20 layers (test_model[pauli]). Override any config field with--<model>-<field>, e.g.--pauli-num-layers 30;just bench --helplists them all.
Each run writes pytest-benchmark timings to results/time-<label>.json and the remaining metrics
to <label>.json. monoprop-bench-report combines all labels in results/REPORT.md.
MPI
For MPI benchmarks, barriers delimit each timed operation; runtime is therefore the makespan across ranks. Memory is the sum of the per-rank peak RSS values. This is an upper bound on the job-wide peak: shared pages are counted once per rank, and rank-local peaks are summed even when they occur at different times.
just bench-build-mpi # build once (MPI on)
monoprop_NUM_THREADS=2 just bench-mpi r5t2 5 --map-by slot:PE=2 --bind-to coreContinuous benchmarking
The bench_main.yml workflow benchmarks every commit on main. Bencher
records the time series and reports threshold violations. The workflow invokes the same recipes
available locally:
just bench-ci ci-linux # default sizes, more rounds, no slow models
just bench-bmf ci-linux # the same results as Bencher Metric Format JSONmonoprop-bench-bmf combines both artifacts because each Bencher upload accepts one adapter. The
standard python_pytest adapter includes timings but omits memory and operator metrics. Four
measures are recorded:
| Measure | Unit | Source | Threshold |
|---|---|---|---|
latency | nanoseconds | pytest-benchmark mean ± 1σ | Student's t-test, upper 0.99 |
peak-memory | bytes | per-operation peak RSS (VmHWM) | +10% |
resting-memory | bytes | settled operator + graph footprint | +10% |
terms | count | terms in the evolved operator | any change |
For a fixed seed and problem size, terms is deterministic; any change therefore indicates an
algorithmic change. Timing alerts from shared runners are recorded but do not fail the build.
Testbeds
Bencher identifies each series by (branch, testbed, measure). A testbed therefore denotes one
fixed machine configuration; changing that configuration would introduce a discontinuity in every
series.
| Workflow | Runner | Testbed | Label | Profile |
|---|---|---|---|---|
bench_main.yml | GitHub-hosted ubuntu-26.04 | ubuntu-26.04 | ci-linux | not slow, 5 rounds |
bench_bare_metal.yml | RunsOn EC2 node | aws-c7i-4xlarge | ci-bare-metal | everything, 3 rounds |
The bare-metal workflow provisions a dedicated node for each run. Its lower measurement variance supports tighter timing thresholds and permits execution of the fixed models omitted on the shared runner.
The bench-markers and bench-rounds inputs of the reusable bench.yml workflow select the
benchmarks and repetition count. These values should remain fixed after establishing a testbed,
because changing them changes the meaning of its time series:
monoprop_BENCH_MARKERS= monoprop_BENCH_ROUNDS=3 just bench-ci ci-bare-metal