Engines¶
The engines module is the execution backend of OpenQARP. Every quantum computation flows through an Engine instance. If a Device is given to the engine, it owns the rebase / route / re-rebase compilation pipeline (plus per-gate noise injection); otherwise the engine simulates the circuit as written.
OpenQARP currently provides two qarpx-native engines. The default, always available:
QarpEngine— fast statevector / trajectory simulation backed byqx.QarpSimulator.
The second, CudaqEngine, wraps qx.CudaqSimulator for GPU
execution (single/multi-GPU statevector or tensor-network backends). It needs both the
cuda-quantum-cu12 Python runtime and a qarpx build compiled with CUDA-Q support
(QARP_WITH_CUDAQ=ON — the published CPU wheel is not built this way); the
Installation page has the build steps. A
CPU-only build raises a clear, actionable error from CudaqEngine — use QarpEngine
for CPU simulation.
To implement a custom engine, subclass Engine.
QarpEngine¶
QarpEngine is the default qarpx-native engine. It runs
on the C++ QarpSimulator and supports both the noise-free statevector fast
path and the per-shot trajectory path (the latter is triggered automatically
when a circuit contains mid-circuit measurement, reset, classical control, or
an active NoiseModel).
In the first example we build and run a QarpEngine with a single
StateVector primitive computing the expectation value
of a Fermi–Hubbard Hamiltonian:
from qarp.utils import FH_ham_and_wf
from qarp.algorithms import StateVector
from qarp.engines import QarpEngine
n = 2
ham, block = FH_ham_and_wf(n, 1.4, 2.31)
# we set the inputs such that we compute an expectation value
sv = StateVector(bra=block, operator=ham, ket=block)
my_engine = QarpEngine()
my_engine.build([sv])
params = block.parameter_map([0.1] * len(block.symbols))
result = my_engine.run(params)
print("Result:", result[0].real)
In the second example we build the engine with two primitives, mixing an exact
StateVector with a shot-based TermwiseHadamardTest:
from qarp.utils import FH_ham_and_wf
from qarp.algorithms import StateVector, TermwiseHadamardTest
from qarp.engines import QarpEngine
n = 2
ham1, block1 = FH_ham_and_wf(n, 1.4, 2.31)
ham2, block2 = FH_ham_and_wf(n, 1.6, 2.2)
sv = StateVector(bra=block1, operator=ham1, ket=block1)
ht = TermwiseHadamardTest(bra=block2, operator=ham2, ket=block2)
my_engine = QarpEngine()
my_engine.build([sv, ht])
params1 = block1.parameter_map([0.1] * len(block1.symbols))
params2 = block2.parameter_map([0.1] * len(block2.symbols))
combined_params = {**params1, **params2}
result = my_engine.run(combined_params)
print("Result 1:", result[0].real)
print("Result 2:", result[1].real)
To make the engine respect device constraints (rebase, route, noise), pass a
Device to the engine constructor — not to the
individual primitives. Below we add an amplitude-damping noise channel on
all 1-qubit gates and compare the noisy TermwiseHadamardTest against
an exact noise-free StateVector:
from qarp.utils import FH_ham_and_wf
from qarp.algorithms import StateVector, TermwiseHadamardTest
from qarp.engines import QarpEngine
from qarp.devices import Device, NoiseModel
n = 2
noisy_param = 0.001
ham1, block1 = FH_ham_and_wf(n, 1.4, 2.31)
# Build a noisy device. ``.inner`` unwraps the Python NoiseModel wrapper
# to the underlying qarpx-native qx.NoiseModel that Device expects.
# +1 qubit: TermwiseHadamardTest's compiled circuit needs an ancilla
# beyond block1's own register.
noise_model = NoiseModel.amplitude_damping(noisy_param, gate_set="1q")
noisy_device = Device(n_qubits=block1.n_qubits + 1, noise_model=noise_model.inner)
sv = StateVector(bra=block1, operator=ham1, ket=block1)
ht = TermwiseHadamardTest(bra=block1, operator=ham1, ket=block1, n_shots=1000)
# Run StateVector on a noise-free engine; TermwiseHadamardTest on a noisy one.
clean_engine = QarpEngine()
clean_engine.build([sv])
noisy_engine = QarpEngine(device=noisy_device)
noisy_engine.build([ht])
params = block1.parameter_map([0.1] * len(block1.symbols))
clean_result = clean_engine.run(params)
noisy_result = noisy_engine.run(params)
print("Result noise-free:", clean_result[0].real)
print("Result noisy: ", noisy_result[0].real)
Exact evaluation and capability checks¶
Every shot-based primitive (Sampler, HadamardTest, PauliAveraging, …)
also has an exact, infinite-shot limit: pass n_shots=qarp.EXACT and the engine
computes the Born distribution directly instead of drawing samples, feeding it
through the same estimator the shot-based path uses. This needs simulator
amplitudes, so it shares its capability requirements with consumes=AMPLITUDES
primitives like StateVector — both fail the same way under noise.
import qarp
from qarp.utils import FH_ham_and_wf
from qarp.algorithms import TermwiseHadamardTest, StateVector
from qarp.engines import QarpEngine
n = 2
ham1, block1 = FH_ham_and_wf(n, 1.4, 2.31)
# n_shots=qarp.EXACT: same primitive, no shot noise.
ht_exact = TermwiseHadamardTest(bra=block1, operator=ham1, ket=block1, n_shots=qarp.EXACT)
sv = StateVector(bra=block1, operator=ham1, ket=block1)
engine = QarpEngine()
engine.build([ht_exact, sv])
params = block1.parameter_map([0.1] * len(block1.symbols))
result = engine.run(params)
print("HadamardTest (n_shots=qarp.EXACT):", result[0].real)
print("StateVector (reference): ", result[1].real)
Engine.build() validates every primitive’s declared capabilities against the
engine and device before compiling anything, so an incompatible combination
raises CapabilityError immediately rather than failing deep
inside the simulator. Reusing the noisy device from the example above, asking a
noisy QarpEngine for exact amplitudes raises:
noisy_engine = QarpEngine(device=noisy_device)
noisy_engine.build([ht_exact])
# CapabilityError: TermwiseHadamardTest requires exact amplitudes
# (consumes=AMPLITUDES or n_shots=qarp.EXACT), which QarpEngine cannot
# provide: amplitudes are undefined under noise (each shot is one
# trajectory of a mixed state). Use a sampling primitive with finite
# shots, or disable the noise model (engine.noise_model.enabled = False).
CapabilityError also covers a primitive built with a target outside its
supported_targets, and a primitive that declares supports_exact=False
run with n_shots=qarp.EXACT. All three checks are cheap: they run once at
build(), not per shot.
The same gate guards every algorithm-level statevector fast path (the ADAPT pool
scans, QSE, QITE, MonteCarlo’s exact reference distribution, QMEGS’s classical
overlaps). Those paths read amplitudes through the engine’s own simulator, never
through a fresh one, so a noisy or routed engine refuses with CapabilityError
naming the remedy, disabling the noise model reopens the path on the same engine,
and an engine without a host statevector API is refused rather than idealised.
CudaqEngine caps any device→host statevector transfer at 30 qubits
(_STATEVECTOR_HOST_QUBIT_CAP); the cap applies to these fast paths exactly as
it does to an OVERLAP or EXACT primitive, while EXPECTATION_VALUE stays
on-device and is uncapped.
Every simulator backend (QarpSimulator, CudaqSimulator)
returns results through the same SamplingResult contract regardless of
whether the run was exact or shot-based, so result[i] is handled identically
either way — the primitive’s own estimator is what changed, not the code that
reads the result.
Circuit cutting¶
Circuit cutting is exposed as the CuttingPrimitive
primitive algorithm and is engine-agnostic. See the cutting documentation for details.
Gradients¶
Every engine differentiates its built primitives through
run_gradient(params, method=...): the adjoint (QarpEngine only), a
batched per-occurrence parameter shift, finite differences and SPSA, all
returning one array per primitive in the insertion order of params.
CudaqEngine never falls back to a CPU simulator for a gradient. The
registry, costs and refusals are documented in Gradients.