Zum Hauptinhalt springen

Transpilationsoptimierung mit SABRE

Geschätzte Nutzungsdauer: 1 Minute auf einem Heron-r2-Prozessor (HINWEIS: Dies ist nur eine Schätzung. Deine tatsächliche Laufzeit kann abweichen.)

Lernziele

Nach diesem Tutorial solltest du verstehen:

  • Wie du SABRE-Parameter (layout_trials, swap_trials, max_iterations) konfigurierst, um die Transpilationsqualität zu verbessern
  • Die Abwägungen zwischen Transpilationslaufzeit und Schaltkreisqualität (Tiefe und Gate-Anzahl)
  • Wie du die SABRE-Routing-Heuristik (basic, decay, lookahead) anpasst und ihre Leistung auf Hardware vergleichst

Voraussetzungen

Wir empfehlen, dass du mit folgenden Themen vertraut bist, bevor du dieses Tutorial durcharbeitest:

Hintergrund

Transpilation wandelt Quantenschaltkreise in Formen um, die mit spezifischer Quantenhardware kompatibel sind. Zwei zentrale Phasen sind die Wahl eines Qubit-Layouts (Zuordnung logischer Qubits zu physischen Qubits) und das Gate-Routing (Einfügen von SWAP-Gates, damit Multi-Qubit-Gates die Gerätekonnektivität einhalten).

SABRE (SWAP-Based Bidirectional heuristic search algorithm) optimiert sowohl Layout als auch Routing. Es ist besonders effektiv für großskalige Schaltkreise (100+ Qubits) auf Geräten mit komplexen Kopplungskarten, wie den IBM® Heron-Prozessoren. SABRE minimiert SWAP-Gates und reduziert die Schaltkreistiefe, was die Ausführungstreue verbessert. Neuere Verbesserungen im LightSABRE-Algorithmus reduzieren Laufzeiten und Gate-Anzahlen weiter.

In diesem Tutorial konfigurierst du zunächst SabreLayout mit verschiedenen Parametern, um einen kleinen GHZ-Schaltkreis zu optimieren und den Einfluss auf die Ausführungstreue zu beobachten. Anschließend vergleichst du SABREs Routing-Heuristiken im großen Maßstab auf echter Hardware.

Anforderungen

Bevor du dieses Tutorial startest, stelle sicher, dass Folgendes installiert ist:

  • Qiskit SDK v2.0 oder höher, mit Visualisierungs-Unterstützung
  • Qiskit Runtime v0.22 oder höher (pip install qiskit-ibm-runtime)
  • Qiskit Aer (pip install qiskit-aer)

Einrichtung

# Added by doQumentation — required packages for this notebook
!pip install -q matplotlib numpy qiskit qiskit-aer qiskit-ibm-runtime
from qiskit import QuantumCircuit
from qiskit.quantum_info import SparsePauliOp
from qiskit_ibm_runtime import QiskitRuntimeService
from qiskit_ibm_runtime import EstimatorOptions
from qiskit_ibm_runtime import EstimatorV2 as Estimator
from qiskit_aer.primitives import EstimatorV2 as AerEstimator
from qiskit.transpiler.passes import (
SabreLayout,
SabreSwap,
BarrierBeforeFinalMeasurements,
StarPreRouting,
)
from qiskit.transpiler.passes.layout.vf2_layout import VF2LayoutStopReason
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
from qiskit.passmanager.flow_controllers import ConditionalController
import matplotlib.pyplot as plt
import numpy as np
import time

seed = 42

service = QiskitRuntimeService(
channel="ibm_cloud",
token="<YOUR_API_TOKEN>", # Replace with your actual API token
instance="<YOUR_INSTANCE_NAME>", # Replace with your instance name if needed
)
backend = service.least_busy(operational=True, simulator=False)

print(f"Using backend: {backend.name}")
Using backend: ibm_kingston

Kleinskaliges Simulator-Beispiel

In diesem Abschnitt wird ein Rauschsimulator auf Basis des Rauschmodells des echten Backends verwendet, um zu zeigen, wie verschiedene SabreLayout-Konfigurationen sowohl die Transpilationsqualität als auch die Ausführungstreue beeinflussen. Mit qiskit_aer und einem Rauschmodell aus echten Hardware-Kalibrierdaten kannst du die Transpilation testen, ohne Hardware-Credits zu verbrauchen.

Schritt 1: Klassische Eingaben auf ein Quantenproblem abbilden

Wir konstruieren einen GHZ-Schaltkreis mit Stern-Topologie mit 15 Qubits. Das erste Qubit ist der Hub, CNOT-Gates verbinden es direkt mit jedem anderen Qubit. Diese Topologie stellt ein anspruchsvolles Layout-Problem dar, weil sie sich nicht trivial auf die Kopplungskarte des Geräts abbilden lässt.

Wir definieren außerdem ZZ-Operatoren, um Verschränkungskorrelationen Z0Zi\langle Z_0 Z_i \rangle über Qubit-Paare hinweg zu messen.

ghz_star_topology.png

Wenn du die Schaltkreisstruktur kennst

SABRE ist ein Allzweckalgorithmus und macht keine Annahmen über die Schaltkreisstruktur. Für diesen GHZ-Schaltkreis mit Stern-Topologie ist ein optimales Routing tatsächlich bekannt: Der StarPreRouting-Pass erkennt Stern-Teilschaltkreise und schreibt sie in eine lineare Kette um, die sich direkt auf jedes Backend mit einem ausreichend langen linearen Pfad abbilden lässt. Dieses Tutorial konzentriert sich auf SABRE, weil es für beliebige Schaltkreise funktioniert. Wenn du jedoch weißt, dass dein Schaltkreis eine klare Sonderstruktur hat, kann das Anwenden eines spezialisierten Passes wie StarPreRouting vor dem Routing jede heuristische Suche übertreffen.

num_qubits_sim = 15

