Circuit Packing and Calibration-Aware Layouts

qkan.solver ships a provider-neutral circuit-packing toolkit: calibration-aware selection of qubits, connected subgraphs, and disjoint tiles (qkan.solver.layout), plus a packer that turns k independent copies of a small circuit into one pinned, result-mappable job. Packing serves two repeated-execution patterns. The first is the variational batch: one parameterized circuit evaluated at k parameter sets per job, so a training loop advances k evaluations per round trip instead of one. The second is shot reduction for a single circuit: only one circuit needs to run, but packing it k times collects statistics in parallel, so each job needs only shots/k shots — 8 copies at 256 shots match 2048 shots of a single placement at one eighth of the QPU time, at the cost of tying the estimate to k tiles’ average noise instead of one tile’s. pack_circuit() is the single entry point, overloaded over both supported stacks with the same selection logic: a qiskit QuantumCircuit packs against a backend, a plain @cudaq.kernel packs against a DeviceProfile, and both return one PackedCircuit type whose placement accessors and rebind are shared. Everything is torch-free and usable independently of QKAN models.

Device profiles

Calibration enters through DeviceProfile, a frozen snapshot of per-qubit and per-edge calibration.

from qkan import DeviceProfile

profile = DeviceProfile.from_qiskit(backend)            # BackendV2 Target or legacy properties()
profile = DeviceProfile.from_braket(aws_device)         # Braket standardized schema (experimental)
profile = DeviceProfile(num_qubits=..., edges=..., ...) # or supply your own data

NaN calibration entries are treated as missing data, unreported qubit labels are marked non-operational, and dead coupling edges (2-qubit error at the physical bound) are excluded from all searches. Profiles are snapshots: pass refresh=True (qiskit) or rebuild the profile before long runs to pick up recalibration.

Selection API

  • rank_qubits(profile, n, ...): top-n independent qubit ranking with threshold (max_readout_error, qubit_error_threshold) and strict semantics.

  • best_subgraph(profile, interaction, ...): best-calibrated connected placement for one multi-qubit circuit, where interaction is its 2-qubit-gate edge list. qiskit’s transpiler already does this well at optimization_level 2-3; use this for explicit thresholds, level-1 pipelines, custom objectives, or non-qiskit stacks.

  • tile_disjoint(profile, interaction, k, ...): tile the chip with k (or "max") disjoint calibration-aware copies of one circuit — for running independent circuits in parallel on one QPU, which no transpiler currently offers.

  • score_layout(profile, interaction, layout): the fidelity-product cost used by both searches, usable for custom comparisons.

Layouts are positional (layout[i] hosts logical qubit i) and tiles are returned best-first.

Selection is stack-neutral, and CUDA-Q kernels reach it through the same pack_circuit() entry point: k=1 reduces packing to single-circuit placement (best_subgraph semantics).

import cudaq
from qkan import pack_circuit

@cudaq.kernel
def ghz20():
    q = cudaq.qvector(20)
    h(q[0])
    for i in range(19):
        x.ctrl(q[i], q[i + 1])

placed = pack_circuit(profile, ghz20, k=1)   # one copy on the best-calibrated path
result = cudaq.sample(placed.kernel, shots_count=1000)
pairs = ["I" * i + "ZZ" + "I" * (18 - i) for i in range(19)]
zz = [placed.expectation(result, pair, 0) for pair in pairs]

The QKAN CUDA-Q solver applies the same selection to its own packed single-qubit jobs via initial_layout="auto" (see the solver guide).

Packing with qiskit

pack_circuit() is the packing API: it derives the circuit’s interaction graph, selects disjoint tiles from live calibration, composes the copies into one circuit, and transpiles it pinned to the tiles (SWAP-free by construction; a routed result, e.g. from a calibration profile that disagrees with the backend, raises RuntimeError). Packing pays off when the same circuit runs many times, in one of two ways.

The first is the variational batch: pack a parameterized circuit once, and every training step evaluates k parameter sets in one hardware job — each copy carries its own renamed parameters, bound per step with parameter_values.

