Qubit Layout
The gate-level compiler decides which physical qubit of the device holds each logical qubit of
the circuit. This assignment is the circuit’s layout. On a star-topology device, only the
central qubit 0 shares an entangler with the others, and the outer qubits share none with each
other. The layout therefore changes how many entanglers a circuit needs. The compiler can search for
a layout that needs fewer entanglers.
You will see:
how a layout search shortens a circuit;
how to read the chosen layout, and how to compute the compilation error of a circuit that carries one;
the layout methods, and what each one searches;
how a layout is chosen by error or by duration instead of by gate count.
The Gate Compilers tutorial introduces the configurations used here.
Setup
This notebook needs quasar and the plotting extra:
pip install --find-links=wheels/ ".[plot]"
%matplotlib inline
import matplotlib.pyplot as plt
import quasar
from qiskit import QuantumCircuit
from qiskit.circuit.random import random_circuit
from qiskit.quantum_info import Operator, average_gate_fidelity
A shorter circuit by relabeling
The example circuit entangles qubits 0 and 1, then swaps qubits 1 and 2. On the star,
qubits 1 and 2 are not connected, so the swap is expensive when every qubit stays in place:
gateset = quasar.CROTGateSet(n_qubits=3)
qc = QuantumCircuit(3)
qc.h(0)
qc.cx(0, 1)
qc.swap(1, 2)
qc.draw(output="mpl");
Compiled with every qubit in place, it needs six crot gates:
config_trivial = quasar.AdaptiveConfig(tolerance_error=1e-8, seed=1)
compiler_trivial = quasar.GateCompiler(gateset, config=config_trivial)
qc_trivial = compiler_trivial.compile(qc)
qc_trivial.draw(output="mpl")
qc_trivial.count_ops()["crot"]
6
layout_method="greedy_search" lets the compiler choose the layout at the input and at the
output. The swap then becomes a relabeling of the output, which costs no gates:
The layout of a compiled circuit
The compiled circuit records the chosen layout on .layout, a Qiskit TranspileLayout. The
initial layout says which physical qubit holds each logical qubit at the input, and the
final layout says the same at the output. Both are in logical -> physical order.
{
"initial (input)": qc_greedy.layout.initial_index_layout(),
"final (output)": qc_greedy.layout.final_index_layout(),
}
{'initial (input)': [1, 0, 2], 'final (output)': [1, 2, 0]}
The gates alone put the logical qubits on other physical qubits, as the layout records.
compilation_error undoes that relabeling before it compares, so it reports the correct
error:
compiler_greedy.compilation_error(qc, qc_greedy)
1.9877433032888803e-12
Comparing the two circuits’ unitaries directly, without the layout, reports a large error, even though the compile above is exact:
1 - average_gate_fidelity(Operator(qc), Operator(qc_greedy))
0.8888888888888826
The layout methods
A layout search compiles the circuit for several candidate layouts and keeps the best one. The methods differ in which candidates they try:
"trivial"(the default) tries no other layout. Logical qubitistays on physical qubiti, at the input and at the output."diagonal_search"tries the layouts that are the same at the input and at the output. The compiled circuit then needs no relabeling afterwards. On a star, the choice is mostly which logical qubit sits on the central qubit."greedy_search"chooses in two rounds. First it chooses the input layout, with the output layout equal to it. Then it keeps that input layout and chooses the output layout. It needs fewer compiles than trying every pair, but it can miss the best pair."symmetric_search"considers every pair of input and output layouts, like"full_search", but uses the symmetry of the topology to compile fewer of them."full_search"compiles the circuit once for every pair of input and output layouts.
How many times each method compiles the circuit:
|
3 qubits |
4 qubits |
|---|---|---|
|
1 |
1 |
|
3 |
4 |
|
12 |
48 |
|
18 |
96 |
|
36 |
576 |
The swap in the example circuit disappears only through a different layout at the output, which
"diagonal_search" does not try. It still saves entanglers:
config_diagonal = quasar.AdaptiveConfig(tolerance_error=1e-8, seed=1, layout_method="diagonal_search")
compiler_diagonal = quasar.GateCompiler(gateset, config=config_diagonal)
qc_diagonal = compiler_diagonal.compile(qc)
qc_diagonal.draw(output="mpl")
{"crot": qc_diagonal.count_ops()["crot"], "layout": qc_diagonal.layout.initial_index_layout()}
Choosing a layout by error or by duration
By default a search keeps the layout with the fewest entanglers. When the gate set carries
gate errors or durations (see the Gate Sets tutorial), layout_objective can rank the
layouts by those instead:
"gate_count"(default): the fewest entanglers;"error": the lowest overall error;"duration": the shortest run time.
The gate set below has two uneven edges: edge (0, 1) is accurate but slow, edge (0, 2) is
fast but noisy. Durations are in microseconds.
gateset_uneven = quasar.CROTGateSet(
n_qubits=3,
single_qubit_gate_error=0.001,
single_qubit_gate_duration=0.1,
gate_errors={("crot", (0, 1)): 0.005, ("crot", (0, 2)): 0.03},
gate_durations={("crot", (0, 1)): 4.0, ("crot", (0, 2)): 1.0},
)
{
edge: {
"crot error": gateset_uneven.gate_error(("crot", edge)),
"crot duration": gateset_uneven.gate_duration(("crot", edge)),
}
for edge in [(0, 1), (0, 2)]
}
{(0, 1): {'crot error': 0.005, 'crot duration': 4.0},
(0, 2): {'crot error': 0.03, 'crot duration': 1.0}}
The circuit is a random 3-qubit circuit:
qc_random = random_circuit(num_qubits=3, depth=5, seed=21)
qc_random.draw(output="mpl");
Its three-qubit gates act on the whole register, so the compiled circuit needs crot gates on
both edges. The layout decides how many of them fall on each edge.
We compile the circuit once with each objective. The figure shows, for each compile, the crot
gates on each edge, the physical error and the duration. Every gate of CROTGateSet reserves the
central qubit, so the gates run one after another, and the duration is the sum of the gate durations.
compiled_by_objective = {}
summary = {}
for objective in ["gate_count", "error", "duration"]:
config = quasar.AdaptiveConfig(
tolerance_error=1e-8, seed=1, layout_method="diagonal_search", layout_objective=objective
)
compiler = quasar.GateCompiler(gateset_uneven, config=config)
compiled = compiler.compile(qc_random)
compiled_by_objective[objective] = compiled
gates = [
(instruction.operation.name, tuple(compiled.find_bit(q).index for q in instruction.qubits))
for instruction in compiled.data
]
summary[objective] = {
"crot on (0, 1)": gates.count(("crot", (0, 1))),
"crot on (0, 2)": gates.count(("crot", (0, 2))),
"physical error": round(compiler.estimate_errors(qc_random, compiled).physical, 3),
"duration": round(sum(gateset_uneven.gate_duration(gate) for gate in gates), 1),
}
objectives = list(summary)
on_accurate = [summary[objective]["crot on (0, 1)"] for objective in objectives]
on_fast = [summary[objective]["crot on (0, 2)"] for objective in objectives]
errors = [summary[objective]["physical error"] for objective in objectives]
durations = [summary[objective]["duration"] for objective in objectives]
fig, (ax_crot, ax_error, ax_duration) = plt.subplots(1, 3, figsize=(11, 3.4))
bars = ax_crot.bar(objectives, on_accurate, color="tab:blue", label="edge (0, 1)")
ax_crot.bar_label(bars, label_type="center", color="white")
bars = ax_crot.bar(objectives, on_fast, bottom=on_accurate, color="tab:orange", label="edge (0, 2)")
ax_crot.bar_label(bars, label_type="center", color="white")
ax_crot.set_ylim(0, 1.4 * max(a + b for a, b in zip(on_accurate, on_fast)))
ax_crot.set_ylabel("crot gates")
ax_crot.legend(loc="upper center", ncol=2)
bars = ax_error.bar(objectives, errors, color="tab:gray")
ax_error.bar_label(bars, fmt="%.3f")
ax_error.set_ylim(0, 1.2 * max(errors))
ax_error.set_ylabel("physical error")
bars = ax_duration.bar(objectives, durations, color="tab:gray")
ax_duration.bar_label(bars, fmt="%.1f")
ax_duration.set_ylim(0, 1.2 * max(durations))
ax_duration.set_ylabel("duration (µs)")
for ax in (ax_crot, ax_error, ax_duration):
ax.set_xlabel("optimized by")
fig.tight_layout()
All three objectives return the same number of crot gates. They differ in how the gates are
split between the two edges:
"gate_count"counts only the gates, so it has no reason to prefer an edge. Here it splits them four and four."error"puts five on the accurate edge(0, 1). The physical error drops from 0.163 to 0.140, and the circuit runs 24.7 µs instead of 21.6 µs."duration"puts five on the fast edge(0, 2). The circuit runs 18.7 µs, and the physical error rises to 0.186.
Ranked by gate count, four crot gates sit on each edge:
compiled_by_objective["gate_count"].draw(output="mpl");
Ranked by error, five of the crot gates sit on edge (0, 1):
compiled_by_objective["error"].draw(output="mpl");
Ranked by duration, five of them sit on edge (0, 2):
compiled_by_objective["duration"].draw(output="mpl");
A duration search also records the run time of the circuit it returns, the same 18.7 µs:
compiled_by_objective["duration"].metadata["duration"]
18.700000000000003
layout_objective belongs to AdaptiveConfig and TreeSearchConfig. The error-aware
configurations always rank layouts by the overall error.
Where to go next
State Preparation and Pauli Readout tutorial — compiling a state preparation, a readout and a reset.
Gate Compilers tutorial — the strategies these searches run on.
The Gate-level compilation section for what each layout method costs.