# Create star-topology GHZ circuit
qc_sim = QuantumCircuit(num_qubits_sim)
qc_sim.h(0)
for i in range(1, num_qubits_sim):
qc_sim.cx(0, i)
qc_sim.measure_all()

# ZZ operators: Z on qubit 0 and qubit i, identity elsewhere
operator_strings_sim = [
"Z" + "I" * i + "Z" + "I" * (num_qubits_sim - 2 - i)
for i in range(num_qubits_sim - 1)
]
operators_sim = [SparsePauliOp(op) for op in operator_strings_sim]

Schritt 2: Problem für die Ausführung auf Quantenhardware optimieren

Der Standard-optimization_level=3-Preset-Pass-Manager verwendet bereits SabreLayout, jedoch mit konservativen Standardwerten. Um den Einfluss stärkerer Einstellungen zu untersuchen, wird dieser Pass durch ein benutzerdefiniertes SabreLayout mit aggressiverer Suche ersetzt, während alle anderen Passes der Layout-Phase unverändert bleiben. Als gesonderter Vergleichspunkt behält ein vierter Pass-Manager das Standard-SabreLayout, fügt aber StarPreRouting zur Init-Phase hinzu. StarPreRouting ist ein strukturbewusster Pass, der Stern-Teilschaltkreise erkennt und sie vor dem Routing in eine lineare Kette umschreibt.

Der Arbeitsablauf ist:

  1. Inspizieren des Standard-Pass-Managers, um zu sehen, wo SabreLayout innerhalb der layout-Phase liegt.
  2. Ersetzen dieses Passes durch eine benutzerdefinierte SabreLayout-Instanz mit PassManager.replace(index, passes=...), und Erstellen der pm_star-Variante mit pm.init += StarPreRouting().
  3. Ausführen aller vier Pass-Manager und Vergleich der Metriken.

Die vier Konfigurationen sind:

KonfigurationBeschreibung
pm_1 (Standard)Standard Level-3 Preset (SabreLayout mit max_iterations=4, layout_trials=20, swap_trials=20)
pm_2Benutzerdefiniertes SabreLayout (max_iterations=4, layout_trials=200, swap_trials=200)
pm_3Benutzerdefiniertes SabreLayout (max_iterations=8, layout_trials=200, swap_trials=200)
pm_starStandard-Preset mit StarPreRouting in der Init-Phase

Wichtige SABRE-Parameter:

  • layout_trials / swap_trials: Steuern, wie viele Kandidatenlayouts und Routing-Lösungen SABRE erkundet. Eine höhere Anzahl an Versuchen bedeutet, dass SABRE einen breiteren Suchraum abtastet, was die Chance erhöht, eine bessere Lösung zu finden.
  • max_iterations: Steuert, wie viele Vorwärts-Rückwärts-Routing-Verfeinerungszyklen SABRE für jeden Kandidaten durchführt. SABRE verbessert das Layout iterativ durch Routing-Feedback, sodass mehr Iterationen zu besseren Verbesserungen führen.

Beides kostet mehr Transpilationszeit, aber die resultierenden Schaltkreise sind kürzer und verwenden weniger Gates, was direkt Dekohärenz und Gate-Fehler auf echter Hardware reduziert.

Schritt 2a: Standard-Pass-Manager inspizieren. Ein StagedPassManager besteht aus Phasen (init, layout, routing, translation, optimization, scheduling), die jeweils selbst ein PassManager sind. Das Aufrufen von .draw() auf einer Phase rendert ihre Passes als Graph, sodass wir sehen können, wo SabreLayout liegt.

# Build the default pass manager (no modifications yet)
pm_1 = generate_preset_pass_manager(
optimization_level=3, backend=backend, seed_transpiler=seed
)

# Visualize the layout stage to see where SabreLayout sits
pm_1.layout.draw()

Output of the previous code cell

Im obigen Diagramm liegt der SabreLayout-Pass, den wir anpassen möchten, innerhalb des ConditionalController an Position [2] der Layout-Phase. Dieser Controller tut zwei Dinge:

  • Er schaltet SabreLayout so, dass es nur läuft, wenn VF2Layout bei [1] kein perfektes Mapping gefunden hat (andernfalls wird das perfekte VF2-Layout beibehalten).
  • Er stellt SabreLayout einen BarrierBeforeFinalMeasurements-Pass voran, der Messungen davor schützt, während SABREs internem Routing umgeordnet zu werden.

Wenn wir einfach replace(index=2, passes=sl_2) verwenden, gehen beide Verhaltensweisen verloren. Um sie beizubehalten, verpacken wir unser benutzerdefiniertes SabreLayout im gleichen ConditionalController (mit derselben Bedingung und der schützenden Barriere) neu, bevor wir es einsetzen.

Schritt 2b: Benutzerdefinierte SabreLayout-Passes erstellen und den Standard ersetzen.

cmap = backend.coupling_map

# Custom SabreLayout passes with more aggressive search
sl_2 = SabreLayout(
coupling_map=cmap,
seed=seed,
max_iterations=4,
layout_trials=200,
swap_trials=200,
)
sl_3 = SabreLayout(
coupling_map=cmap,
seed=seed,
max_iterations=8,
layout_trials=200,
swap_trials=200,
)

# Same condition the preset uses: only run SabreLayout when VF2Layout did not
# find a perfect mapping. This preserves any perfect layout VF2 produced at [1].
def _vf2_match_not_found(property_set):
if property_set["layout"] is None:
return True
return (
property_set["VF2Layout_stop_reason"] is not None
and property_set["VF2Layout_stop_reason"]
is not VF2LayoutStopReason.SOLUTION_FOUND
)

def wrap_sabre(sabre_pass):
"""Re-wrap a SabreLayout in the original ConditionalController + barrier."""
return ConditionalController(
[
BarrierBeforeFinalMeasurements(
"qiskit.transpiler.internal.routing.protection.barrier"
),
sabre_pass,
],
condition=_vf2_match_not_found,
)

