Transpilation benchmarks

Note

Qiskit has no synthesis method that does what GULPS does, so neither comparison is like for like. The default synthesis uses one kind of entangling gate per block, which is not optimal when the Target offers several. XXDecomposer finds least-cost sequences for these gates, but it runs in Python and has not been ported to Rust.

These benchmarks transpile QFT, EfficientSU2, quantum-volume, and random circuits of 4 to 64 qubits for an all-to-all Target with U and \(R_{ZZ}(\pi/2)\), \(R_{ZZ}(\pi/4)\), and \(R_{ZZ}(\pi/6)\). Duration is the sum of the gate durations, without scheduling.

Against Qiskit’s default synthesis

Qiskit’s default synthesis produces circuits 1.5 to 2 times longer than GULPS on every circuit except EfficientSU2. It synthesizes each block with TwoQubitBasisDecomposer over \(R_{ZZ}(\pi/2)\) alone, which is locally equivalent to CX, while GULPS also uses the two shorter gates.

import json
from pathlib import Path

rows = json.loads(Path("docs/_scripts/fullcircuit.json").read_text())
qubits = sorted({r["qubits"] for r in rows})
families = list(dict.fromkeys(r["circuit"] for r in rows))
ratio = {
    (r["circuit"], r["qubits"]): r["qiskit_duration_us"] / r["gulps_duration_us"]
    for r in rows
}
print("Default / GULPS summed duration")
print(f"{'Circuit':<14}" + "".join(f"{f'{n} qubits':>11}" for n in qubits))
for family in families:
    print(f"{family:<14}" + "".join(f"{ratio[family, n]:>11.2f}" for n in qubits))
Default / GULPS summed duration
Circuit          4 qubits   8 qubits  16 qubits  32 qubits  64 qubits
QFT                  1.70       1.83       1.87       1.89       1.90
EfficientSU2         1.00       1.00       1.00       1.00       1.00
QV                   1.60       1.59       1.60       1.58       1.58
Random               2.01       1.79       1.65       1.65       1.64

EfficientSU2 entangles with CX, so both outputs use \(R_{ZZ}(\pi/2)\) only.

Against XXDecomposer

GULPS transpiles tens to hundreds of times faster than XXDecomposer, with the same summed two-qubit duration in 19 of the 20 cases. To make the two minimize the same cost, each \(R_{ZZ}\) gate gets the fidelity (1 - ERR_BASE) ** p, where p is its duration relative to \(R_{ZZ}(\pi/2)\), so XXDecomposer’s largest fidelity product is the least summed duration.

Line chart of XXDecomposer's transpile time divided by GULPS's, on a logarithmic axis from 32 to 512, against qubit count from 4 to 64, with one line each for QFT, EfficientSU2, quantum volume, and random circuits and error bars spanning two rounds.

XXDecomposer’s transpile time divided by GULPS’s. Each point is the geometric mean of two rounds, and the bars span both rounds.

The one case with different duration

In the random circuit at 64 qubits, XXDecomposer’s output is shorter by one sixth of the \(R_{ZZ}(\pi/2)\) duration. The difference comes from one block with Weyl coordinates \((11/24,11/24,0)\). XXDecomposer synthesizes this block with one \(R_{ZZ}(\pi/6)\) and three \(R_{ZZ}(\pi/4)\), whose interaction strengths sum to \(11/12\), the sum of the block’s coordinates. The block therefore lies on the boundary of the region of this cheaper sentence. XXDecomposer’s reachability tolerance accepts the block, and its output has process infidelity up to \(9.5\times10^{-13}\) against it. GULPS uses two \(R_{ZZ}(\pi/2)\) gates.

Measurement setup

All pipelines ran with Qiskit 2.5.2 and GULPS built from this repository, pinned to one core of an AMD Ryzen 5 5600X under WSL2. BLAS, OpenMP, and Rayon each used one thread. Each measurement has two rounds with fresh decomposers and pass managers, and the pipeline order changes between rounds.

