Gate Compilers

QUASAR compiles a circuit for a star-topology device in two steps: first to the device’s native gates, then to the control pulses that realize them. A GateCompiler performs the first step. It binds a gate set and a configuration, and the configuration’s type picks the compilation strategy. This notebook compiles one circuit along the Qiskit path and with the synthesis strategies, and compares the results.

You will see:

  • the Qiskit baseline, and the synthesis strategies: adaptive, tree-search and their error-aware versions;

  • how a looser tolerance trades accuracy for fewer gates;

  • how the error-aware strategies use the gate errors of the device;

  • estimate_errors, which splits the error of a compiled circuit into its parts;

  • when the error-aware search stops;

  • compression and rotation rounding, which shorten a compiled circuit;

  • a 4-qubit compile, and which configuration to choose when.

The Gate Sets tutorial introduces the gate sets. The Qubit Layout and State Preparation and Pauli Readout tutorials continue from here.

Setup

This notebook needs quasar and the plotting extra, which brings matplotlib and the pylatexenc that Qiskit’s circuit drawer requires:

pip install --find-links=wheels/ ".[plot]"
%matplotlib inline

import time

import quasar
from qiskit.circuit.random import random_circuit

We use CROTGateSet and the same small random circuit throughout:

gateset = quasar.CROTGateSet(n_qubits=3)
qc = random_circuit(num_qubits=3, depth=6, seed=2)
qc.draw(output="mpl");
../../_images/d6dd9c5a34db42c88270b8276fbb4d09d167b76478a0c200e553b5c7fe5b3f48.png

The strategies

Every synthesis strategy fits native gates to the circuit’s unitary. They differ in how they grow the circuit ansatz.

Each compiled circuit below is drawn at the same scale, so a longer circuit takes more rows.

Baseline: Qiskit routing

QiskitConfig routes and translates the circuit with Qiskit’s transpiler, then lowers it to native rotations. It is the baseline the synthesis strategies below aim to beat.

config_qiskit = quasar.QiskitConfig(optimization_level=3, seed=1)
compiler_qiskit = quasar.GateCompiler(gateset, config=config_qiskit)
qc_qiskit = compiler_qiskit.compile(qc)
qc_qiskit.draw(output="mpl")
dict(qc_qiskit.count_ops())
{'rz': 55, 'r': 55, 'crot': 26}
../../_images/2e86c5fa836943586dbcac9aa82d005cc15e42c3ea970c2759f0dab5e126660c.png

compilation_error compares a compiled circuit with its source. It is one minus the average gate fidelity, so 0 is a perfect match:

compiler_qiskit.compilation_error(qc, qc_qiskit)
9.992007221626409e-16

Adaptive

The adaptive strategy adds entanglers until the compilation error falls below tolerance_error. A small tolerance (here 1e-8) makes the result numerically exact. If no depth up to the bound level_max meets the tolerance, the compile raises ConvergenceError.

config_adaptive = quasar.AdaptiveConfig(tolerance_error=1e-8, seed=1)
compiler_adaptive = quasar.GateCompiler(gateset, config=config_adaptive)
qc_adaptive = compiler_adaptive.compile(qc)
qc_adaptive.draw(output="mpl")
dict(qc_adaptive.count_ops())
{'rz': 25, 'r': 15, 'crot': 11}
../../_images/80aeb0d5adc766b55460fe155556d024bfea6c12cf645f10e801775284de0a9c.png

Fewer than half the crots of the baseline, and still exact:

compiler_adaptive.compilation_error(qc, qc_adaptive)
9.650458210330726e-11

Error-aware

On a real device every gate adds its own error, so an exact circuit is not always the most accurate one. The error-aware strategies take the errors of the device’s gates and return the circuit whose overall error is smallest. The overall error combines the compilation error with the error the circuit’s gates are expected to add, the physical error.

The gate errors belong to the device, so the gate set carries them (see the Gate Sets tutorial). We use a large crot error here, 5 % per gate, to make the effect easy to see. A real device’s two-qubit error is usually well under 1 %, and the result is then much closer to the exact one.

gateset_priced = quasar.CROTGateSet(n_qubits=3, single_qubit_gate_error=0.001, entangler_gate_error=0.05)
config_error_aware = quasar.ErrorAwareTreeSearchConfig(seed=1)
compiler_error_aware = quasar.GateCompiler(gateset_priced, config=config_error_aware)
qc_error_aware = compiler_error_aware.compile(qc)
qc_error_aware.draw(output="mpl")
dict(qc_error_aware.count_ops())
{'rz': 15, 'r': 15, 'crot': 6}
../../_images/065561651e6293af52acd145ab47b71e192f18fda458ec249603f0c5dfc3bb57.png

estimate_errors gives the three errors of a compiled circuit. The compilation error is not zero: it is the price of the gates left out.

compiler_error_aware.estimate_errors(qc, qc_error_aware)
ErrorEstimate(compilation=0.12041882190315212, physical=0.29882889009150304, overall=0.3787650112372791)

The same estimate for the exact adaptive circuit shows why the error-aware circuit is the better one on this device. The adaptive circuit has no compilation error, but its gates add more:

compiler_error_aware.estimate_errors(qc, qc_adaptive)
ErrorEstimate(compilation=9.650458210330726e-11, physical=0.461569541901389, overall=0.4615695419477819)

The compiled circuit records the overall error at every depth the search tried. plot_error_aware_history draws it and marks the depth it returned:

quasar.plot_error_aware_history(qc_error_aware);
../../_images/c7a025b362886e935641f520c4fa6225d4a1b522435f33ee3291f0e63ddef153.png

When the search stops