# Build two fresh pass managers and swap in the wrapped custom SabreLayout at index 2
pm_2 = generate_preset_pass_manager(
optimization_level=3, backend=backend, seed_transpiler=seed
)
pm_3 = generate_preset_pass_manager(
optimization_level=3, backend=backend, seed_transpiler=seed
)
pm_2.layout.replace(index=2, passes=wrap_sabre(sl_2))
pm_3.layout.replace(index=2, passes=wrap_sabre(sl_3))

# Build pm_star: default preset with StarPreRouting added to the init stage
pm_star = generate_preset_pass_manager(
optimization_level=3, backend=backend, seed_transpiler=seed
)
pm_star.init += StarPreRouting()

# Visualize pm_3 after replacement (pm_2 has the same structure, only max_iterations differs)
pm_3.layout.draw()

Output of the previous code cell

Position [2] ist nun wieder ein ConditionalController — in der Form identisch mit dem Standard, aber das innere SabreLayout ist unser benutzerdefiniertes (mit layout_trials=200, swap_trials=200 und max_iterations=8 für pm_3; pm_2 ist bis auf max_iterations=4 identisch). Die schützende Barriere und das _vf2_match_not_found-Gating bleiben erhalten, sodass der einzige Unterschied zwischen pm_2/pm_3 und pm_1 die SABRE-Konfiguration selbst ist. pm_star behält das Standard-SabreLayout und fügt StarPreRouting nur am Ende der Init-Phase hinzu.

Schritt 2c: Jeden Pass-Manager ausführen und vergleichen.

results_sim = {}
for name, pm in [
("pm_1 (4,20,20)", pm_1),
("pm_2 (4,200,200)", pm_2),
("pm_3 (8,200,200)", pm_3),
("pm_star (default + StarPreRouting)", pm_star),
]:
t0 = time.time()
tqc = pm.run(qc_sim)
elapsed = time.time() - t0
depth = tqc.depth(lambda x: x.operation.num_qubits == 2)
size = tqc.size()
ops_mapped = [op.apply_layout(tqc.layout) for op in operators_sim]
results_sim[name] = {
"tqc": tqc,
"ops": ops_mapped,
"depth": depth,
"size": size,
"time": elapsed,
}
print(f"{name}: 2Q Depth {depth}, Size {size}, Time {elapsed:.2f}s")

# Print improvement relative to default (pm_1)
baseline = results_sim["pm_1 (4,20,20)"]
print("\nImprovement vs. default (pm_1):")
for name in [
"pm_2 (4,200,200)",
"pm_3 (8,200,200)",
"pm_star (default + StarPreRouting)",
]:
r = results_sim[name]
depth_pct = (baseline["depth"] - r["depth"]) / baseline["depth"] * 100
size_pct = (baseline["size"] - r["size"]) / baseline["size"] * 100
print(f" {name}: 2Q depth {depth_pct:+.1f}%, size {size_pct:+.1f}%")
pm_1 (4,20,20): 2Q Depth 38, Size 183, Time 0.01s
pm_2 (4,200,200): 2Q Depth 36, Size 183, Time 0.15s
pm_3 (8,200,200): 2Q Depth 30, Size 158, Time 0.16s
pm_star (default + StarPreRouting): 2Q Depth 26, Size 160, Time 0.01s

Improvement vs. default (pm_1):
pm_2 (4,200,200): 2Q depth +5.3%, size +0.0%
pm_3 (8,200,200): 2Q depth +21.1%, size +13.7%
pm_star (default + StarPreRouting): 2Q depth +31.6%, size +12.6%

Alle drei modifizierten Pass-Manager erzeugten Schaltkreise mit geringerer 2Q-Tiefe als der Standard. Die aggressiven SABRE-Konfigurationen (pm_2 und pm_3) tauschen längere Transpilationszeit gegen eine breitere Suche, während pm_star die Stern-Struktur des Schaltkreises nutzt und ein noch flacheres Ergebnis ohne zusätzliche Transpilationskosten erzeugt. Die genauen Gewinne variieren von Lauf zu Lauf, aber der allgemeine Trend ist konsistent: Mehr SABRE-Versuche und Iterationen ermöglichen der Heuristik, einen breiteren Raum zu durchsuchen, und strukturbewusste Passes wie StarPreRouting können diese Suche vollständig umgehen, wenn die Schaltkreisform passt.

Selbst in diesem kleinen Maßstab (15 Qubits) ist der Verbesserungsspielraum groß genug, dass alle drei Ansätze den Standard übertreffen. Mit größeren Schaltkreisen (100+ Qubits) wächst der Suchraum dramatisch, und die Vorteile sowohl erhöhter Versuche als auch strukturbewusster Passes werden wesentlich ausgeprägter, wie der großskalige Abschnitt zeigen wird.

pm_names = list(results_sim.keys())
depths = [results_sim[n]["depth"] for n in pm_names]
sizes = [results_sim[n]["size"] for n in pm_names]
times = [results_sim[n]["time"] for n in pm_names]
colors = ["#404080", "#2a9d8f", "#a8d05e", "#e29bdd"]
x = np.arange(len(pm_names))

fig, axs = plt.subplots(1, 3, figsize=(14, 5))

# 2Q Depth
bars = axs[0].bar(x, depths, color=colors)
axs[0].set_ylabel("2Q Depth", fontsize=11)
axs[0].set_title("Two-Qubit Gate Depth", fontsize=13)
axs[0].set_ylim(0, max(depths) * 1.2)
for bar, val in zip(bars, depths):
axs[0].text(
bar.get_x() + bar.get_width() / 2,
bar.get_height() + max(depths) * 0.02,
str(val),
ha="center",
va="bottom",
fontsize=11,
fontweight="bold",
)
for i in range(1, len(depths)):
pct = (depths[0] - depths[i]) / depths[0] * 100
if pct != 0:
axs[0].text(
bars[i].get_x() + bars[i].get_width() / 2,
bars[i].get_height() / 2,
f"{pct:+.0f}%",
ha="center",
va="center",
fontsize=10,
color="white",
fontweight="bold",
)

