Resource estimation¶
qarp.resources answers “what does this circuit cost?” with numbers that
say where in the compilation pipeline they were taken. A
ResourceVector is one snapshot — width, depth, gate
counts by arity, measurements, resets, T count, SWAP count, a per-gate
histogram — stamped with a Provenance naming the
stage, gate set, optimisation level, router and device that produced it. A
ResourceReport is the ordered set of those snapshots
for one circuit.
The module never runs a circuit. It mirrors the staging an engine performs (rebase → optimise → route → rebase, see Devices and Engines) and counts at each boundary, so a number is always attributable to a configured pipeline rather than to “the circuit” in the abstract. The rules the numbers obey are the contract’s §19; this page shows the API.
Counting a block¶
estimate() is the entry point. With no gate set and no
device it takes the LOGICAL snapshot only — the circuit exactly as built:
from qarp.blocks import SimpleBlock
from qarp.resources import Stage, estimate
def workload(n=4):
b = SimpleBlock(n, name="workload")
b.h(0)
for k in range(1, n):
b.cx(0, k)
b.ccx(0, 1, 2)
b.rz(3, 0.4)
b.gphase(0.2) # a global phase is not a physical gate
b.reset(1) # nor is a reset
b.measure([(q, q) for q in range(n)])
return b.build()
block = workload()
report = estimate(block)
v = report[Stage.LOGICAL]
print("width / depth:", v.n_qubits, v.depth)
print("gates by arity:", v.n_1q, v.n_2q, v.n_3q_plus, "of", v.n_gates)
print("measurements / resets:", v.n_measurements, v.n_resets)
print("histogram:", dict(sorted(v.op_histogram.items())))
Two invariants worth internalising, both from §19:
The arity buckets partition the gate count.
n_1q + n_2q + n_3q_plus == n_gatesat every stage, and bucketing follows each command’s own qubit list — theCCXabove lands inn_3q_plus, so a controlled block’s wide gates are never invisible to a consumer reading the breakdown.``None`` is never ``0``. A field is
Nonewhen the quantity is not expressible for that snapshot: nothing has been routed, soswap_count is None; a symbolic or non-CliffordRzis present, sot_count is None. Unknowns are declared, never imputed — which is what makesswap_count == 0on a routed stage a real answer.
assert v.n_1q + v.n_2q + v.n_3q_plus == v.n_gates
assert v.swap_count is None # not routed
assert v.t_count is None # an Rz(0.4) has no exact T count
What counts as a gate is one classification, qx.gate_is_physical, shared
with the block accessors n_gates() / n_1q_gates() / n_2q_gates() /
n_nqb_gates(k) / depth() — so the report and the block agree by
construction. Measure and Reset are not gates but are reported
separately; Barrier, GPhase and branch markers are pseudo-ops and
appear nowhere.
assert v.n_gates == block.n_gates()
assert v.n_3q_plus == block.n_nqb_gates(3)
assert v.depth == block.depth()
Stages¶
Give estimate a gate set and a device and it snapshots every pipeline
boundary. OPTIMIZED is taken after the rebase and O1 optimisation,
ROUTED after routing but before the final rebase — the only stage at
which the router’s SWAPs exist as SWAP gates, so swap_count is
countable there — and TARGET after the final rebase into the device gate
set:
import qarpx as qx
from qarp.blocks import SimpleBlock
from qarp.devices import Device, get_nearest_neighbour_architecture
from qarp.resources import Stage, estimate
def fanout(n=5):
b = SimpleBlock(n)
b.h(0)
for k in range(1, n):
b.cx(0, k) # CX(0, k) fan-out forces SWAPs on a line
b.rz(3, 1e-3)
b.rz(4, 0.3)
b.measure([(q, q) for q in range(n)])
return b.build()
line = Device(5, architecture=get_nearest_neighbour_architecture(1, 5))
report = estimate(
fanout(),
gateset=qx.clifford_t_rz_gateset(),
device=line,
device_label="line-5",
)
show = lambda x: "—" if x is None else x
print(f"{'stage':<10}{'width':>6}{'depth':>6}{'gates':>6}{'2q':>5}{'swap':>6}{'T':>5}")
for stage, v in report.items():
print(f"{stage.value:<10}{v.n_qubits:>6}{v.depth:>6}{v.n_gates:>6}"
f"{v.n_2q:>5}{show(v.swap_count):>6}{show(v.t_count):>5}")
report.final is the last stage present; report.stages lists them in
pipeline order; report[Stage.ROUTED] addresses one. swap_count counts
the SWAP gates in the routed stream — router-inserted plus any the user
wrote that survive the rebase — not router overhead alone. The Rz gates
survive into TARGET (the Clifford+T+Rz gate set keeps them), so t_count
stays None on every stage here; making it countable is the job of
synthesis below.
The pipeline estimate walks is the configured one. It may legitimately
differ from a particular engine’s execution pipeline — an engine need not fuse
at all — so pick opt_level to match the pipeline you are claiming to
describe.
Modelers¶
Counted fields come from the command stream and nothing else. Anything that
requires a cost model — a T budget for a synthesis you have not run, a
per-gate error, a wall-clock estimate — is a modeled field, produced by a
ResourceModeler and stamped with
provenance.modeler. The two never mix: a modeler cannot rewrite a counted
field, and when the stream contains something opaque (a Custom gate) the
modeler must null its outputs rather than undercount.
No modeler ships in-tree; the protocol is the extension seam. A conforming
object exposes name and model(commands, vector) -> ResourceVector, and
with_model() is how it returns the
annotated copy:
import qarpx as qx
from qarp.resources import estimate
class OneTPerRz:
"""Toy: price every Rz as one T. A real modeler prices a synthesis budget."""
name = "one_t_per_rz"
def model(self, commands, vector):
n_rz = sum(c.gate == qx.GateType.Rz for c in commands)
opaque = any(c.gate == qx.GateType.Custom for c in commands)
return vector.with_model(
modeler=self.name,
t_count_modeled=None if opaque else float(n_rz),
extras={"n_rz": float(n_rz)},
)
report = estimate(
fanout(),
gateset=qx.clifford_t_rz_gateset(),
device=line,
device_label="line-5",
modeler=OneTPerRz(),
)
final = report.final
print("stage: ", final.provenance.stage.value)
print("modeler: ", final.provenance.modeler)
print("t_count: ", final.t_count, " (counted: Rz gates remain, so None)")
print("t_count_modeled:", final.t_count_modeled)
print("extras: ", final.extras)
assert final.t_count is None # counted stays honest …
assert final.t_count_modeled == final.extras["n_rz"] # … the model fills the gap
Clifford+T synthesis¶
Every transpiler pass is unitary-exact (§16), so approximate Rz →
Clifford+T synthesis is not a pass: it is a declared-ε stage that lives
outside the transpiler. Pass synthesis_epsilon and estimate appends a
SYNTHESIZED snapshot in which every Rz has been replaced by a
phase-exact H/S/T/X sequence within ε per rotation (Ross &
Selinger’s gridsynth, via the optional pygridsynth dependency —
pip install "openqarp[cliffordt]"). There t_count is exactly countable:
import math
import qarpx as qx
from qarp.resources import Stage, estimate
report = estimate(
fanout(),
gateset=qx.clifford_t_rz_gateset(),
device=line,
device_label="line-5",
synthesis_epsilon=1e-10,
)
syn = report[Stage.SYNTHESIZED]
print("synthesis:", syn.provenance.synthesis)
print("t_count: ", syn.t_count)
print("Rz left: ", syn.op_histogram.get("Rz", 0))
# Ross & Selinger: ~3·log2(1/ε) T per non-Clifford rotation.
print("expected per rotation:", round(3 * math.log2(1e10)))
assert syn.t_count is not None
assert "Rz" not in syn.op_histogram
Angles are folded modulo π/4 exactly before synthesis, so an Rz whose
angle is a multiple of π/4 becomes a one-gate S/T form and never
reaches gridsynth — multi-controlled lowerings emit those by the hundred.
ε is a per-rotation knob, not a global budget.
The same replacement is available standalone on a Clifford+T+Rz-format stream
through synthesize_clifford_t():
import qarpx as qx
from qarp.blocks import SimpleBlock
from qarp.resources import synthesize_clifford_t
b = SimpleBlock(1)
b.rz(0, 0.3)
b.build()
synthesized = synthesize_clifford_t(b.flatten(), epsilon=1e-6)
print(len(synthesized), "Clifford+T gates for one Rz at ε = 1e-6")
assert all(c.gate != qx.GateType.Rz for c in synthesized)
Serialising a report¶
to_dict() on a vector or a report is a stable wire format for external
resource-estimation tooling, versioned by
SCHEMA_VERSION; from_dict()
reads it back. Changes are additive-with-a-bump: the number never moves
without a migration.
import json
from qarp.resources import SCHEMA_VERSION, ResourceVector
d = final.to_dict()
print("schema", SCHEMA_VERSION, "keys:", sorted(d)[:6], "…")
assert ResourceVector.from_dict(d) == final
print(json.dumps(d, indent=2)[:400], "…")
Counting a raw command stream¶
estimate is a facade over ResourceEstimator,
which owns the pipeline; the bare counter underneath is
count_resources(), for when you already hold a command
list or a CircuitDAG and only want one snapshot. It needs the provenance
spelled out, because a command list carries no memory of what produced it:
from qarp.resources import Provenance, Stage, count_resources
v = count_resources(block.flatten(),
provenance=Provenance(stage=Stage.LOGICAL),
n_qubits=block.n_qubits)
assert v == estimate(block)[Stage.LOGICAL]
The notebooks examples/resources/mwe_counting.ipynb (the block accessors
and how the books balance between physical gates and non-gate commands) and
examples/resources/mwe_resource_estimation.ipynb (the full staged report,
a modeler, and synthesis) are the runnable companions to this page.