Drishan
← All field notes

/8 min read/Compilers/Quantum/compilers · quantum · qir · cuda-q · qsharp · benchmarks

The scoreboard: qcc against Qiskit, pytket, and Q#

Emitting QIR, running it on CUDA-Q, and the honest benchmark. Four local passes match or beat the Qiskit transpiler's O2/O3 on eight of ten suites at a fraction of pytket's compile time, lose exactly where they should, and reveal what Microsoft's QDK is actually for.

Part 4 of 4Building a Quantum CompilerAll parts →

The first three parts built an IR (part 1), an oracle (part 2), and four optimization passes (part 3). This part takes the optimized circuit out of the compiler and into the world: it emits QIR, runs on CUDA-Q, and then measures qcc against modern transpilers. Every number here comes from results/compile_bench.json, stamped with the machine it ran on.

QIR: back to the register model

QIR is the portable output, the LLVM-based format the broader quantum toolchain speaks. It uses the pointer model from part 1, so emission walks the optimized SSA and, through pyqir, calls an intrinsic per gate. The base profile is deliberately spare, so a few gates lower on the way out: u3 becomes rz·ry·rz, swap becomes three cx, the phase gate becomes rz. A Bell pair comes out as:

uv run qcc compile examples/bell.qasm --emit qir
define void @main() #0 {
entry:
call void @__quantum__qis__h__body(ptr null)
call void @__quantum__qis__cnot__body(ptr null, ptr inttoptr (i64 1 to ptr))
call void @__quantum__qis__mz__body(ptr null, ptr null)
call void @__quantum__qis__mz__body(ptr inttoptr (i64 1 to ptr), ptr inttoptr (i64 1 to ptr))
ret void
}

CUDA-Q: driving the GPU

For execution I target CUDA-Q. One finding worth recording, current as of CUDA-Q 0.15 (August 2026): there is no public API to ingest external QIR back into a kernel. QIR is an output, not an input. So the execution backend builds a CUDA-Q kernel from the same optimized IR through the kernel-builder API, and QIR stays the portable artifact. If a later release adds QIR ingestion, that becomes a second, simpler path; the recipe is to check whether cudaq has grown a load-from-QIR entry point and prefer it when it exists.

On an RTX 5090 the nvidia target runs the Bell pair as expected, with a CPU fallback when no GPU is present. One convention to watch: CUDA-Q orders measurement bits with qubit 0 least significant, while qcc’s simulator uses the opposite convention, so comparing states means reversing the bit order first.

uv run qcc run examples/bell.qasm --shots 2000
target: nvidia
00 976
11 1024

The benchmark, and the rules

The point of a benchmark is that it can be lost, so the rules are strict and stated up front. All three compilers receive the identical circuit, translated to a common gate set at optimization level zero so nobody gets a head start from a smarter frontend. Qiskit runs transpile with no backend and no coupling map, which is logical optimization only, the same game qcc plays. pytket runs FullPeepholeOptimise. Timings are the median of five runs of the optimization call alone. Every qcc result is checked equivalent to the input by the part-2 oracle before it counts.

Ten suites: GHZ, the QFT at two sizes, QAOA max-cut, random Clifford+T, a hardware-efficient ansatz, and a ripple-carry adder.

Total gates

Lower is better. Qiskit’s -O2 and -O3 produced identical gate totals on every suite, so they share a column.

suite input qcc qiskit O1 qiskit O2/O3 pytket
ghz n12 12 12 12 12 12
qft n6 84 68 74 61 61
qft n10 240 194 222 181 181
qaoa n12 p2 (a) 144 135 144 144 138
qaoa n12 p2 (b) 144 134 144 144 140
clifford+T n8 (a) 400 201 251 216 210
clifford+T n8 (b) 400 178 218 198 191
clifford+T n8 (c) 400 205 250 220 219
ansatz su2 n10 267 67 67 67 91
adder b4 137 125 129 129 132

Four local rewrites match or beat Qiskit’s -O2/-O3 on total gate count in eight of the ten suites: six outright wins (both QAOA, all three Clifford+T, the adder), two ties (GHZ, which is already minimal, and the ansatz), and two losses, both QFT. Summed across every suite, qcc leaves 1319 gates standing against Qiskit -O2/-O3’s 1372 and pytket’s 1375.

Gates remaining after each optimizer, as a percentage of the input, one row per benchmark

Two-qubit gates: where qcc loses