# Size
bars = axs[1].bar(x, sizes, color=colors)
axs[1].set_ylabel("Gate Count", fontsize=11)
axs[1].set_title("Circuit Size", fontsize=13)
axs[1].set_ylim(0, max(sizes) * 1.2)
for bar, val in zip(bars, sizes):
axs[1].text(
bar.get_x() + bar.get_width() / 2,
bar.get_height() + max(sizes) * 0.02,
str(val),
ha="center",
va="bottom",
fontsize=11,
fontweight="bold",
)
for i in range(1, len(sizes)):
pct = (sizes[0] - sizes[i]) / sizes[0] * 100
if abs(pct) > 0.1:
axs[1].text(
bars[i].get_x() + bars[i].get_width() / 2,
bars[i].get_height() / 2,
f"{pct:+.0f}%",
ha="center",
va="center",
fontsize=10,
color="white",
fontweight="bold",
)

# Time
bars = axs[2].bar(x, times, color=colors)
axs[2].set_ylabel("Time (s)", fontsize=11)
axs[2].set_title("Transpilation Time", fontsize=13)
axs[2].set_ylim(0, max(times) * 1.3)
for bar, val in zip(bars, times):
axs[2].text(
bar.get_x() + bar.get_width() / 2,
bar.get_height() + max(times) * 0.03,
f"{val:.2f}s",
ha="center",
va="bottom",
fontsize=11,
fontweight="bold",
)

for ax in axs:
ax.set_xticks(x)
ax.set_xticklabels(pm_names, fontsize=8, rotation=15)
ax.grid(axis="y", linestyle="--", alpha=0.5)

plt.suptitle(
"Transpilation quality vs. configuration",
fontsize=14,
fontweight="bold",
y=1.02,
)
plt.tight_layout()
plt.show()

Output of the previous code cell

Schritt 3: Ausführung mit Qiskit-Primitiven

Wir führen jeden transpilierten Schaltkreis 10 Mal mit dem Aer EstimatorV2 und einem Rauschmodell des echten Backends aus. Da verrauschte Simulationsergebnisse zwischen Läufen variieren, liefert das Mitteln über mehrere Läufe zuverlässigere Treue-Schätzungen und ermöglicht es, die statistische Unsicherheit mit Fehlerbalken zu quantifizieren.

# Create a noisy estimator from the real backend's noise model
noisy_estimator = AerEstimator.from_backend(backend)

num_runs = 10
# sim_all_runs[name] = list of arrays, one per run
sim_all_runs = {name: [] for name in results_sim}

for run in range(num_runs):
for name, r in results_sim.items():
job = noisy_estimator.run([(r["tqc"], r["ops"])])
evs = list(job.result()[0].data.evs)
sim_all_runs[name].append(evs)
print(f"Run {run + 1}/{num_runs} done")

# Compute mean and std across runs for each config
sim_stats = {}
for name in results_sim:
all_evs = np.array(sim_all_runs[name]) # shape (num_runs, num_operators)
sim_stats[name] = {
"mean": np.mean(all_evs, axis=0),
"std": np.std(all_evs, axis=0),
"overall_mean": np.mean(all_evs),
"overall_std": np.std(
np.mean(all_evs, axis=1)
), # std of per-run averages
}
print(
f"{name}: mean fidelity = {sim_stats[name]['overall_mean']:.4f} +/- {sim_stats[name]['overall_std']:.4f}"
)
Run 1/10 done
Run 2/10 done
Run 3/10 done
Run 4/10 done
Run 5/10 done
Run 6/10 done
Run 7/10 done
Run 8/10 done
Run 9/10 done
Run 10/10 done
pm_1 (4,20,20): mean fidelity = 0.9510 +/- 0.0094
pm_2 (4,200,200): mean fidelity = 0.9513 +/- 0.0043
pm_3 (8,200,200): mean fidelity = 0.9540 +/- 0.0065
pm_star (default + StarPreRouting): mean fidelity = 0.9547 +/- 0.0072

Da es sich um einen kleinen Schaltkreis handelt, liegen die Treuewerte bei allen vier Konfigurationen relativ nah beieinander. Die Schaltkreise sind kurz genug, dass Hardware-Rauschen selbst die am wenigsten optimierte Version nicht stark bestraft. Die mittlere Treue korreliert grob mit der 2Q-Tiefe: pm_3 und pm_star, die beiden flachsten Schaltkreise, erreichen die höchsten Treuewerte und liegen im Rahmen ihrer Fehlerbalken im Wesentlichen gleichauf. pm_2 ist ein nützliches Gegenbeispiel: Obwohl seine 2Q-Tiefe geringer ist als die von pm_1, ist seine mittlere Treue marginal geringer, was daran erinnert, dass der Zusammenhang zwischen Tiefe und Treue statistisch und nicht deterministisch ist. Die spezifischen Qubits, die ein Layout auswählt, und deren Kalibrierung zur Laufzeit spielen ebenfalls eine Rolle.

Schritt 4: Nachbearbeitung und Rückgabe des Ergebnisses im gewünschten klassischen Format

Als Nächstes werden die Verschränkungskorrelationen Z0Zi\langle Z_0 Z_i \rangle als Funktion des Qubit-Abstands sowie die mittlere Korrelation als einzelne Treuemetrik dargestellt. Im idealen (rauschfreien) Fall wären alle Korrelationen gleich 1. Mit realistischem Rauschen führt jedes zusätzliche Gate zu Fehlern und jeder zusätzliche Zeitschritt ermöglicht Dekohärenz, sodass ein transpilierter Schaltkreis mit geringerer Tiefe und weniger Gates (insbesondere Zwei-Qubit-Gates) die Verschränkung besser bewahren sollte.

data_sim = list(range(1, len(operators_sim) + 1))
markers = ["o", "s", "^", "*"]
colors_line = ["#404080", "#2a9d8f", "#a8d05e", "#e29bdd"]