GATES = [
    ("zz", np.pi / 2, 1.0),
    ("sq2_zz", np.pi / 4, 1 / 2),
    ("sq3_zz", np.pi / 6, 1 / 3),
]
DUR_BASE, DUR_1Q, ERR_BASE = 500e-9, 52.3e-9, 1e-3
ROUNDS = 2


def build_target(n: int) -> Target:
    """An all-to-all Target on ``n`` qubits with the three RZZ gates and U."""
    target = Target()
    pairs = list(permutations(range(n), 2))
    for name, angle, scale in GATES:
        props = InstructionProperties(
            duration=DUR_BASE * scale, error=1 - (1 - ERR_BASE) ** scale
        )
        target.add_instruction(RZZGate(angle), {p: props for p in pairs}, name=name)
    u_err = 1 - (1 - ERR_BASE) ** (DUR_1Q / DUR_BASE)
    u_props = {
        (q,): InstructionProperties(duration=DUR_1Q, error=u_err) for q in range(n)
    }
    target.add_instruction(
        UGate(Parameter("t"), Parameter("p"), Parameter("l")), u_props
    )
    return target


class XXPass(TransformationPass):
    """Qiskit's mixed-strength XXDecomposer on every consolidated two-qubit block."""

    def __init__(self) -> None:
        """Build one decomposer with the three RZZ strengths and their fidelities."""
        super().__init__()
        embodiments, fidelities = {}, {}
        for _, angle, scale in GATES:
            qc = QuantumCircuit(2)
            qc.h([0, 1])
            qc.rzz(angle, 0, 1)
            qc.h([0, 1])
            # XXDecomposer maximizes the product of fidelities. With fidelity
            # (1 - ERR_BASE)**scale it minimizes the summed scale, as GULPS does.
            embodiments[angle], fidelities[angle] = qc, (1 - ERR_BASE) ** scale
        self.xx = XXDecomposer(fidelities, euler_basis="U", embodiments=embodiments)

    def run(self, dag: DAGCircuit) -> DAGCircuit:
        """Replace each two-qubit unitary node with its decomposition."""
        for node in dag.op_nodes():
            if node.op.name == "unitary" and node.op.num_qubits == 2:
                dag.substitute_node_with_dag(
                    node, self.xx(node.op.to_matrix(), approximate=False, use_dag=True)
                )
        return dag
def pass_managers(target: Target) -> dict[str, PassManager]:
    """The GULPS, XXDecomposer, and default UnitarySynthesis pipelines."""
    unroll, merge1q = (
        Unroll3qOrMore(target=target),
        Optimize1qGatesDecomposition(target=target),
    )
    consolidate = ConsolidateBlocks(force_consolidate=True)
    # The decomposer is built from the same gates and durations as the Target.
    gulps = GulpsDecomposer(
        [RZZGate(angle) for _, angle, _ in GATES], [scale for *_, scale in GATES]
    )
    return {
        "gulps": PassManager([unroll, GulpsDecompositionPass(gulps), merge1q]),
        "xx": PassManager([unroll, consolidate, XXPass(), merge1q]),
        "qiskit": PassManager(
            [unroll, consolidate, UnitarySynthesis(target=target), merge1q]
        ),
    }

Each pipeline runs once on a small circuit before it is timed.

