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");
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}
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}
Fewer than half the crots of the baseline, and still exact:
compiler_adaptive.compilation_error(qc, qc_adaptive)
9.650458210330726e-11
Tree-search
Tree-search tries more circuit structures at each depth. At the same tolerance it can find a
shorter circuit than adaptive, and a compile can take longer. Here the tolerance is looser,
0.1: the search stops at the first depth within it, so it finishes quickly, and it trades
accuracy for fewer gates:
config_tree = quasar.TreeSearchConfig(tolerance_error=0.1, seed=1)
compiler_tree = quasar.GateCompiler(gateset, config=config_tree)
qc_tree = compiler_tree.compile(qc)
qc_tree.draw(output="mpl")
{"crot": qc_tree.count_ops()["crot"], "compilation error": compiler_tree.compilation_error(qc, qc_tree)}
{'crot': 7, 'compilation error': 0.08893167648612677}
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}
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);
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:
CROTGateSetrounds onto \(0\), where a rotation disappears;FixedCROTGateSetrounds onto \(0\) and \(\pi/2\), where a rotation takes onesxinstead 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 |
|
an exact circuit, perhaps shorter, and can wait longer |
|
the most accurate circuit for given gate errors |
|
the same, on a small register, perhaps shorter |
|
a routed circuit, without synthesis |
|
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.