fig, (ax1, ax2) = plt.subplots(
1, 2, figsize=(14, 5), gridspec_kw={"width_ratios": [2.5, 1]}
)

# Left: correlations vs distance with error bars (mean +/- 1 std)
for (name, stats), marker, color in zip(
sim_stats.items(), markers, colors_line
):
ax1.errorbar(
data_sim,
stats["mean"],
yerr=stats["std"],
marker=marker,
label=name,
color=color,
linewidth=2,
capsize=3,
capthick=1,
elinewidth=1,
)

ax1.set_xlabel("Distance between qubits $i$", fontsize=11)
ax1.set_ylabel(r"$\langle Z_0 Z_i \rangle$", fontsize=11)
ax1.set_title(
"Entanglement correlations vs. qubit distance (avg. of 10 runs)",
fontsize=12,
)
ax1.legend(fontsize=9)
ax1.grid(alpha=0.3)

# Right: mean correlation bar chart with error bars
names = list(sim_stats.keys())
means = [sim_stats[n]["overall_mean"] for n in names]
stds = [sim_stats[n]["overall_std"] for n in names]
x_bar = np.arange(len(names))
bars = ax2.bar(
x_bar, means, yerr=stds, color=colors_line, capsize=5, ecolor="gray"
)
ax2.set_ylabel(r"Mean $\langle Z_0 Z_i \rangle$", fontsize=11)
ax2.set_title("Average fidelity", fontsize=13, pad=12)
y_range = max(means) - min(means) if max(means) != min(means) else 0.01
# Top of ylim accounts for the bar height + std error bar + headroom for the value label
y_top = max(m + s for m, s in zip(means, stds)) + y_range * 1.5
ax2.set_ylim(min(means) - y_range * 0.8, y_top)
for bar, val, std in zip(bars, means, stds):
ax2.text(
bar.get_x() + bar.get_width() / 2,
bar.get_height() + std + y_range * 0.15,
f"{val:.4f}",
ha="center",
va="bottom",
fontsize=10,
fontweight="bold",
)
# Annotate % change vs pm_1
baseline_mean = means[0]
for i in range(1, len(means)):
pct = (means[i] - baseline_mean) / baseline_mean * 100
if abs(pct) > 0.01:
mid_y = (means[i] + ax2.get_ylim()[0]) / 2
ax2.text(
bars[i].get_x() + bars[i].get_width() / 2,
mid_y,
f"{pct:+.1f}%",
ha="center",
va="center",
fontsize=10,
color="white",
fontweight="bold",
)
ax2.set_xticks(x_bar)
ax2.set_xticklabels(names, fontsize=8, rotation=15)
ax2.grid(axis="y", linestyle="--", alpha=0.5)

fig.tight_layout()
plt.show()

Output of the previous code cell

Die Ergebnisse zeigen einen klaren Zusammenhang zwischen Transpilationsqualität und Ausführungstreue, mit einigen nützlichen Vorbehalten:

  • pm_1 (Standard): Ausgangspunkt. Mit nur 20 Versuchen und vier Iterationen hat SABRE begrenzten Spielraum zur Optimierung, was zu den tiefsten der reinen SABRE-Schaltkreise führt.
  • pm_2 (mehr Versuche): Das Erkunden von zehnmal mehr Kandidaten findet ein etwas flacheres Layout, aber die mittlere Treue ist ungefähr gleich (und kann sogar innerhalb des Rauschens unter den Ausgangswert fallen), weil der Tiefengewinn in diesem Maßstab gering ist.
  • pm_3 (mehr Versuche + mehr Iterationen): Die Verdopplung von max_iterations auf 8 gibt SABRE mehr Verfeinerungszyklen, was den flachsten reinen SABRE-Schaltkreis und die höchste mittlere Treue im Vergleich erzeugt.
  • pm_star (Standard + StarPreRouting): Fügt StarPreRouting zur Init-Phase eines ansonsten standardmäßigen Presets hinzu. Das strukturbewusste Umschreiben kollabiert den Stern in eine lineare Kette, die der Rest des Transpilers auf den linearen Pfad des Geräts abbildet, und erzeugt den flachsten Schaltkreis insgesamt (etwas besser als pm_3) und stimmt mit pm_3 bei der Treue innerhalb der Fehlerbalken überein. Dies geschieht mit derselben Transpilationszeit wie der Standard, da das Umschreiben im Vergleich zu SABREs stochastischer Suche im Wesentlichen kostenlos ist.

Beachte, dass die Erhöhung von max_iterations nicht immer positiv ist. In diesem Fall hat es erheblich geholfen, aber für andere Schaltkreise oder Backends können die zusätzlichen Iterationen keine weitere Verbesserung bringen oder die Leistung aufgrund von Überoptimierung eines lokalen Minimums sogar leicht verschlechtern. Im Allgemeinen solltest du layout_trials und swap_trials so weit wie dein Zeitbudget erlaubt erhöhen, da mehr Versuche immer die Chance erhöhen, ein besseres Layout zu finden. Die Erhöhung von max_iterations lohnt sich zum Testen, sollte aber für deinen spezifischen Anwendungsfall validiert werden. Spezialisierte Passes wie StarPreRouting sind ähnlich im Geiste, aber stärker schaltkreisabhängig: Sie helfen nur, wenn der Schaltkreis tatsächlich die Struktur enthält, die sie anvisieren. Der Gewinn ist groß, wenn er anwendbar ist, und null sonst, aber sie kosten im Wesentlichen nichts zu versuchen.

Großskaliges Hardware-Beispiel

Neben der Anpassung der Versuchsanzahl unterstützt SABRE die Anpassung der Routing-Heuristik. SABRE bietet drei Heuristiken:

  • basic: Ein einfacher gieriger Ansatz, der den Swap auswählt, der den unmittelbaren Abstand zum nächsten Gate minimiert.
  • decay (Standard): Gewichtet Qubits dynamisch basierend auf jüngster Aktivität, um wiederholte Swaps auf denselben Qubits zu entmutigen.
  • lookahead: Bewertet zukünftige Routing-Kosten durch einen Blick auf bevorstehende Gates und findet potenziell bessere Swap-Sequenzen.

