Case Study 2: Reading the Compiler's Mind

"'The transpiler optimized it' is not an explanation. The diff is."

Executive Summary

A circuit that works at optimization level 3 fails at level 1 — or runs three times slower, or uses different qubits, or produces a different answer. Something in the compiler changed and you need to know what.

There is a precise way to find out, and it takes about a minute: dump the QASM at both levels and diff them. The output is a line-by-line record of every decision the compiler made, in a format you can read.

This case study builds that technique on three circuits of increasing size, extracts a repeatable method, and ends with a small tool you can keep. It is the most immediately practical hour in Part I, and the skill is used constantly from Chapter 10 onward.

Skills applied: reading transpiled QASM (§6.5); physical qubit notation and inline gate definitions (§6.5); virtual rz (Chapter 3 §3.8); routing costs (Chapter 4 §4.6).

Reproducibility. FakeSherbrooke, seed_transpiler=42, entirely local.

The Technique

import difflib
from qiskit import qasm3
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
from qiskit_ibm_runtime.fake_provider import FakeSherbrooke

backend = FakeSherbrooke()

def qasm_at(qc, level):
    pm = generate_preset_pass_manager(optimization_level=level, backend=backend,
                                      seed_transpiler=42)
    return qasm3.dumps(pm.run(qc)).splitlines()

def compare(qc, a=0, b=3):
    print("\n".join(difflib.unified_diff(qasm_at(qc, a), qasm_at(qc, b),
                                         f"level {a}", f"level {b}", lineterm="")))

Three lines of real work. Now use it.

Investigation 1: The Bell State

Chapter 2's Exercise 2.20 measured level 0 at 25 operations and level 3 at 9, without saying which sixteen disappeared. Now we can look.

qc = QuantumCircuit(2, 2)
qc.h(0)
qc.cx(0, 1)
qc.measure([0, 1], [0, 1])
compare(qc)

The diff shows a long run of rz/sx pairs at level 0 collapsing into a much shorter sequence at level 3, with the ecr and the two measure statements unchanged in both.

What the compiler did, read off the diff:

It merged adjacent single-qubit gates. Level 0 lowers each logical gate independently — h becomes its own $R_z\sqrt{X}R_z$ sequence, and the single-qubit corrections around the ecr become theirs. Level 3 notices that consecutive single-qubit gates on the same wire compose into one arbitrary single-qubit unitary, and Chapter 3's Case Study 2 established that an arbitrary single-qubit gate costs exactly three rz and two sx regardless of complexity. Ten operations become five, and five of the ten were free anyway.

It did not touch the two-qubit gate. One ecr before, one ecr after. There is nothing to optimize about a single entangling gate, and this is the general pattern: optimization levels move single-qubit gate counts around and rarely change two-qubit counts on small circuits.

That second observation is the useful one, because two-qubit gates dominate the error budget. It means that on a circuit like this, the difference between levels is real but modest — consistent with Chapter 2's measurement of 95.6% versus 98.3%.

Investigation 2: Where Routing Appears

Small circuits do not exercise the interesting part. Scale up:

ghz = QuantumCircuit(5, 5)
ghz.h(0)
for i in range(4):
    ghz.cx(i, i + 1)
ghz.measure(range(5), range(5))

Now compare not just the text but the extracted facts:

import re

def facts(qc, level):
    pm = generate_preset_pass_manager(optimization_level=level, backend=backend,
                                      seed_transpiler=42)
    isa = pm.run(qc)
    text = qasm3.dumps(isa)
    ops = dict(isa.count_ops())
    return {
        "physical": sorted({int(m) for m in re.findall(r"\$(\d+)", text)}),
        "depth": isa.depth(),
        "two_qubit": sum(v for k, v in ops.items() if k in ("ecr", "cz", "cx")),
        "real_pulses": sum(v for k, v in ops.items() if k in ("sx", "x")),
        "virtual": ops.get("rz", 0),
    }

for level in (0, 1, 2, 3):
    print(level, facts(ghz, level))

Two things to look for in the output, and they are the whole point of scaling up.

Did the two-qubit count exceed what you wrote? You wrote four CNOTs. If a level reports more than four two-qubit gates, the router inserted SWAPs — three CNOTs each — because it needed to connect qubits that are not physically adjacent. This is Chapter 4 §4.6's hidden cost, made visible, and it is usually the single largest difference between a good layout and a bad one.

Did the physical qubit set change between levels? Levels 0 and 1 assign by position; levels 2 and 3 assign using error data. Chapter 4's Case Study 1 showed what that difference can be worth: an eightfold change in the correctness of the answer, from nothing but qubit choice.

