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) and ToffoliLadder(6) get separate entries.

  • Each entry has these attributes:

    • source_op: the Ident of 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 .params carries the parameters. to_str(full=False) renders it in the usual qsub form, with parameters in angle brackets: ToffoliLadder<5>. It is None for the entry sub of compile_sub, which receives a Sub and 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). auxiliary is the sub’s own ancillas plus the largest auxiliary among the subs it calls, because sequential callees reuse the same ancilla space.

    • gate_counts: the primitive-gate breakdown, one GateCounts per scope. Counts are keyed by the gate’s Ident, so you look a gate up as counts[std.Toffoli.id]. Op parameters are part of that key, so RZ(0.1) and RZ(0.2) are counted apart. This is finer than GateCountEvaluatorHooks, which keys by BaseIdent and merges them. The gates= filter still selects by kind, so gates=(std.RZ,) keeps every angle. Each GateCounts holds per_call (one invocation) and all_calls (multiplied by call_count):

Scope

Attribute

Gates in this sub’s own instructions

own

Gates contributed by every sub this one calls, summed transitively

descendants

own plus descendants

cumulative

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:

  1. Toffoli own/call is 1 at every level. No single body contains too many Toffolis.

  2. call_count doubles per level (1, 2, 4, 8, 16) and Toffoli desc/call grows 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)
../../../_images/ba2a764a9d0732e902ef6b9f719fc7289293a1b2cbb1ef45e463329d654ab209.svg

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)
../../../_images/c78e748318cc57a34272bce17e9321c08b379f5cd83ab39d3f5c24419eff9c20.svg

In production, prefer an audited library sub where one exists. Subwise profiling is for the cases where you have to write the sub yourself.