Um eine benutzerdefinierte Heuristik zu verwenden, erstelle einen SabreSwap-Pass und verbinde ihn mit SabreLayout über den routing_pass-Parameter.

Ein vierter Pass-Manager wird dem Vergleich hinzugefügt: pm_star_hw, der die Standard-SabreLayout/SabreSwap-Einstellungen beibehält, aber StarPreRouting zur Init-Phase hinzufügt. In diesem Maßstab (100 Qubits) ist die SABRE-Suche schwieriger, und das Umschreiben von einem Stern in eine lineare Kette wird zu einem klaren Vorteil, weil ein Heron-Prozessor lineare Pfade hat, die lang genug sind, um den resultierenden Schaltkreis aufzunehmen.

Hier vergleichen wir alle drei SABRE-Heuristiken plus StarPreRouting im großen Maßstab auf einem 100-Qubit-GHZ-Schaltkreis. Wir führen mehrere Layout-Versuche mit verschiedenen Seeds für die SABRE-Konfigurationen durch, wählen den besten transpilierten Schaltkreis aus jedem aus und übermitteln alle neben dem StarPreRouting-Ergebnis an echte Hardware.

Schritte 1-4 in einem einzigen Code-Block zusammengefasst

Hier wird der vollständige Arbeitsablauf in größerem Maßstab zusammengesetzt. Wenn SabreSwap als routing_pass für SabreLayout verwendet wird, wird nur ein Layout-Versuch pro Aufruf durchgeführt, daher iteriert die folgende Code-Zelle über Seeds, um den Layout-Raum zu erkunden.

Wir verwenden den gleichen wrap_sabre-Helfer, der im kleinskaligen Schritt 2 (oben) definiert wurde, und fügen einen analogen wrap_routing-Helfer hinzu, weil die routing-Phase bei Index [1] ebenfalls ein ConditionalController([BarrierBeforeFinalMeasurements, routing_pass], ...) ist — ein direktes Ersetzen würde ebenso die schützende Barriere und das _swap_condition-Gating verlieren.

# -------------------------Step 1-------------------------

num_qubits = 100

# Create star-topology GHZ circuit
qc = QuantumCircuit(num_qubits)
qc.h(0)
for i in range(1, num_qubits):
qc.cx(0, i)
qc.measure_all()

# ZZ operators
operator_strings = [
"Z" + "I" * i + "Z" + "I" * (num_qubits - 2 - i)
for i in range(num_qubits - 1)
]
operators = [SparsePauliOp(op) for op in operator_strings]
# -------------------------Step 2-------------------------

num_seeds = 10
seed_list = [seed + i for i in range(num_seeds)]
swap_trials = 200

# The default routing[1] is a ConditionalController([barrier, routing_pass],
# condition=_swap_condition); we re-wrap so the new routing pass keeps the
# protective barrier and is skipped when routing isn't needed (matches the preset).
def _swap_condition(property_set):
return not property_set["routing_not_needed"]

def wrap_routing(routing_pass):
return ConditionalController(
[
BarrierBeforeFinalMeasurements(
"qiskit.transpiler.internal.routing.protection.barrier"
),
routing_pass,
],
condition=_swap_condition,
)

heuristic_results = {}

# Three SABRE heuristics, swept over seeds
for heuristic in ["basic", "decay", "lookahead"]:
trials = []
for s in seed_list:
sr = SabreSwap(
coupling_map=cmap, heuristic=heuristic, trials=swap_trials, seed=s
)
sl = SabreLayout(coupling_map=cmap, routing_pass=sr, seed=s)
pm = generate_preset_pass_manager(
optimization_level=3, backend=backend, seed_transpiler=s
)
# Re-wrap each custom pass in its original ConditionalController + barrier
# (wrap_sabre is defined in the small-scale Step 2 cell above).
pm.layout.replace(index=2, passes=wrap_sabre(sl))
pm.routing.replace(index=1, passes=wrap_routing(sr))

t0 = time.time()
tqc = pm.run(qc)
elapsed = time.time() - t0
depth = tqc.depth(lambda x: x.operation.num_qubits == 2)
size = tqc.size()
trials.append(
{
"tqc": tqc,
"depth": depth,
"size": size,
"time": elapsed,
"seed": s,
}
)

heuristic_results[heuristic] = trials

# Default preset + StarPreRouting in init, also swept over seeds for a fair comparison
star_trials = []
for s in seed_list:
pm_star_hw = generate_preset_pass_manager(
optimization_level=3, backend=backend, seed_transpiler=s
)
pm_star_hw.init += StarPreRouting()

t0 = time.time()
tqc = pm_star_hw.run(qc)
elapsed = time.time() - t0
depth = tqc.depth(lambda x: x.operation.num_qubits == 2)
size = tqc.size()
star_trials.append(
{
"tqc": tqc,
"depth": depth,
"size": size,
"time": elapsed,
"seed": s,
}
)
heuristic_results["StarPreRouting"] = star_trials