from qkan import pack_circuit
from qiskit import QuantumCircuit
from qiskit.circuit import Parameter
from qiskit_ibm_runtime import EstimatorV2

theta = Parameter("theta")
vqc = QuantumCircuit(2)
vqc.ry(theta, 0)
vqc.cx(0, 1)

packed = pack_circuit(backend, vqc, k=8, max_readout_error=0.05)
obs = packed.observable("ZI")                # any Pauli/SparsePauliOp, mapped per tile
estimator = EstimatorV2(mode=backend)

for step in range(steps):
    batch = optimizer.ask(8)                 # 8 parameter sets, one per tile
    values = packed.parameter_values([[t] for t in batch])
    evs = estimator.run([(packed.isa, obs, values)]).result()[0].data.evs
    optimizer.tell(batch, evs)               # evs[t] belongs to batch[t]

The second is shot reduction for a single circuit: pack k identical copies, run each job at shots/k, and pool the tiles with the averaged observable — same statistics, a k-fold shorter shot budget per job.

packed = pack_circuit(backend, bell, k=8)
pooled = packed.observable("ZZ", "mean")     # (1/8) * sum of the per-tile operators
value = estimator.run([(packed.isa, pooled)]).result()[0].data.evs

packed.observable(obs, tile) maps a block-level Pauli observable onto the packed ISA circuit — one tile, the list over all copies (None), or their pooled average ("mean"); packed.tiles / packed.physical_qubits(t) expose the placement, and the selection thresholds, buffer_hops, k="max", and strict semantics are those of tile_disjoint(). For custom flows the underlying pieces (tile_disjoint plus a concatenated tile-major initial_layout) remain available.

Measured results (Bell pairs, |Phi+> fidelity per pair):

  • FakeSherbrooke noise model, 8 pairs: mean fidelity 0.969 (worst 0.961) for tiles vs 0.894 (worst 0.775) for qiskit level-3 placement of the merged circuit and 0.816 (worst 0.231) for the trivial layout.

  • ibm_berlin (real hardware, live calibration, default mitigation): worst pair 0.982 for tiles vs 0.951 for level 3, with means at the mitigated ceiling.

  • ibm_berlin GHZ-20 line via best_subgraph: a clean 20-qubit path with min neighbor <ZZ> 0.970; before dead-edge pruning the search crossed a calibrated dead edge and that stabilizer collapsed to 0.742 — one bad edge poisons a 20-qubit entangled state, which is exactly what calibration-aware selection prevents.

Packing with CUDA-Q

The same pack_circuit() call packs a plain @cudaq.kernel — no signature convention and no manual tiles. The kernel’s gate list is extracted from its compiled Quake IR (loops, closure captures, and list indexing resolve to literal gates), tiles come from the same calibration-aware selection, and the copies are rebuilt into one kernel that applies each copy’s gates at its tile’s physical indices and measures the full register. The same two repeated-use patterns apply.

The variational batch binds one runtime-argument set per tile (block_args_batch); each step re-binds the same tiles with rebind — only extraction and the kernel rebuild run, in milliseconds.

import cudaq
from qkan import DeviceProfile, pack_circuit

@cudaq.kernel
def rotate(theta: float):
    q = cudaq.qvector(2)
    ry(theta, q[0])
    x.ctrl(q[0], q[1])

packed = pack_circuit(profile, rotate, block_args_batch=[(0.3,), (0.9,), (1.7,)])

for step in range(steps):
    result = cudaq.sample(packed.kernel, shots_count=2048)
    z0 = packed.expectation(result, "ZI")   # one value per tile
    packed = packed.rebind(optimizer.ask(3))

Shot reduction for a single circuit packs identical copies (block_args or none) and pools the tiles with "mean".

@cudaq.kernel
def bell():
    q = cudaq.qvector(2)
    h(q[0])
    x.ctrl(q[0], q[1])

packed = pack_circuit(profile, bell, k=8, max_readout_error=0.05)
result = cudaq.sample(packed.kernel, shots_count=256)      # 8 x 256 = 2048 effective
zz = packed.expectation(result, "ZZ", "mean")

