Backends and conventions¶
One circuit, eight backends, one answer. tests/test_cross_backend.py runs the same
circuit zoo through every installed backend and asserts agreement with the NumPy
reference on statevectors, probabilities, expectations over X/Y/Z and two-body terms,
seeded sampling, and parameter-shift gradients.
| simulator | when | |
|---|---|---|
numpy |
the built-in reference | the default. Exact, vectorised, fastest up to ~13 qubits |
aer |
AerSimulator(method="statevector") |
above ~13 qubits — C++, and it does not degrade the way a Python gate loop does |
qiskit |
quantum_info.Statevector |
Qiskit's reference. Exact, no extra install, and the slowest of the three |
cirq |
cirq.Simulator |
Cirq's reference |
spinqit |
SpinQit's simulator | the diploma's own SDK; Python 3.10 only |
torch |
a differentiable simulator | backprop only |
cirq-density · qiskit-aer |
density matrices | noise, when you ask for it by name |
Which one, in practice¶
The NumPy reference wins at the widths quantum machine learning actually runs at, because it carries the batch as a leading axis and contracts gate by gate rather than sample by sample. That advantage is a Python-level one, and it stops paying once the statevector gets big:
| qubits | numpy |
qiskit |
aer |
|---|---|---|---|
| 10 | 0.46s | 0.98s | 0.59s |
| 12 | 0.92s | 1.60s | 0.97s |
| 14 | 1.52s | 2.16s | 1.27s |
| 16 | 9.87s | 13.03s | 0.60s |
| 18 | 25.91s | 35.99s | 0.66s |
(a Gram matrix on a ZZFeatureMap; single machine, exact throughout.)
The crossover is around 13 qubits and it is abrupt, because it is the same one
that turns off NumPy's batching — NumpyBackend.batch_max_qubits is 10, and above it
the reference falls back to a loop while Aer stays in C++. If you are working wider
than a dozen qubits, backend="aer" is the answer; below it, the default already is.
aer is deliberately a separate name rather than a silent upgrade to qiskit, so the
simulator that produced a number stays visible in the code that produced it.
Every SDK import is lazy, so import qmlkit requires none of them and a missing one
produces an install command rather than an ImportError. Set the default with
QMLKIT_BACKEND or qk.set_default_backend(...), or pass backend= per call.
Qubit ordering¶
qmlkit is big-endian: qubit 0 is the most significant bit. A count key '011'
means qubit 0 measured |0⟩, qubit 1 |1⟩, qubit 2 |1⟩. This matches SpinQit and
PennyLane.
Qiskit is little-endian. Rather than reversing statevectors after the fact, the
Qiskit backend maps qmlkit qubit i to Qiskit qubit n−1−i at build time, so
the index conventions coincide and no reversal is needed anywhere downstream.
Three upstream discrepancies¶
Building the cross-backend suite turned up three real differences. All are handled; all are worth knowing about if you go looking at the native circuits.
| Finding | Handling |
|---|---|
SpinQit's CY applies −iY, not Y, to the control-1 subspace |
Emitted as Sd·CX·S instead. This is a relative phase between control branches, so it is physically observable — a control qubit in superposition gives different measurement statistics. SpinQit's single-qubit Y is correct; only the controlled form is affected |
| Cirq silently drops qubits a circuit never touches | An explicit qubit_order is always passed, so an idle qubit still occupies its place in the statevector |
| Qiskit is little-endian | Index remapping at build time, as above |
verify_conventions() re-checks bit order and gate definitions against a live
install in one call — worth running after an SDK upgrade.
Precision¶
SpinQit's simulator carries a floor near 1e-10 rather than machine precision: a
single-qubit Ry(0.7) expectation lands about 5.6e-11 from the analytic cos(0.7).
Cross-backend comparisons use a per-backend tolerance so this is not mistaken for a
translation error, and it is worth knowing before anyone reports a "gradient
mismatch" that is really accumulated simulator noise.
Native circuits¶
Each backend exposes its own object, so you can hand a circuit to that SDK's transpiler or drawing tools:
# docs: requires qiskit
import qmlkit as qk
qc = qk.QCircuit(2)
qc.h(0).cx(0, 1)
print(type(qk.get_backend("qiskit").to_qiskit(qc.to_spec())).__name__)
to_qiskit, to_cirq and to_spinqit are the three.
Reading circuits in¶
The translations run both ways. to_qiskit/to_cirq/to_spinqit hand a circuit to
another SDK; from_qasm/from_qiskit/from_pennylane/from_cirq bring one back.
import qmlkit as qk
spec = qk.from_qasm("""
OPENQASM 2.0;
include "qelib1.inc";
qreg q[2];
h q[0];
cx q[0],q[1];
""")
print(spec.n_qubits, len(spec.ops))
from_qasm uses the standard library alone, so it works in a bare pip install
qmlkit. Every major SDK exports OpenQASM 2.0, which makes it the widest import path
the library has.
from_qiskit exists next to it for the one thing QASM cannot represent — an unbound
Parameter, which becomes a ParamRef indexed in Qiskit's own parameter order:
# docs: requires qiskit
from qiskit import QuantumCircuit
from qiskit.circuit import Parameter
qc = QuantumCircuit(2)
qc.ry(Parameter("theta"), 0)
spec = qk.from_qiskit(qc)
print(spec.n_params)
from_cirq is the interesting one, because Cirq has no gate names to look up.
cirq.S, cirq.T and cirq.rz are all a ZPowGate; what separates them is the
exponent and the global_shift. So the importer classifies rather than reads a table,
and it reads global_shift rather than ignoring it — cirq.X and cirq.rx(pi) differ
by a global phase, which is unobservable alone and relative inside a controlled block.
# docs: requires cirq
import cirq
import sympy
t = sympy.Symbol("t")
qubits = cirq.LineQubit.range(2)
spec = qk.from_cirq(cirq.Circuit([cirq.rx(2 * t).on(qubits[0]), cirq.CZ(*qubits)]))
print(spec.n_params, spec.ops[0].params[0])
cirq.rx(2 * t) carries the exponent 2*t/pi; multiplied back by pi that is 2*t,
and ParamRef models exactly scale * theta + offset, so it survives as
ParamRef(0, scale=2.0). Symbols are indexed by sorted name, matching
sorted(cirq.parameter_names(circuit)). Anything nonlinear is refused.
One asymmetry to know about: Cirq has no declared register, so a qubit that no operation touches is not in the circuit at all. The same logical circuit imports two qubits wide from Qiskit and one from Cirq.
# docs: requires cirq
print(qk.from_cirq(cirq.Circuit([cirq.X(cirq.LineQubit(0))])).n_qubits) # 1
print(qk.from_cirq(cirq.Circuit([cirq.X(cirq.LineQubit(0)),
cirq.I(cirq.LineQubit(1))])).n_qubits) # 2
The convention that matters¶
Importing is where qubit order goes wrong quietly. Qiskit and QASM are little-endian,
so their qubit j becomes qmlkit's n-1-j — the exact inverse of what to_qiskit
does on the way out. PennyLane and Cirq are big-endian like qmlkit, so their wires
pass through.
Neither claim is taken on trust. tests/test_import.py asserts the statevector
after from_qiskit(to_qiskit(spec)) over randomly generated circuits at 1e-12, and
checks the PennyLane and Cirq importers against those libraries' own simulators. It
also asserts that every gate to_cirq emits comes back through from_cirq, since a
gate qmlkit can write but not read is a one-way door. A circuit whose gates
are symmetric across the register cannot tell a correct mapping from a reversed one,
so the test zoo is deliberately asymmetric.
What is refused¶
A gate with no qmlkit definition raises UnsupportedGate naming it, rather than being
dropped or approximated. So do measure and reset, which the 0.x line does not
model. The single exception is the u/u3 family: it is decomposed into rz·ry·rz
and warns that an overall phase was dropped — unobservable for the circuit alone, and
a relative phase if that circuit is later used inside a controlled block.
SpinQit needs its own environment¶
SpinQit ships wheels for Python 3.8–3.10 only and pins numpy<2, so the extra is
gated behind an environment marker and resolves to nothing on 3.11+. Use a dedicated
3.10 environment:
A practical consequence: nothing in the library may use a NumPy-2-only API
(np.trapezoid, np.in1d, …), because the test suite has to pass under numpy<2
as well. CI runs both.
The torch backend¶
TorchBackend is a differentiable statevector simulator, and it is what makes
method="backprop" possible. It is the least physical backend here — deliberately —
and exists because a circuit inside an autograd graph is genuinely useful, not
because it could ever run anywhere but a simulator.