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 random module

  • NumPy’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.