The search adds one depth after another. It stops when no_improvement_steps depths in a row bring no lower overall error, two by default, or at its depth bound. The overall error here is lowest at six layers. It rises at seven and eight layers, and the search stops there:

history = qc_error_aware.metadata["error_aware"]["layer_history"]
{step["layers"]: round(step["overall_error"], 3) for step in history}
{0: 0.731,
 1: 0.664,
 2: 0.53,
 3: 0.458,
 4: 0.415,
 5: 0.397,
 6: 0.379,
 7: 0.385,
 8: 0.392}

The history starts at zero layers, the closing rotations alone. The returned circuit still holds at least layer_min layers, one by default; ErrorAwareAdaptiveConfig counts level_min levels instead. When every entangler costs more error than it removes, zero layers has the lowest overall error, and layer_min=0 lets the search return that circuit.

A PlateauWarning means something else: no depth the search tried fitted the target better than the shallowest one. A larger depth bound, or more random starting points (n_trials_cold), may help.

All four crot counts side by side:

{
    "qiskit": qc_qiskit.count_ops()["crot"],
    "adaptive": qc_adaptive.count_ops()["crot"],
    "tree-search (tolerance 0.1)": qc_tree.count_ops()["crot"],
    "error-aware": qc_error_aware.count_ops()["crot"],
}
{'qiskit': 26,
 'adaptive': 11,
 'tree-search (tolerance 0.1)': 7,
 'error-aware': 6}

Shortening the circuit

Two passes run after the search. Both make the circuit shorter, and both keep a change only while the circuit still meets its target.

Compression

The adaptive strategies add a whole level at a time, and a level can carry layers the target does not need. Compression removes one layer at a time and refits the rest. AdaptiveConfig keeps a removal while the circuit still meets tolerance_error. By default the gate set decides whether to compress, and every built-in gate set does. compress=False turns it off:

config_uncompressed = quasar.AdaptiveConfig(tolerance_error=1e-8, seed=1, compress=False)
qc_uncompressed = quasar.GateCompiler(gateset, config=config_uncompressed).compile(qc)
{
    "compressed": qc_adaptive.count_ops()["crot"],
    "uncompressed": qc_uncompressed.count_ops()["crot"],
}
{'compressed': 11, 'uncompressed': 12}

ErrorAwareAdaptiveConfig compresses by the overall error. It keeps a removal when the overall error falls, even if the compilation error grows. The compiled circuit’s metadata records the layer count before and after compression:

config_compressing = quasar.ErrorAwareAdaptiveConfig(seed=1)
compiler_compressing = quasar.GateCompiler(gateset_priced, config=config_compressing)
record = compiler_compressing.compile(qc).metadata["error_aware"]
{"layers grown": record["compressed_from_layers"], "layers left": record["layers"]}
{'layers grown': 8, 'layers left': 6}

Rotation rounding

The search leaves each rotation angle where the fit put it, and a rotation that is almost nothing still becomes a gate. Rotation rounding sets angles near chosen values to these values, and refits the rest. A rounding is kept while the compilation error stays within tolerance_error.

round_single_qubit_rotations names the values. By default the gate set decides:

  • CROTGateSet rounds onto \(0\), where a rotation disappears;

  • FixedCROTGateSet rounds onto \(0\) and \(\pi/2\), where a rotation takes one sx instead of two.

() turns rounding off:

config_unrounded = quasar.AdaptiveConfig(
    tolerance_error=1e-8, seed=1, round_single_qubit_rotations=()
)
qc_unrounded = quasar.GateCompiler(gateset, config=config_unrounded).compile(qc)
{"rounded": qc_adaptive.count_ops()["r"], "unrounded": qc_unrounded.count_ops()["r"]}
{'rounded': 15, 'unrounded': 25}

Rounding saves ten r gates here. Each rounded angle costs a refit, so () can make a compile faster where the gate count matters less.

Larger registers

A larger register makes every compile slower, and the tree-search strategies slow down the most. ErrorAwareAdaptiveConfig keeps a 4-qubit compile practical:

gateset_large = quasar.CROTGateSet(n_qubits=4, single_qubit_gate_error=0.001, entangler_gate_error=0.02)
qc_large = random_circuit(num_qubits=4, depth=6, seed=7)

config_large = quasar.ErrorAwareAdaptiveConfig(seed=1)
compiler_large = quasar.GateCompiler(gateset_large, config=config_large)
start = time.perf_counter()
qc_large_compiled = compiler_large.compile(qc_large)
compile_time = time.perf_counter() - start

estimate_large = compiler_large.estimate_errors(qc_large, qc_large_compiled)
{"crot": qc_large_compiled.count_ops()["crot"], "overall error": estimate_large.overall, "compile time (s)": round(compile_time)}
{'crot': 11, 'overall error': 0.270311471017881, 'compile time (s)': 240}

The same configuration compiles 5-qubit circuits as well. A 5-qubit compile takes several times longer than this one.

Which configuration to choose:

you want

use

an exact circuit, quickly

AdaptiveConfig

an exact circuit, perhaps shorter, and can wait longer

TreeSearchConfig

the most accurate circuit for given gate errors

ErrorAwareAdaptiveConfig

the same, on a small register, perhaps shorter

ErrorAwareTreeSearchConfig (much slower)

a routed circuit, without synthesis

QiskitConfig

The Gate-level compilation section explains the strategies, and the API reference lists every option of each configuration.

Where to go next

  • Pulse Compilers tutorial — lowering a compiled circuit further, to hardware pulses.

  • Qubit Layout tutorial — letting the compiler choose which qubit sits where.

  • State Preparation and Pauli Readout tutorial — compiling a state preparation, a readout and a reset.

  • The API reference for the full surface.