Configuration¶
This section describes OpenQARP’s configuration system and type definitions, and states what OpenQARP does under an MPI launcher.
Global configuration: Centralized settings for seeds, parallelization, and circuit cutting
Type safety: Clear type definitions for improved code quality
MPI: detection only — see MPI below
Configuration System¶
OpenQARP provides a global configuration object that controls various settings throughout the package.
The Config Object¶
The configuration is accessed via the config object imported from the main package:
from qarp import config
This object is a singleton instance of the internal __ConfigObject__ class and provides the
following configurable properties:
Random Seed¶
Control random number generation for reproducibility:
# Set a seed for reproducible results
config.seed = 42
# Clear the seed
config.seed = None
# Get current seed
current_seed = config.seed
Setting the seed affects:
Python’s built-in
randommoduleNumPy’s random number generator
SciPy (which uses NumPy’s RNG internally)
NetworkX random functions (which also use NumPy)
Maximum Number of Cuts¶
For circuit cutting operations, limit the maximum number of cuts:
# Set maximum cuts (1-6, default is 6)
config.max_number_of_cuts = 4
# Get current value
max_cuts = config.max_number_of_cuts
Warning
Setting this value too high can generate an exponential number of circuits (6^n), significantly increasing computational cost.
Implementation Details¶
The configuration object is defined in qarp/_config.py and provides:
Property-based getters and setters with validation
Automatic propagation of settings to dependent libraries
Runtime validation of configuration values
Type Definitions¶
OpenQARP defines type aliases for improved code clarity and type checking. These are defined
in qarp/_types.py.
SamplingDictionary¶
A read-only type alias for sampling results. Both a SamplingDistribution
(what Sampler returns) and a plain dict satisfy it:
from qarp import SamplingDictionary
# SamplingDictionary is defined as: Mapping[tuple[int, ...], float]
# Example usage
results: SamplingDictionary = {
(0, 0, 0): 0.25,
(0, 0, 1): 0.25,
(1, 1, 0): 0.25,
(1, 1, 1): 0.25,
}
The keys are LSB-first bit tuples representing measurement outcomes, and values are probabilities. Annotate results you only read with it; build a new dict to change one.
Threads¶
Two parallel layers share one knob. The C++ simulator runs a circuit’s shots as one OpenMP team when the circuit forces per-shot trajectories (mid-circuit measurement, reset, feed-forward, or a noise model); a single-statevector evaluation instead lets the csim kernels parallelise each gate across the amplitudes. The two never nest: inside a shot worker the kernel regions serialise, so a worker never spawns a kernel team of its own (the contention that did cost 1.3 ms per shot on 0.02 ms of work).
QARP_NUM_THREADS sizes both layers and the compilation thread pool.
It is read once, at import qarpx:
QARP_NUM_THREADS=4 python my_script.py
Unset, OMP_NUM_THREADS is used. With neither set, every layer uses the
physical cores the process may run on (its CPU affinity, so taskset and
scheduler CPU binding are honoured), capped one below its logical CPUs: a
spinning OpenMP worker on each hardware thread starves the process. A
container CPU limit (docker run --cpus, a Kubernetes limits.cpu, any
cgroup v1 or v2 CPU quota) counts as the logical CPUs when it is fewer,
rounded up to whole CPUs. OpenMP binding (OMP_PROC_BIND, OMP_PLACES)
pins the main thread to one place; the CPUs counted are then those of all
the places, so binding does not shrink the count. Where the topology is unreadable (outside Linux)
the count is the logical CPUs minus one. The physical cores are those the
kernel reports: under WSL and some virtual machines the virtual topology
pairs CPUs that are separate cores on the hardware and so undercounts them;
there, set QARP_NUM_THREADS to the real core count. The kernel layer
keeps its own finer overrides (QULACS_NUM_THREADS,
QULACS_PARALLEL_NQUBIT_THRESHOLD, see Installation); a
QULACS_NUM_THREADS you set yourself wins over the forwarded value.
Results are seeded per shot, independent of how the shots are partitioned,
so a seeded run is bit-identical at every thread count.
The Pauli expectation kernel (expectation / transition /
batch_expectation, what StateVector contracts with) is one OpenMP
region per call, capped by both QARP_NUM_THREADS and
OMP_NUM_THREADS. Its floating-point reduction order follows the team
size, so its results agree across thread counts to rounding (about 1e-14
relative), not bit for bit.
The simulator releases the GIL while it computes (run, batch_run,
statevector, unitary_matrix, the expectation kernel and the gradient kernels), so Python
threads can drive it concurrently. Below the kernel layer’s parallel
threshold (16 qubits, see Gate fusion) two threads overlap almost fully;
above it each call already occupies every core, so threading buys little
there.
numpy and scipy wheels each bundle their own OpenBLAS, with its own thread
pool sized to every logical CPU. After a multi-threaded BLAS call (a
complex np.linalg.norm, a matrix product, a solve) its workers keep
spinning for 100–200 ms, and a simulator call started in that window
competes with them for the cores: a 16-qubit statevector right after a
norm took 108 ms instead of 5 ms. The penalty is oversubscription, so by
default qarp lowers each bundled copy’s thread count to its own
(QARP_NUM_THREADS) at the first simulation, and numpy and scipy keep
that count for the rest of the process. BLAS stays multi-threaded. A
count set in OPENBLAS_NUM_THREADS or GOTO_NUM_THREADS stays, and so
does a lower limit set in code before the first simulation
(threadpoolctl); one set inside a with block that the first
simulation runs in is restored by threadpoolctl to OpenBLAS’s own count
when the block ends.
QARP_BLAS_THREADS selects what that first simulation does:
QARP_BLAS_THREADS=limit python my_script.py # the default: the count alone
QARP_BLAS_THREADS=pool python my_script.py # the count, plus qarpx's pool
QARP_BLAS_THREADS=native python my_script.py # OpenBLAS untouched
Lowering the count stops helping where qarpx’s threads and as many BLAS
threads no longer fit the logical CPUs: a budget close to the logical count
(no hyper-threading, or QARP_NUM_THREADS raised). pool covers that
case too. It hands each OpenBLAS copy a threading callback that runs its
parallel jobs on a small pool of qarpx’s own, whose idle workers sleep the
moment a call ends. The pool’s workers run on every CPU the process may
use, even when the thread that made the first call is pinned to one, and a
child created with fork starts a fresh pool, so numpy work before a
fork never affects the child’s simulations. It costs: BLAS calls from
several Python threads run one at a time, because OpenBLAS’s per-job
scratch buffers are shared between calls, and factorisation loops run
1.4–1.7× slower than on OpenBLAS’s own pool at the lowered count. Any
other value warns and counts as unset.
The mode is applied at the first simulation: the first QarpEngine
built, or the first Block.statevector or Block.unitary_matrix. A
process that imports qarp but never simulates keeps OpenBLAS exactly as it
was. scipy’s copy is covered at the first simulation after scipy is
imported; qarp never loads scipy itself. Other BLAS libraries (MKL,
Accelerate, a conda OpenBLAS) are left alone.
Gate fusion¶
Every kernel call is a pass over all \(2^n\) amplitudes, so the cost of
a statevector run is passes × state size, not gates × state size. Before
dispatching, QarpSimulator fuses runs of gates into dense blocks of up to
fusion_max_qubits qubits and applies each block in one pass — the
optimisation behind qiskit-aer’s fusion_enable. It is exact (global
phase included) and applies to statevector, run and batch_run;
unitary_matrix, the adjoint gradient and noisy trajectories run gate by
gate.
import qarpx as qx
sim = qx.QarpSimulator()
sim.fusion_max_qubits # 3: the built-in default
sim.fusion_max_qubits = 1 # single-qubit fusion only
sim.fusion_max_qubits = 0 # raw per-gate dispatch, for diagnostics
sim.fusion_min_qubits # 12: narrower registers keep the single-qubit pass
QARP_FUSION_MAX_QUBITS sets the process-wide default for every simulator
an engine constructs — read once, at import qarpx; values that do not
parse or exceed QarpSimulator.MAX_FUSION_QUBITS are ignored:
QARP_FUSION_MAX_QUBITS=1 python my_script.py # compare against unfused timings
Wider is not better: a \(k\)-qubit block costs \(2^k\) multiplies
per amplitude, so the default is the width measured fastest on this
kernel — at 20 qubits, single thread, brickwork 207 → 40 ms, QFT 462 → 190
ms, a Trotter step 230 → 94 ms (macOS arm64; see the statevector
benchmark table) — and the pass only widens a block past two
qubits through a shared qubit. Below fusion_min_qubits the fold costs
more than the passes it saves, so small registers keep the single-qubit
pass.
The same import also exports QULACS_PARALLEL_NQUBIT_THRESHOLD=16 to
the kernel layer unless you set it: csim’s own per-kernel default of 13
forks an OpenMP team where the fork costs more than the gate, which is
where a 13-qubit circuit used to run 11× its 12-qubit twin.
Structured execution¶
Fusion folds gates it can see; it cannot see that a thousand gates only
permute basis states. Block.statevector and QarpEngine’s sampling
paths therefore read the block tree first and run each piece as the
cheapest exact kernel: a basis-state permutation as one gather (declared by
classical_action, or found from the gates), a small block as one dense
matrix, a ladder of the same controlled block as one controlled-powers
kernel, and everything else as its fused gates. A circuit with no such
structure runs exactly as before. See §14 of the conventions for the full
contract.
from qarp.blocks import XnBlock
from qarp.engines import QarpEngine
block = XnBlock(14).build()
block.statevector(structured=False) # the gate stream as-is
QarpEngine(structured=False) # same, for every primitive it builds
QARP_STRUCTURED sets the process-wide default, read once; 0,
false, off and no turn it off. Engines with a device,
amplitude-consuming primitives and the circuits a parameter-shift gradient
rewrites always run the gate path.
A block can also declare how it is composed with structure(): its parts
in order, where a part is a block or Repeat(block, count). A primitive
hands its circuits to the engine unbuilt, and the engine plans a
declaration before building, so QPE and DOSQPE, whose blocks declare
their controlled-U ladder, never build the ladder on a QarpEngine
without a device: their block stays unbuilt and the run goes through one
controlled-powers kernel whatever the ancilla count. The same holds for any
primitive over such a block, PauliAveraging included.
QarpEngine(structured=False) builds the full circuit instead.
To see what runs, block.kernels() lists the kernels statevector
runs on a built block, and engine.kernels(primitive) lists them for each
circuit of a primitive after engine.build, with ['gates'] for a
circuit that runs its gate stream:
import numpy as np
from qarp.algorithms import Sampler
from qarp.blocks import ComputationalBasisStateBlock, QPEBlock, SimpleBlock
from qarp.engines import QarpEngine
u = SimpleBlock(1)
u.p(0, 2 * np.pi * 0.375)
sampler = Sampler(QPEBlock(ComputationalBasisStateBlock([1]), u, 11, 1))
engine = QarpEngine()
engine.build([sampler])
engine.kernels(sampler) # [['gates', 'controlled_powers', 'gates']]
MPI¶
OpenQARP does not implement MPI parallelism. Earlier versions ran every rank through the same serial optimisation in lockstep, which cost a full cluster allocation for a single-process result; that machinery is gone.
What remains is detection. qarp.MPIConfig reads the launcher’s
environment variables (OpenMPI, MPICH/PMI/SLURM, Intel MPI, MVAPICH2) without
importing mpi4py — so no MPI_Init_thread ever runs inside OpenQARP — and
every composite algorithm refuses to construct under a multi-rank launcher:
from qarp import MPIConfig
MPIConfig.is_mpi_env() # True under mpirun / srun
MPIConfig.world_size() # ranks reported by the environment (1 when unknown)
MPIConfig.is_disabled() # QARP_DISABLE_MPI=1 silences detection
$ mpirun -np 4 python my_vqe.py
CapabilityError: VQE: MPI parallelism is not implemented — 4 ranks detected.
Run OpenQARP in a single process, or set QARP_DISABLE_MPI=1 on the one rank that drives it.
Set QARP_DISABLE_MPI=1 when one rank of a larger job drives OpenQARP serially
on purpose. Parallelism inside a single process (threads, the C++ batch
runners) is unaffected; see Engines.