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");
../../_images/78a5ce68a9a6b113be0f0e5b2f131099d7ab0c2dffe104b48081d55727134fb3.png

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
../../_images/bd657836591801d8a0d7fa94ba5a558d098db503291b1cd23a809dfd3ad5f62b.png

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:

config_greedy = quasar.AdaptiveConfig(tolerance_error=1e-8, seed=1, layout_method="greedy_search")
compiler_greedy = quasar.GateCompiler(gateset, config=config_greedy)
qc_greedy = compiler_greedy.compile(qc)
qc_greedy.draw(output="mpl")
qc_greedy.count_ops()["crot"]
1
../../_images/b03c0d7b179112c9a1dd5f9bf4e9cd2c981766229bcbba7c8b416fe474015d1e.png

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 qubit i stays on physical qubit i, 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:

layout_method

3 qubits

4 qubits

"trivial"

1

1

"diagonal_search"

3

4

"greedy_search"

12

48

"symmetric_search"

18

96

"full_search"

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()}
{'crot': 4, 'layout': [1, 0, 2]}
../../_images/15b804f843cd6ad8e83cd173fab1728f01aaad0af4a78bfd4f156ee8cec7f991.png

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");
../../_images/7c7cb3a69d1802bb9f3bb9da7edafe08841dbd1bc8b07b71b8859d4730ddbd0f.png

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()
../../_images/5f635d93c304e2e9280828209d2cdd949717e0a289d6adfc0750fa6e26ea5711.png

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");
../../_images/44d7a69a2ef72f2721fa0f38b8ea8a49d454cd62044f3255a87777fc2cb6fffb.png

Ranked by error, five of the crot gates sit on edge (0, 1):

compiled_by_objective["error"].draw(output="mpl");
../../_images/05af75c63ca00feac53f3dcab5acee13e6f39d58d2bda05a42036b41807c6de5.png

Ranked by duration, five of them sit on edge (0, 2):

compiled_by_objective["duration"].draw(output="mpl");
../../_images/f5c9016d01b57d4df419a9d87d456923ebe5caf10ccd6a3107f60cc03428581c.png

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.