xx_result = cudaq.sample(packed.basis_kernel("XX"), shots_count=256)
xx = packed.expectation(xx_result, "XX", "mean")

packed.expectation(result, pauli, tile) is the observable mapping: it reads <pauli> from a sample of the matching basis (the plain kernel for Z/I strings, basis_kernel(pauli) otherwise) — one tile, the list over copies (None), or the pooled "mean". packed.basis_kernel(bases) rebuilds the packed kernel with per-qubit basis changes before the measurement, so X/Y observables read as Z-parities — the hardware-safe observable path. packed.observable(pauli, tile) — the same call that maps qiskit observables — builds cudaq.spin operators at a tile’s physical indices for cudaq.observe on packed.observe_kernel(); hardware targets that compact idle qubits reject sparse-index observables, so treat that as the simulator route on the native cudaq pipelines. Through the Braket target the observe route does run end-to-end: exact on SV1 (sparse indices and idle qubits included), and on Rigetti hardware a 20-qubit random circuit returned program-indexed expectations whose strongest observable came back sign-correct under realistic noise decay (weak |<P>| < 0.15 observables drown at 500 shots). Each Pauli term submits one Braket task, so multi-term operators multiply task fees — basis_kernel plus expectation reads every tile from one counts set per basis and stays the cheaper hardware route. This path is verified numerically on the qpp-cpu simulator (exact Bell/GHZ correlations at sparse physical tiles; parameterized blocks match cos(theta) per tile) and at the wire level (the OpenQASM submitted through the quantum_machines target carries the gates on the exact tile indices over the full register). Whether a hardware target honors the physical indices matches the single-qubit case: Braket’s vendor compiler may rewire (verified on Rigetti hardware for single-qubit packing and for a packed 20-qubit job, whose compiled Quil ran on a different chip region than requested) and quantum_machines mapping is decided by the Qoperator server. The native iqm / oqc / anyon pipelines compact untouched qubits, and the packed kernel’s full-register measurement is the verified mitigation — gates stay at the exact physical indices through the emulated iqm codegen.

For explicit control, pack_circuit(..., tiles=...) pins hand-picked tiles (from tile_disjoint() or best_subgraph()) on either stack, skipping the calibration-driven selection. Automatic mode bakes argument values at pack time (per tile via block_args_batch, refreshed with rebind); to thread runtime arguments through to sample time unchanged, write the packed kernel by hand — a top-level kernel that takes the runtime arguments and calls a (q: cudaq.qview, layout: list[int], offset: int) block at each tile’s argument offset. For a fixed hand-written block over that convention, pack_circuit(..., tiles=...) composes the copies by sub-kernel calls exactly as written, skipping the Quake extraction.

Caveats

  • The interaction graph must be connected and embed in the coupling map without routing; pre-transpile and extract the routed interaction graph for circuits that need SWAPs.

  • Inputs are measurement-free: pack_circuit rejects circuits with classical bits (copies would share clbits and in-circuit measurements corrupt estimator values) and kernels that measure (the packed kernel measures the full register itself); expectation rejects results whose bitstrings are narrower than the packed register.

  • CUDA-Q introspection covers flat kernels: sub-kernel calls, mid-circuit conditionals, and qubit indices that stay dynamic after constant folding are rejected with clear errors, and parameterized kernels are extracted per bound argument set (angles are baked at pack time; block_args_batch extracts once per tile and rebind once per step, all entries preserving the block’s qubit count and interaction graph).

  • The Quake extraction uses internal-but-stable cudaq.mlir entry points (the same ones cudaq’s kernel builder uses) and is pinned per cudaq release by the packing test suite (verified on cudaq 0.15).

  • Crosstalk between neighboring tiles is not modeled by public calibration data; buffer_hops=1 leaves a one-qubit shell between tiles at the cost of fewer tiles (52 to 31 Bell tiles on FakeSherbrooke).

  • Calibration drifts: rebuild profiles before long runs.