# Print summary for each entry
for label in ["basic", "decay", "lookahead", "StarPreRouting"]:
trials = heuristic_results[label]
depths = [t["depth"] for t in trials]
sizes = [t["size"] for t in trials]
best = min(trials, key=lambda t: t["depth"])
print(f"{label}:")
print(
f" 2Q depth: min: {min(depths)}, mean: {np.mean(depths):.1f}, std: {np.std(depths):.1f}"
)
print(
f" size : min: {min(sizes)}, mean: {np.mean(sizes):.1f}, std: {np.std(sizes):.1f}"
)
print(
f" best seed: {best['seed']} (2Q depth={best['depth']}, size={best['size']})"
)
basic:
2Q depth: min: 524, mean: 570.5, std: 39.9
size : min: 3819, mean: 4227.1, std: 360.6
best seed: 51 (2Q depth=524, size=3852)
decay:
2Q depth: min: 387, mean: 436.4, std: 41.7
size : min: 2687, mean: 3183.1, std: 459.3
best seed: 45 (2Q depth=387, size=2786)
lookahead:
2Q depth: min: 364, mean: 424.6, std: 36.5
size : min: 2335, mean: 3014.6, std: 388.1
best seed: 51 (2Q depth=364, size=2485)
StarPreRouting:
2Q depth: min: 196, mean: 196.0, std: 0.0
size : min: 1151, mean: 1151.0, std: 0.0
best seed: 42 (2Q depth=196, size=1151)
hw_colors = {
"basic": "#ff7f0e",
"decay": "#d62728",
"lookahead": "#1f77b4",
"StarPreRouting": "#2a9d8f",
}

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5))

for label in ["basic", "decay", "lookahead", "StarPreRouting"]:
trials = heuristic_results[label]
depths = [t["depth"] for t in trials]
sizes = [t["size"] for t in trials]
seeds = [t["seed"] for t in trials]
color = hw_colors[label]

ax1.scatter(
seeds,
depths,
label=label,
color=color,
alpha=0.8,
edgecolor="k",
s=60,
)
ax1.axhline(np.mean(depths), color=color, linestyle="--", alpha=0.5)

ax2.scatter(
seeds,
sizes,
label=label,
color=color,
alpha=0.8,
edgecolor="k",
s=60,
)
ax2.axhline(np.mean(sizes), color=color, linestyle="--", alpha=0.5)

ax1.set_xlabel("Seed", fontsize=11)
ax1.set_ylabel("2Q Depth", fontsize=11)
ax1.set_title("Two-Qubit Gate Depth per Seed", fontsize=13)
ax1.legend(fontsize=10)
ax1.grid(alpha=0.3)

ax2.set_xlabel("Seed", fontsize=11)
ax2.set_ylabel("Gate Count", fontsize=11)
ax2.set_title("Circuit Size per Seed", fontsize=13)
ax2.legend(fontsize=10)
ax2.grid(alpha=0.3)

plt.suptitle(
"Transpilation variability across seeds: SABRE heuristics vs. StarPreRouting",
fontsize=14,
fontweight="bold",
y=1.02,
)
plt.tight_layout()
plt.show()

# Summary comparison
for label in ["basic", "decay", "lookahead", "StarPreRouting"]:
best = min(heuristic_results[label], key=lambda t: t["depth"])
print(
f"{label}: best 2Q depth={best['depth']}, size={best['size']} (seed={best['seed']})"
)

Output of the previous code cell

basic: best 2Q depth=524, size=3852 (seed=51)
decay: best 2Q depth=387, size=2786 (seed=45)
lookahead: best 2Q depth=364, size=2485 (seed=51)
StarPreRouting: best 2Q depth=196, size=1151 (seed=42)
# -------------------------Step 3: Execute on hardware-------------------------

best_circuits = {}
for label in ["basic", "decay", "lookahead", "StarPreRouting"]:
best_circuits[label] = min(
heuristic_results[label], key=lambda t: t["depth"]
)
b = best_circuits[label]
print(f"Best {label}: 2Q depth={b['depth']}, size={b['size']}")

options = EstimatorOptions()
options.resilience_level = 2
options.dynamical_decoupling.enable = True
options.dynamical_decoupling.sequence_type = "XY4"
estimator = Estimator(backend, options=options)

hw_jobs = {}
hw_ops = {}
for label, best in best_circuits.items():
hw_ops[label] = [op.apply_layout(best["tqc"].layout) for op in operators]
hw_jobs[label] = estimator.run([(best["tqc"], hw_ops[label])])
print(f"{label} job: {hw_jobs[label].job_id()}")
estimator.options.environment.job_tags = ["TUT_TOWS"]

hw_results = {}
for label, job in hw_jobs.items():
hw_results[label] = job.result()[0]
print(f"{label} job done")
Best basic: 2Q depth=524, size=3852
Best decay: 2Q depth=387, size=2786
Best lookahead: 2Q depth=364, size=2485
Best StarPreRouting: 2Q depth=196, size=1151
basic job: d81q5tnoha1c73bknprg
decay job: d81q5tugbeec73aktopg
lookahead job: d81q5to0bvlc73d1epe0
StarPreRouting job: d81q5u7tjchs73bn82hg
basic job done
decay job done
lookahead job done
StarPreRouting job done
# -------------------------Step 4: Post-process-------------------------

data = list(range(1, len(operators) + 1))
hw_markers = {
"basic": "D",
"decay": "o",
"lookahead": "s",
"StarPreRouting": "*",
}
hw_labels = ["basic", "decay", "lookahead", "StarPreRouting"]

fig, (ax1, ax2) = plt.subplots(
1, 2, figsize=(14, 5), gridspec_kw={"width_ratios": [2.5, 1]}
)

# Left: correlations vs distance
for label in hw_labels:
evs = list(hw_results[label].data.evs)
b = best_circuits[label]
ax1.plot(
data,
evs,
marker=hw_markers[label],
color=hw_colors[label],
linewidth=2,
label=f"{label} (2Q depth={b['depth']}, size={b['size']})",
markersize=5 if label == "StarPreRouting" else 4,
)

ax1.set_xlabel("Distance between qubits $i$", fontsize=11)
ax1.set_ylabel(r"$\langle Z_0 Z_i \rangle$", fontsize=11)
ax1.set_title(
"Entanglement correlations vs. qubit distance (hardware)", fontsize=12
)
ax1.legend(fontsize=9)
ax1.grid(alpha=0.3)