def benchmark() -> list[dict]:
    """Transpile every circuit at every size with the three pipelines."""
    rows = []
    for n in QUBITS:
        target = build_target(n)
        warm = QuantumCircuit(n)
        warm.unitary(np.eye(4), [0, 1])
        warm.unitary(Operator(RZZGate(0.3)), [0, 1])
        for i, (family, qc) in enumerate(circuits(n).items()):
            row = dict(circuit=family, qubits=n, gulps_s=[], xx_s=[], qiskit_s=[])
            for rnd in range(ROUNDS):
                # Fresh pipelines per round: GULPS keeps each block class's sentence
                # across calls, so a repeated circuit would be timed warm.
                managers = pass_managers(target)
                for pm in managers.values():
                    pm.run(warm)
                keys = list(managers)
                shift = (rnd + i) % len(keys)
                for key in keys[shift:] + keys[:shift]:
                    start = time.perf_counter()
                    out = managers[key].run(qc)
                    row[key + "_s"].append(time.perf_counter() - start)
                    if rnd == 0:
                        if n <= 8:
                            assert Operator(out).equiv(Operator(qc)), (key, family, n)
                        row[key + "_duration_us"] = duration_us(out)
                        row[key + "_2q_duration_us"] = duration_us(out, one_qubit=False)
            row["same_cost"] = math.isclose(
                row["xx_2q_duration_us"],
                row["gulps_2q_duration_us"],
                rel_tol=SAME_COST_RTOL,
            )
            row.update(audit_blocks(qc, target))
            rows.append(row)
            print(
                f"{family:12s} {n:2d}q  GULPS {min(row['gulps_s']):.4f}-{max(row['gulps_s']):.4f}s"
                f"  XX {min(row['xx_s']):.4f}-{max(row['xx_s']):.4f}s"
                f"  default {min(row['qiskit_s']):.4f}-{max(row['qiskit_s']):.4f}s"
                f"  duration GULPS {row['gulps_duration_us']:.4f} XX {row['xx_duration_us']:.4f}"
                f" default {row['qiskit_duration_us']:.4f} us"
                f"  two-qubit GULPS {row['gulps_2q_duration_us']:.4f}"
                f" XX {row['xx_2q_duration_us']:.4f} us"
                f"  XX inexact blocks {row['blocks_xx_inexact']}",
                flush=True,
            )
    return rows
Plotting code
def plot(
    rows: list[dict], out: Path = HERE.parent / "_static" / "fullcircuit.svg"
) -> None:
    """Draw the XXDecomposer/GULPS ratio of transpile time."""
    with plt.rc_context({"svg.hashsalt": "fullcircuit"}):
        fig, ax = plt.subplots(figsize=(6.5, 3.4), constrained_layout=True)
        extent = []
        for family, marker in zip(FAMILIES, MARKERS):
            fam = sorted(
                (r for r in rows if r["circuit"] == family and r["xx_s"]),
                key=lambda r: r["qubits"],
            )
            qubits = [r["qubits"] for r in fam]
            rounds = np.array([np.divide(r["xx_s"], r["gulps_s"]) for r in fam])
            ratio = np.exp(np.log(rounds).mean(axis=1))
            extent += [rounds.min(), rounds.max()]
            ax.errorbar(
                qubits,
                ratio,
                yerr=(ratio - rounds.min(axis=1), rounds.max(axis=1) - ratio),
                marker=marker,
                capsize=1.5,
                elinewidth=0.8,
                label={"QV": "Quantum volume"}.get(family, family),
            )
        # The y range ends at the powers of two just outside the data.
        lo = int(np.floor(np.log2(min(extent))))
        hi = int(np.ceil(np.log2(max(extent))))
        if lo <= 0:
            ax.axhline(1, color="0.5", lw=0.8, ls="--", zorder=0)
        ax.set_xscale("log", base=2)
        ax.set_yscale("log", base=2)
        ax.set_ylim(2.0**lo, 2.0**hi)
        ax.set_xticks(QUBITS, [str(q) for q in QUBITS])
        ticks = [2**k for k in range(lo, hi + 1)]
        ax.set_yticks(ticks, [f"{t:g}" for t in ticks])
        ax.minorticks_off()
        ax.set_xlabel("Qubits")
        ax.set_ylabel("Transpile time, XXDecomposer / GULPS")
        fig.legend(loc="outside upper center", ncol=4)
        fig.savefig(out, metadata={"Date": None})

Reproduce the measurements

docs/_scripts/fullcircuit.py writes docs/_scripts/fullcircuit.json and the figure; --plot redraws the figure from the JSON file. Pinned to one core:

taskset -c 2 python docs/_scripts/fullcircuit.py

The run takes about three minutes, most of it in XXDecomposer at 32 and 64 qubits.