How to locate a buggy sub with subwise profiling¶
Why use subwise profiling¶
When a large FTQC circuit uses more T gates, more ancillas, or more Toffolis than you expected, the aggregate count from GateCountEvaluatorHooks tells you that something is wrong but not where. The usual workaround is to split the circuit into pieces by hand, compile each piece, and count each piece on its own until you find the sub that is wrong.
Subwise profiling removes that manual work. It walks the compiled MachineSub once and reports, for each sub, its call count, qubit usage, and primitive-gate breakdown. The sub whose numbers do not match your expectation is the sub to inspect.
Build a parametric circuit with nested subs¶
Suppose you are writing ToffoliLadder(n) by hand: a chain of Toffolis onto one shared target, one for each adjacent pair of the n controls. You expect the Toffoli count to grow linearly in n, one Toffoli per added control.
You build it recursively: each level adds one Toffoli and recurses on the remaining controls. The intended body has one recursive call per level. The buggy body has a copy-paste error that recurses twice.
from collections.abc import Sequence
from quri_parts.qsub.opsub import ParamUnitarySubDef, param_opsub
from quri_parts.qsub.register import QRegSpec
from quri_parts.qsub.sub import SubBuilder
from quri_parts.qsub.lib import std
# Someone defined ToffoliLadder by hand, but incorrect
class _ToffoliLadder(ParamUnitarySubDef[int]):
name = "ToffoliLadder"
def qubit_count_fn(self, n: int) -> int:
return n + 1
def qregs_fn(self, n: int) -> Sequence[QRegSpec]:
return (QRegSpec("ctrl", n), QRegSpec("tgt", 1))
def sub(self, b: SubBuilder, n: int) -> None:
ctrls = b.qregs["ctrl"].qubits
(tgt,) = b.qregs["tgt"].qubits
if n == 1:
b.add_op(std.CNOT, (ctrls[0], tgt))
return
if n == 2:
b.add_op(std.Toffoli, (ctrls[0], ctrls[1], tgt))
return
# Bug: recursive call pasted twice -> Toffoli(n) = 2*Toffoli(n-1) + 1.
b.add_op(ToffoliLadder(n - 1), (*ctrls[:-1], tgt))
b.add_op(std.Toffoli, (ctrls[-2], ctrls[-1], tgt))
b.add_op(ToffoliLadder(n - 1), (*ctrls[:-1], tgt))
ToffoliLadder, _ = param_opsub(_ToffoliLadder)
Step 1. First approach: analysis with aggregate counting¶
Sweep n and run the existing GateCountEvaluatorHooks. The Toffoli count should grow by one per added control. Instead it doubles.
from quri_parts.qsub.compile import compile
from quri_parts.qsub.evaluate import Evaluator
from quri_parts.qsub.eval import GateCountEvaluatorHooks
primitives = (
std.X, std.H, std.T, std.Tdag, std.S, std.Sdag,
std.CNOT, std.Toffoli,
)
for n in range(2, 8):
compiled = compile(ToffoliLadder(n), primitives)
totals = Evaluator(GateCountEvaluatorHooks((std.Toffoli,))).run(compiled)
print(f"n={n} ->", {k[1]: v for k, v in totals.items()})
n=2 -> {'Toffoli': 1}
n=3 -> {'Toffoli': 3}
n=4 -> {'Toffoli': 7}
n=5 -> {'Toffoli': 15}
n=6 -> {'Toffoli': 31}
n=7 -> {'Toffoli': 63}
Expected: Toffoli = 1, 2, 3, 4, 5, 6. Observed: 1, 3, 7, 15, 31, 63, which is 2^{n-1} - 1. The aggregate count alone cannot say which level is doubling.
Step 2. Second approach with subwise profiling¶
SubwiseProfileEvaluatorHooks has the same API shape as GateCountEvaluatorHooks, so it works with the same Evaluator pipeline:
The constructor takes the gates you want broken down. Pass nothing to count every primitive that appears.
run(compiled)returns a list of entries, one per distinct sub, parameters included.ToffoliLadder(5)andToffoliLadder(6)get separate entries.Each entry has these attributes:
source_op: theIdentof the op this sub implements. Because it is the identifier rather than a formatted string, you can compare it directly against an op you already hold (entry.source_op == ToffoliLadder(5).id), and its.paramscarries the parameters.to_str(full=False)renders it in the usual qsub form, with parameters in angle brackets:ToffoliLadder<5>. It isNonefor the entry sub ofcompile_sub, which receives aSuband so has no op.call_count: how many times the program invokes this sub.qubit_counts: the qubits the caller passes in (data), the peak ancilla usage (auxiliary), and their sum (total).auxiliaryis the sub’s own ancillas plus the largestauxiliaryamong the subs it calls, because sequential callees reuse the same ancilla space.gate_counts: the primitive-gate breakdown, oneGateCountsper scope. Counts are keyed by the gate’sIdent, so you look a gate up ascounts[std.Toffoli.id]. Op parameters are part of that key, soRZ(0.1)andRZ(0.2)are counted apart. This is finer thanGateCountEvaluatorHooks, which keys byBaseIdentand merges them. Thegates=filter still selects by kind, sogates=(std.RZ,)keeps every angle. EachGateCountsholdsper_call(one invocation) andall_calls(multiplied bycall_count):
Scope |
Attribute |
|---|---|
Gates in this sub’s own instructions |
|
Gates contributed by every sub this one calls, summed transitively |
|
|
|
Put one row per entry into a dataframe, reading each scope at std.Toffoli.id (desc abbreviates descendants, and cum abbreviates cumulative):
from collections.abc import Mapping
import pandas as pd
from quri_parts.qsub.eval import SubwiseProfileEntry, SubwiseProfileEvaluatorHooks
from quri_parts.qsub.op import Ident
compiled = compile(ToffoliLadder(6), primitives)
report_data = Evaluator(SubwiseProfileEvaluatorHooks(gates=(std.Toffoli,))).run(compiled)
TOFFOLI = std.Toffoli.id
def sub_ident_name(ident: Ident | None) -> str:
return ident.to_str(full=False) if ident else "<entry>"
def sub_name(entry: SubwiseProfileEntry) -> str:
return sub_ident_name(entry.source_op)
def by_gate_name(counts: Mapping[Ident, int]) -> dict[str, int]:
return {ident.to_str(full=False): n for ident, n in counts.items()}
def profile_table(report: Sequence[SubwiseProfileEntry]) -> pd.DataFrame:
return pd.DataFrame([
{
"sub": sub_name(e),
"call_count": e.call_count,
"qubits": e.qubit_counts.total,
"Toffoli own/call": e.gate_counts.own.per_call.get(TOFFOLI, 0),
"Toffoli desc/call": e.gate_counts.descendants.per_call.get(TOFFOLI, 0),
"Toffoli cum/call": e.gate_counts.cumulative.per_call.get(TOFFOLI, 0),
"Toffoli cum total": e.gate_counts.cumulative.all_calls.get(TOFFOLI, 0),
}
for e in report
])
profile_table(report_data)
| sub | call_count | qubits | Toffoli own/call | Toffoli desc/call | Toffoli cum/call | Toffoli cum total | |
|---|---|---|---|---|---|---|---|
| 0 | ToffoliLadder<6> | 1 | 7 | 1 | 30 | 31 | 31 |
| 1 | ToffoliLadder<5> | 2 | 6 | 1 | 14 | 15 | 30 |
| 2 | ToffoliLadder<4> | 4 | 5 | 1 | 6 | 7 | 28 |
| 3 | ToffoliLadder<3> | 8 | 4 | 1 | 2 | 3 | 24 |
| 4 | ToffoliLadder<2> | 16 | 3 | 1 | 0 | 1 | 16 |
The profile table shows two things:
Toffoli own/callis1at every level. No single body contains too many Toffolis.call_countdoubles per level (1, 2, 4, 8, 16) andToffoli desc/callgrows by about 2x per level (30, 14, 6, 2, 0). The excess comes from the child fan-out, not from any one body.
Step 3. Draw the call graph¶
The profile table says the excess comes from the child fan-out. CallGraphEvaluatorHooks extracts that structure. Evaluating the same compiled sub returns the call graph as a dict. The dict maps each sub to the subs it calls directly, each with its number of calls per caller invocation, and a leaf maps to an empty dict. Nodes are op Idents, the same values the profile reports as source_op, so you can join the two reports on them.
from quri_parts.qsub.eval import CallGraphEvaluatorHooks
call_graph = Evaluator(CallGraphEvaluatorHooks()).run(compiled)
{
sub_ident_name(caller): {sub_ident_name(c): n for c, n in callees.items()}
for caller, callees in call_graph.items()
}
{'ToffoliLadder<6>': {'ToffoliLadder<5>': 2},
'ToffoliLadder<5>': {'ToffoliLadder<4>': 2},
'ToffoliLadder<4>': {'ToffoliLadder<3>': 2},
'ToffoliLadder<3>': {'ToffoliLadder<2>': 2},
'ToffoliLadder<2>': {}}
The shape alone does not show the bug: it is a plain chain, the same chain the fixed version produces. The per-edge counts show the bug. draw_call_graph puts them in one picture. It annotates each edge with its calls per parent invocation, highlighted when greater than one. If you also pass the profile report, it annotates each node with its call count and aggregate gate counts.
draw_call_graph uses Graphviz to lay out the graph. To install the graphviz Python package, run pip install "quri-parts-qsub[visualize]". Rendering also needs the Graphviz system package.
from quri_parts.qsub.visualize import draw_call_graph
draw_call_graph(call_graph, report_data)
Every edge is x2 where the intended decomposition has x1. No single level is at fault. The doubling multiplies along the chain. Following the single path from the root shows the multiplication accumulating into each level’s call_count:
entries = {e.source_op: e for e in report_data}
ident = ToffoliLadder(6).id
path = []
while True:
path.append(f"{ident.to_str(full=False)} x{entries[ident].call_count}")
callees = call_graph[ident]
if not callees:
break
ident = next(iter(callees))
print(" -> ".join(path))
ToffoliLadder<6> x1 -> ToffoliLadder<5> x2 -> ToffoliLadder<4> x4 -> ToffoliLadder<3> x8 -> ToffoliLadder<2> x16
Step 4. Inspect a single sub¶
Pick a single entry out of the report and inspect it on its own. Because source_op is an identifier, you can match it against the op you want.
mcx5_entry = next(e for e in report_data if e.source_op == ToffoliLadder(5).id)
print("sub:", sub_name(mcx5_entry))
print("params:", mcx5_entry.source_op.params)
print("calls:", mcx5_entry.call_count)
print("qubits:", mcx5_entry.qubit_counts)
print("own (per call):", by_gate_name(mcx5_entry.gate_counts.own.per_call))
print("descendants (per call):", by_gate_name(mcx5_entry.gate_counts.descendants.per_call))
print("cumulative (per call):", by_gate_name(mcx5_entry.gate_counts.cumulative.per_call))
sub: ToffoliLadder<5>
params: (5,)
calls: 2
qubits: QubitCounts(data=6, auxiliary=0, total=6)
own (per call): {'Toffoli': 1}
descendants (per call): {'Toffoli': 14}
cumulative (per call): {'Toffoli': 15}
ToffoliLadder<5> has own.per_call = {Toffoli: 1} and cumulative.per_call = 15. The excess comes from the subs it calls, not from this body.
Step 5. Fix and re-check¶
Delete the duplicate recursive call from _ToffoliLadder.sub, redefine the sub, and sweep n again. The Toffoli count should now grow by one per added control.
class _ToffoliLadder(ParamUnitarySubDef[int]): # noqa: F811 — replacing the buggy definition
name = "ToffoliLadder"
def qubit_count_fn(self, n: int) -> int:
return n + 1
def qregs_fn(self, n: int) -> Sequence[QRegSpec]:
return (QRegSpec("ctrl", n), QRegSpec("tgt", 1))
def sub(self, b: SubBuilder, n: int) -> None:
ctrls = b.qregs["ctrl"].qubits
(tgt,) = b.qregs["tgt"].qubits
if n == 1:
b.add_op(std.CNOT, (ctrls[0], tgt))
return
if n == 2:
b.add_op(std.Toffoli, (ctrls[0], ctrls[1], tgt))
return
b.add_op(ToffoliLadder(n - 1), (*ctrls[:-1], tgt))
b.add_op(std.Toffoli, (ctrls[-2], ctrls[-1], tgt))
ToffoliLadder, _ = param_opsub(_ToffoliLadder)
for n in range(2, 8):
compiled = compile(ToffoliLadder(n), primitives)
totals = Evaluator(GateCountEvaluatorHooks((std.Toffoli,))).run(compiled)
print(f"n={n} ->", {k[1]: v for k, v in totals.items()})
n=2 -> {'Toffoli': 1}
n=3 -> {'Toffoli': 2}
n=4 -> {'Toffoli': 3}
n=5 -> {'Toffoli': 4}
n=6 -> {'Toffoli': 5}
n=7 -> {'Toffoli': 6}
Subwise profiling on the fixed version should now show call_count = 1 for every level and Toffoli desc/call decreasing by one per level.
compiled = compile(ToffoliLadder(6), primitives)
report_data_fixed = Evaluator(SubwiseProfileEvaluatorHooks(gates=(std.Toffoli,))).run(compiled)
profile_table(report_data_fixed)
| sub | call_count | qubits | Toffoli own/call | Toffoli desc/call | Toffoli cum/call | Toffoli cum total | |
|---|---|---|---|---|---|---|---|
| 0 | ToffoliLadder<6> | 1 | 7 | 1 | 4 | 5 | 5 |
| 1 | ToffoliLadder<5> | 1 | 6 | 1 | 3 | 4 | 4 |
| 2 | ToffoliLadder<4> | 1 | 5 | 1 | 2 | 3 | 3 |
| 3 | ToffoliLadder<3> | 1 | 4 | 1 | 1 | 2 | 2 |
| 4 | ToffoliLadder<2> | 1 | 3 | 1 | 0 | 1 | 1 |
The call graph confirms the fix: the chain is unchanged, every edge is x1, and the Toffoli totals now decrease by one per level.
call_graph_fixed = Evaluator(CallGraphEvaluatorHooks()).run(compiled)
draw_call_graph(call_graph_fixed, report_data_fixed)
In production, prefer an audited library sub where one exists. Subwise profiling is for the cases where you have to write the sub yourself.