Total gate count is the flattering metric. On real hardware the two-qubit gates are the expensive, error-prone ones, and here the story inverts.

suite input 2q qcc 2q qiskit O2 2q pytket 2q
clifford+T n8 (a) 129 121 119 95
clifford+T n8 (b) 109 103 101 81
clifford+T n8 (c) 121 119 116 95
qft n10 95 95 90 90

pytket removes roughly a quarter of the two-qubit gates on Clifford+T; qcc barely touches them. This is not a tuning gap, it’s a missing capability. qcc’s passes only ever cancel, merge, and fuse gates that are already there. pytket and Qiskit’s higher levels collect two-qubit blocks and resynthesize them, finding shorter implementations of the same two-qubit unitary. That is also why Qiskit wins both QFT rows and why pytket wins the 2q column everywhere. My compiler has no pass that touches two-qubit structure, and this figure is that absence drawn out.

Two-qubit gates remaining after each optimizer; pytket well ahead on Clifford+T

Compile time

Optimization is not free, and depth of optimization costs wall-clock. Median across all suites: qcc 11 ms, Qiskit -O1 7 ms, -O2 15 ms, -O3 19 ms, pytket 428 ms. qcc buys -O2-quality gate counts at closer to -O1 compile time, and runs tens of times faster than pytket’s peephole pass, whose resynthesis is the thing it’s spending that time on.

Optimization wall time per suite on a log scale, median marked; pytket two orders of magnitude slower

The Q# stack plays a different game

Microsoft’s QDK belongs in this story, but not in the table above, because its compiler is not an optimizing transpiler and pretending it is one would be a category error. The QDK is a language frontend: it lowers Q# and, since its OpenQASM support landed, OpenQASM 3 to QIR, and by design leaves gate-level optimization to whatever consumes the QIR downstream. That architectural bet is measurable, so I measured it (results/qdk_qir_compare.json).

The comparison that is fair to both: feed the QDK and qcc the identical OpenQASM 3, and count the QIR instructions each emits with one shared parser, so each side pays for its own lowering choices. On eight of ten suites the QDK’s base-profile QIR is instruction-for-instruction the size of unoptimized qcc at -O0. The two QFT rows differ for a lowering reason, not an optimization one: the QDK keeps a native swap intrinsic, while qcc’s emission expands swap into three cx. Confirmed, then: the QDK does what it says on the tin, and an optimizer in front of it has real work to do.

suite QDK (base) qcc -O0 qcc -O1
qft n10 240 250 214
clifford+T n8 (a) 320 320 226
vqe su2 n10 r3 267 267 130
adder b4 113 113 109
qaoa n12 p2 (a) 144 144 153

The QAOA row is a metric lesson I nearly optimized away for the wrong reason. Fusion coarsens pairs of cheap gates into u3, and base-profile QIR has no generic one-qubit intrinsic, so a three-angle u3 re-expands into three rotations: the optimizer improved the IR gate count and pessimized the QIR instruction count. Every optimizing compiler makes this exact trade (Qiskit’s u, tket’s TK1), the two-qubit count that dominates hardware cost is untouched, and the free half of the fix was real: eliding near-zero ZYZ angles at emission, so a u3 that is really one rotation costs one instruction. That change alone erased the adder regression and cut the Clifford+T and ansatz QIR sizes.

Two more numbers for the road. Median source-to-QIR compile: QDK 84 ms, qcc 33 ms. And the cross-stack check that matters most: the QDK’s own simulator executes qcc’s optimized output for GHZ-12 and reproduces exactly the two-bitstring support. Two independently built stacks agreeing across a shared IR boundary is what QIR is for.

What this proves, and what it doesn’t

Four local def-use rewrites, none longer than a screen, get you to parity with a production transpiler’s total gate count on most circuits, verified correct on every one, at compile times that never embarrass you. That is the case for the value-semantics IR from part 1: the optimizations are small because the data structure did the hard part.

It does not get you two-qubit resynthesis, and the benchmark is honest about the cost. Closing that gap means a pass that collects two-qubit blocks and rebuilds them from their unitary, the KAK decomposition, which is the natural next thing to build and a good subject for wherever this series goes next.

Every figure and number here regenerates from the repo with uv run python bench/run_bench.py and uv run python bench/qdk_compare.py, on an RTX 5090 with Qiskit 2.5, pytket 2.18, CUDA-Q 0.15, QDK 1.31, and xDSL 0.68, all recorded in the results JSONs’ provenance blocks.