⚙️ Under the Transpiler — The four numbers to extract from any diff.

Number Where What it tells you
physical qubits the $n identifiers which layout was chosen; record this with every result
two-qubit gate count count ecr/cz/cx whether SWAPs were inserted — compare to what you wrote
real pulses count sx + x the true single-qubit cost (rz is free)
depth isa.depth() the coherence budget consumed

If you extract nothing else from a transpiled circuit, extract these four. They answer most of the questions that arise when a circuit behaves differently than expected, and three of the four are readable straight from the QASM text.

Investigation 3: When the Diff Is Empty

Run the comparison on a circuit that is already minimal — say, a single cx between two adjacent physical qubits — and the diff comes back empty.

That is a result, not a failure. It says the compiler found nothing to do, which means:

  • Your circuit is already in the device's basis and layout.
  • Any performance problem you have is not a compilation problem, and looking for one is wasted effort.
  • The difference between optimization levels, for this circuit, is exactly zero — so use level 1 and save the transpilation time.

An empty diff is one of the most useful outputs of this technique, because it eliminates a whole class of hypothesis. Chapter 26 will make the general version of this argument: a diagnostic that rules something out is worth as much as one that rules something in.

The Method, Extracted

def transpiler_report(qc, backend, levels=(0, 1, 2, 3), seed=42):
    """What each optimization level does to a circuit. Print, read, decide."""
    rows = []
    for level in levels:
        pm = generate_preset_pass_manager(optimization_level=level, backend=backend,
                                          seed_transpiler=seed)
        isa = pm.run(qc)
        text = qasm3.dumps(isa)
        ops = dict(isa.count_ops())
        rows.append({
            "level": level,
            "depth": isa.depth(),
            "2q": sum(v for k, v in ops.items() if k in ("ecr", "cz", "cx")),
            "pulses": sum(v for k, v in ops.items() if k in ("sx", "x")),
            "free": ops.get("rz", 0),
            "qubits": sorted({int(m) for m in re.findall(r"\$(\d+)", text)}),
        })
    return rows

Use it like this, in order:

  1. Run transpiler_report first. Four numbers per level tell you most of what you need.
  2. If a level surprises you, diff its QASM against the neighbouring level. The specific lines that changed are the specific decisions that differed.
  3. If the two-qubit count exceeds what you wrote, you have a routing problem — go to Chapter 10 for the pass manager and Chapter 29 for layout.
  4. If the physical qubits differ between levels, check their calibration — Chapter 4's Case Study 1 is the procedure.
  5. If the diff is empty, stop looking at the compiler. The problem is elsewhere.

Lessons

  1. Diff the QASM. Three lines, and it converts "the transpiler optimized it" into a specific, readable list of decisions.
  2. Optimization levels mostly move single-qubit gates. Two-qubit counts change on larger circuits, through routing, and that is where the error budget lives.
  3. A two-qubit count higher than what you wrote means SWAPs were inserted, at three CNOTs each.
  4. rz is free — do not count it as cost. Count sx and x.
  5. The physical qubit set is part of your result. Record it; it changes between optimization levels and it changes your answer.
  6. An empty diff is informative. It rules out compilation as the cause.
  7. Extract four numbers from every transpiled circuit: physical qubits, two-qubit count, real pulses, depth.

Questions

  1. Run compare() on the Bell circuit yourself. Count exactly how many rz and sx operations appear at each level. Does the reduction match Chapter 2's 25 → 9?

  2. Build transpiler_report and run it on the 5-qubit GHZ chain. Does the two-qubit count ever exceed 4? If so, at which levels, and what does that tell you about the layouts chosen?

  3. Construct a circuit that forces routing: a CNOT between two qubits you know are far apart on the heavy-hex lattice (use initial_layout to pin them). Report the two-qubit gate count before and after transpilation, and compute how many SWAPs were inserted.

  4. Find a circuit where level 2 and level 3 produce identical QASM. What does that tell you about when level 3 is worth its extra compilation time?

  5. Find a circuit where level 3 is worse than level 2 on some metric. (Hint: the levels optimize different things, and transpilation time is itself a metric.) Report both.

  6. Extend transpiler_report to also report the estimated total error of the transpiled circuit, using the backend's per-gate error rates. Which level wins on that metric, and does it agree with the one that wins on depth?

  7. Hardest. The diff shows you what changed but not why. Qiskit's pass managers expose the list of passes that ran (pm.stages, and the pass manager's run accepts a callback). Instrument a transpilation to record the circuit after each pass, then identify which specific pass is responsible for the largest reduction on your circuit. What would you do with that information?