# Right: mean fidelity bar chart
hw_means = [np.mean(list(hw_results[label].data.evs)) for label in hw_labels]
hw_bar_colors = [hw_colors[label] for label in hw_labels]
x_bar = np.arange(len(hw_labels))
bars = ax2.bar(x_bar, hw_means, color=hw_bar_colors)
ax2.set_ylabel(r"Mean $\langle Z_0 Z_i \rangle$", fontsize=11)
ax2.set_title("Average fidelity", fontsize=13)
y_range = (
max(hw_means) - min(hw_means) if max(hw_means) != min(hw_means) else 0.01
)
ax2.set_ylim(min(hw_means) - y_range * 0.2, max(hw_means) + y_range * 0.15)
for bar, val in zip(bars, hw_means):
ax2.text(
bar.get_x() + bar.get_width() / 2,
bar.get_height() + y_range * 0.05,
f"{val:.4f}",
ha="center",
va="bottom",
fontsize=11,
fontweight="bold",
)
ax2.set_xticks(x_bar)
ax2.set_xticklabels(hw_labels, fontsize=9, rotation=15)
ax2.grid(axis="y", linestyle="--", alpha=0.5)

fig.tight_layout()
plt.show()

print("\nMean fidelity:")
for label, m in zip(hw_labels, hw_means):
print(f" {label}: {m:.4f}")

Output of the previous code cell

Mean fidelity:
basic: 0.0344
decay: 0.1298
lookahead: 0.1857
StarPreRouting: 0.3295

Analyse

Die Streudiagramme zeigen eine erhebliche Variabilität über Seeds für alle drei SABRE-Heuristiken, was die Wichtigkeit unterstreicht, mehrere Layout-Versuche durchzuführen, anstatt sich auf eine einzelne Transpilation zu verlassen. Die StarPreRouting-Linie ist über Seeds im Wesentlichen flach, weil das Umschreiben von einem Stern in eine lineare Kette bei gegebener Struktur deterministisch ist; das anschließende SABRE-Routing hat dann auf einer linearen Kette sehr wenig Spielraum, sodass der Seed fast keinen Einfluss auf die endgültige Tiefe oder Größe hat.

Aus den Transpilationsergebnissen übertreffen sowohl die decay- als auch die lookahead-Heuristiken basic konsistent mit einem großen Vorsprung. Die basic-Heuristik, obwohl schnell, verwendet eine einfache gierige Strategie, die oft zu erheblich tieferen Schaltkreisen führt. Für diesen GHZ-Schaltkreis mit Stern-Topologie neigt lookahead dazu, die niedrigste 2Q-Tiefe und Gate-Anzahl unter den SABRE-Heuristiken zu erzeugen, da seine vorausschauende Kostenfunktion gut für Schaltkreise mit weitreichenden Konnektivitätsmustern geeignet ist. StarPreRouting übertrifft jedoch alle drei um einen erheblichen Abstand: Indem es den Stern vor dem Routing in eine lineare Kette umschreibt, umgeht es das Suchproblem vollständig und liefert einen Schaltkreis, den der Rest des Transpilers mit minimalen zusätzlichen SWAPs auf einen linearen Pfad abbilden kann.

Dieser Vorteil überträgt sich direkt auf die Hardware-Treue. Geringere 2Q-Tiefe und Gate-Anzahl übertragen sich nicht immer eins zu eins in höhere Treue (die spezifischen physischen Qubits, die ein Layout verwendet, und deren Kalibrierung zur Laufzeit spielen ebenfalls eine Rolle), aber wenn der Tiefenunterschied so groß ist wie der zwischen SABRE und StarPreRouting hier, gewinnt der strukturbewusste Ansatz entscheidend, weil der Schaltkreis weitaus weniger Dekohärenz und Zwei-Qubit-Fehlerereignisse anhäuft. Das Treuediagramm zeigt StarPreRouting deutlich vor der besten SABRE-Heuristik, während basic weit unter dem Rest liegt, weil seine viel tieferen Schaltkreise die meisten Fehler anhäufen.

Wichtigste Erkenntnisse:

  • Unter den SABRE-Heuristiken sind decay und lookahead für nicht-triviale Schaltkreise erheblich besser als basic. Bevorzuge eine der beiden für Produktions-Workloads.
  • Die beste SABRE-Heuristik hängt von deinem Schaltkreis und der Hardware ab. Das Testen mehrerer Heuristiken mit mehreren Seeds ist die zuverlässigste Strategie.
  • Wenn du noch mehr Layouts erkunden möchtest, erhöhe swap_trials (und layout_trials, wenn du keinen benutzerdefinierten Routing-Pass festlegst), anstatt die Arbeit auf entfernte Knoten zu verteilen. Die SABRE-Passes parallelisieren Versuche bereits über lokale Threads, und die Arbeit pro Versuch ist klein genug, dass der Verteilungsaufwand typischerweise jeden Speedup dominiert.
  • Wenn der Schaltkreis eine bekannte Sonderstruktur hat, kann das Anwenden eines strukturbewussten Passes wie StarPreRouting vor SABRE eine Verbesserung um eine Größenordnung liefern, die keine Menge SABRE-Tuning erreichen wird. Dies ersetzt SABRE nicht: StarPreRouting hilft nur, wenn der Schaltkreis tatsächlich Stern-Teilschaltkreise enthält und das Backend einen ausreichend langen linearen Pfad hat. Es lohnt sich, die Pass-Bibliothek nach Übereinstimmungen zu durchsuchen, wenn du die Form deines Schaltkreises kennst.

Nächste Schritte

Wenn du diese Arbeit interessant fandest, könntest du dich für folgendes Material interessieren:

Empfehlungen

Tutorial-Umfrage

Bitte nimm an dieser kurzen Umfrage teil, um Feedback zu diesem Tutorial zu geben. Deine Erkenntnisse helfen uns, unsere Inhalte und das Nutzererlebnis zu verbessern.

Hinweis: Diese Umfrage stammt von IBM Quantum und betrifft den Tutorial-Inhalt (von IBM verfasst). doQumentation stellt die Website, Übersetzungen und Code-Ausführung bereit — für Feedback dazu bitte ein GitHub-Issue öffnen.

Link zur Umfrage