Optimierung der Transpilation mit SABRE
Geschätzte Laufzeit: 1 Minute auf einem Heron- r2 -Prozessor (HINWEIS: Dies ist lediglich eine Schätzung. (Die Laufzeit kann variieren.)
Lernziele
- So konfigurieren Sie die SABRE-Parameter (
layout_trials,swap_trials,max_iterations), um die Transpilationsqualität zu verbessern - Die Abwägung zwischen Transpilationslaufzeit und Schaltungsqualität (Tiefe und Gate-Anzahl)
- So passen Sie die SABRE-Routing-Heuristik (
basic,decay,lookahead) an und vergleichen deren Leistung auf der Hardware
Voraussetzungen
- Transpile-Schaltkreise : Überblick über die Transpilierung in Qiskit
- Transpiler-Phasen : Layout- und Routing-Phasen
- Voreingestellte Pass-Manager konfigurieren : Optimierungsstufen anpassen
Hintergrund
Bei der Transpilation werden Quantenschaltungen in Formen umgewandelt, die mit bestimmter Quantenhardware kompatibel sind. Zwei wichtige Schritte sind die Wahl eines Qubit-Layouts (die Zuordnung logischer Qubits zu physikalischen Qubits) und das Gate-Routing (das Einfügen von SWAP-Gates, damit Multi-Qubit-Gates die Konnektivität des Bauelements berücksichtigen).
SABRE ( SWAP-basierter bidirektionaler heuristischer Suchalgorithmus ) optimiert sowohl das Layout als auch die Verdrahtung. Es ist besonders effektiv für groß angelegte Schaltkreise (100+ Qubits) auf Geräten mit komplexen Kopplungsabbildungen, wie beispielsweise den Heron-Prozessoren von IBM®. SABRE minimiert die Anzahl der SWAP-Gatter und verringert die Schaltungstiefe, wodurch die Ausführungsgenauigkeit verbessert wird. Die jüngsten Verbesserungen am Algorithmus „ LightSABRE “ führen zu einer weiteren Verkürzung der Laufzeiten und einer weiteren Verringerung der Gatteranzahl.
In diesem Tutorial werden Sie zunächst verschiedene Parameter konfigurieren SabreLayout , um eine kleine GHZ-Schaltung zu optimieren, und die Auswirkungen auf die Ausführungsgenauigkeit beobachten. Anschließend werden Sie die Routing-Heuristiken von SABRE in großem Maßstab auf echter Hardware vergleichen.
Anforderungen
Bevor Sie mit diesem Tutorial beginnen, sollten Sie sicherstellen, dass Sie Folgendes installiert haben:
- Qiskit SDK v2.0 oder höher, mit Visual isierungsunterstützung
- Qiskit Runtime v0.22 oder später (
pip install qiskit-ibm-runtime) - Qiskit Aer (
pip install qiskit-aer)
Einrichtung
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}")Output:
Using backend: ibm_kingston
Beispiel für einen Simulator im kleinen Maßstab
In diesem Abschnitt wird ein auf dem Rauschmodell des realen Backends basierender Rauschsimulator verwendet, um zu veranschaulichen, wie sich verschiedene SabreLayout Konfigurationen sowohl auf die Transpilationsqualität als auch auf die Ausführungsgenauigkeit auswirken. Durch qiskit_aer die Verwendung eines Rauschmodells, das auf tatsächlichen Hardware-Kalibrierungsdaten basiert, können Sie die Transpilierung testen, ohne Hardware-Credits zu verbrauchen.
Schritt 1: Klassische Eingaben einem Quantenproblem zuordnen
Wir konstruieren einen GHZ-Schaltkreis mit Sterntopologie und 15 Qubits. Das erste Qubit bildet den Knotenpunkt, wobei es über CNOT-Gatter direkt mit jedem anderen Qubit verbunden ist. Diese Topologie stellt ein anspruchsvolles Layout-Problem dar, da sie sich nicht ohne Weiteres auf die Kopplungskarte des Bauteils abbilden lässt.
Außerdem definieren ZZ wir Operatoren zur Messung von Verschränkungs-Korrelationen zwischen Qubit-Paaren.
SABRE ist ein universeller Algorithmus und stellt keine Annahmen hinsichtlich der Schaltungsstruktur auf. Für diese GHZ-Schaltung mit Sterntopologie ist tatsächlich eine optimale Verlegung bekannt: Der StarPreRouting „Pass“ erkennt Stern-Teilstromkreise und wandelt 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, da es für beliebige Schaltungen funktioniert. Wenn Sie jedoch wissen, dass Ihre Schaltung eine eindeutige spezielle Struktur aufweist, kann die Anwendung eines spezialisierten Durchlaufs wie StarPreRouting vor dem Routing eine bessere Leistung erzielen als jede heuristische Suche.
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: Optimierung des Problems für die Ausführung auf Quantenhardware
SabreLayoutDer standardmäßige optimization_level=3 Pass-Manager verwendet bereits diese Einstellung, allerdings mit konservativen Standardwerten. Um die Auswirkungen strengerer Einstellungen zu untersuchen, wird dieser Durchlauf durch einen benutzerdefinierten SabreLayout ersetzt, der für eine aggressivere Suche konfiguriert ist, während alle anderen Durchläufe in der Layout-Phase unverändert bleiben. Als separaten Vergleichspunkt behält ein „Fourth-Pass-Manager“ die Standardeinstellung SabreLayout bei, fügt StarPreRouting jedoch der Initialisierungsphase etwas hinzu. StarPreRouting ist ein strukturorientierter Durchlauf, der Stern-Teilschaltungen erkennt und diese vor dem Routing in eine lineare Kette umschreibt.
Der Arbeitsablauf sieht wie folgt aus:
- Überprüfen Sie den Standard-Pass-Manager, um festzustellen, wo
SabreLayouter sich innerhalb derlayoutPhase befindet. - Ersetzen Sie diesen Durchlauf durch eine benutzerdefinierte
SabreLayoutInstanz mithilfe vonPassManager.replace(index, passes=...)und erstellen Sie diepm_starVariante mitpm.init += StarPreRouting(). - Führen Sie alle vier Pass-Manager aus und vergleichen Sie die Kennzahlen.
Die vier Konfigurationen sind:
Konfiguration | Beschreibung |
|---|---|
pm_1(Standard) | Standard-Voreinstellung für „ level-3 “ (SabreLayout mit max_iterations=4, layout_trials=20, swap_trials=20) |
pm_2 | Benutzerdefiniert SabreLayout (max_iterations=4, layout_trials=200, swap_trials=200) |
pm_3 | Benutzerdefiniert SabreLayout (max_iterations=8, layout_trials=200, swap_trials=200) |
pm_star | Standardvoreinstellung mit StarPreRouting in der Initialisierungsphase hinzugefügt |
Wichtige SABRE-Parameter:
layout_trials/swap_trials: Legt fest, wie viele mögliche Layouts und Routing-Lösungen SABRE prüft. Durch die Erhöhung der Anzahl der Versuche deckt SABRE einen größeren Suchraum ab, wodurch sich die Chance erhöht, eine bessere Lösung zu finden.max_iterations: Legt fest, wie viele Vorwärts-Rückwärts-Routing-Verfeinerungszyklen SABRE für jeden Kandidaten durchführt. SABRE optimiert das Layout schrittweise, indem es aus dem Routing-Feedback lernt. Je mehr Iterationen durchgeführt werden, desto besser fallen die Verbesserungen aus.
Beides geht zwar mit einer längeren Transpilationszeit einher, doch die daraus resultierenden Schaltungen sind kürzer und verwenden weniger Gatter, was die Dekohärenz und die Gatterfehler auf der tatsächlichen Hardware direkt verringert.
Schritt 2a: Überprüfen Sie den Standard-Passwortmanager. PassManagerA StagedPassManager besteht aus den Teilmengen (init, layout, routing, translation, optimization, scheduling), von denen jede selbst ein ist. Wenn man eine Funktion .draw() auf einer Bühne aufruft, werden ihre Durchläufe als Graph dargestellt, sodass wir sehen können, wo SabreLayout sie sich befindet.
# 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:
In der obigen Abbildung befindet sich der SabreLayout Pass, den ConditionalController wir anpassen möchten, an Position [2] der Layout-Phase. Dieser Controller erfüllt zwei Aufgaben:
- Es wird ein Gate
SabreLayouteingesetzt, sodass es nur dann ausgeführt wird, wennVF2Layoutbei [1] keine perfekte Zuordnung gefunden werden konnte (ansonsten wird das perfekte Layout „ VF2 “ beibehalten). - Dem geht
SabreLayouteinBarrierBeforeFinalMeasurementsDurchlauf voraus, der verhindert, dass Messungen während des internen Routings von „ SabreLayout's “ neu angeordnet werden.
Wenn wir einfach replace(index=2, passes=sl_2), werden beide Verhaltensweisen verworfen. Um sie zu schützen, verpacken wir unser Sonderanfertigung SabreLayout vor dem Einbau erneut in dieselbe ConditionalController Verpackung (unter denselben Bedingungen und mit derselben Schutzschicht).
Schritt 2b: : Erstellen Sie benutzerdefinierte SabreLayout Pässe und ersetzen Sie die Standardpasses.
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:
max_iterations=4Position [2] ist nun wieder ein ConditionalController — in der Form identisch mit der Standardvariante, aber das innere SabreLayout ist unsere benutzerdefinierte Variante (mit layout_trials=200, swap_trials=200, und max_iterations=8 für pm_3; pm_2 ist bis auf identisch). Die Schutzbarriere und die _vf2_match_not_found Gating-Funktion bleiben erhalten, sodass der einzige Unterschied zwischen pm_2/pm_3 und pm_1 in der SABRE-Konfiguration selbst besteht. pm_star behält die Standardeinstellung SabreLayout bei und fügt StarPreRouting lediglich am Ende der Initialisierungsphase hinzu.
Schritt 2c: Führen Sie jeden Pass-Manager aus und vergleichen Sie die Ergebnisse.
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}%")Output:
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 einer geringeren „ 2Q “-Tiefe als die Standardeinstellung. Die aggressiven SABRE-Konfigurationen (pm_2 und pm_3) tauschen eine längere Transpilationszeit gegen eine breitere Suche ein, während pm_star die Sternstruktur der Schaltung nutzt und ein noch flacheres Ergebnis liefert, ohne dass zusätzliche Transpilationskosten anfallen. Die genauen Verbesserungen variieren von Durchlauf zu Durchlauf, doch der allgemeine Trend ist konsistent: Je mehr SABRE-Versuche und Iterationen durchgeführt werden, desto größer ist der Suchraum, den die Heuristik abtasten kann, und strukturbewusste Durchläufe wie StarPreRouting können diese Suche gänzlich umgehen, wenn die Schaltungsform übereinstimmt.
Selbst bei diesem kleinen Maßstab (15 Qubits) ist der Spielraum für Verbesserungen so groß, dass alle drei Ansätze die Standardlösung übertreffen. Bei größeren Schaltkreisen (100+ Qubits) nimmt der Suchraum dramatisch zu, und die Vorteile sowohl einer erhöhten Anzahl von Versuchen als auch strukturorientierter Durchläufe treten deutlich stärker zutage, wie im Abschnitt über groß angelegte Schaltkreise gezeigt 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:
Schritt 3: Mit „ Qiskit primitives “ ausführen
Wir führen jede transpilierte Schaltung 10 Mal mit dem Aer EstimatorV2 aus, wobei wir ein Rauschmodell verwenden, das aus dem tatsächlichen Backend abgeleitet wurde. Da die Simulationsergebnisse von Lauf zu Lauf Schwankungen unterliegen, liefert die Mittelwertbildung über mehrere Läufe zuverlässigere Genauigkeitsschätzungen und ermöglicht es uns, die statistische Unsicherheit mithilfe von 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}"
)Output:
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 eine kleine Schaltung handelt, liegen die Wiedergabetreuewerte bei allen vier Konfigurationen relativ nahe beieinander. Die Schaltkreise sind so kurz, dass Hardware-Rauschen selbst bei der am wenigsten optimierten Version keine nennenswerten Einbußen verursacht. Die mittlere Genauigkeit entspricht im Großen und Ganzen der Tiefe der „ 2Q “: pm_3 und pm_star, die beiden flachsten Schaltkreise, erzielen die höchsten Genauigkeitswerte und liegen innerhalb ihrer Fehlerbänder praktisch gleichauf. pm_2 ist ein nützliches Gegenbeispiel: Obwohl seine „ 2Q “-Tiefe geringer ist als pm_1die von, fällt auch seine mittlere Fidelity geringfügig niedriger aus, was daran erinnert, dass der Zusammenhang zwischen Tiefe und Fidelity eher statistischer als deterministischer Natur ist. Auch die Auswahl der einzelnen Qubits durch ein Layout und deren Kalibrierung zur Laufzeit spielen eine Rolle.
Schritt 4: Nachbearbeitung und Rückgabe des Ergebnisses im gewünschten klassischen Format
Stellen Sie als Nächstes die Verschränkungskorrelationen als Funktion des Qubit-Abstands dar, zusammen mit der mittleren Korrelation als einheitlicher Maßstab für die Fidelity. Im Idealfall (ohne Rauschen) würden alle Korrelationen den Wert 1 annehmen. Bei realistischen Rauschbedingungen führt jedes zusätzliche Gatter zu Fehlern und jeder zusätzliche Zeitschritt ermöglicht Dekohärenz; daher sollte eine transpilierte Schaltung mit geringerer Tiefe und weniger Gattern (insbesondere Zwei-Qubit-Gattern) die Verschränkung besser bewahren.
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:
Die Ergebnisse zeigen einen eindeutigen Zusammenhang zwischen der Qualität der Transpilierung und der Ausführungsgenauigkeit, wobei einige nützliche Einschränkungen zu beachten sind:
pm_1(Standard) : Basislinie. Mit nur 20 Versuchen und vier Iterationen verfügt SABRE nur über begrenzten Optimierungsspielraum, was zu den tiefsten Schaltungen führt, die ausschließlich mit SABRE erzielt werden können.pm_2(weitere Versuche) : Bei der Untersuchung von zehnmal so vielen Kandidaten ergibt sich eine etwas flachere Struktur, doch die mittlere Genauigkeit bleibt in etwa gleich (und kann im Rauschbereich sogar unter den Ausgangswert fallen), da der Tiefengewinn in diesem Maßstab gering ist.pm_3(mehr Versuche + mehr Iterationen) : Durch die Verdopplungmax_iterationsauf 8 erhält SABRE mehr Verfeinerungszyklen, wodurch die flachste reine SABRE-Schaltung und die höchste mittlere Genauigkeit im Vergleich erzielt werden.pm_star(Standard + StarPreRouting ) : FügtStarPreRoutingzur Initialisierungsphase einer ansonsten standardmäßigen Voreinstellung hinzu. Die strukturorientierte Umschreibung reduziert das Stern-Symbol auf eine lineare Kette, die der Transpiler im weiteren Verlauf auf den linearen Pfad des Bausteins abbildet, wodurch insgesamt die flachste Schaltung entsteht (etwas besser alspm_3) und die Treue innerhalb der Fehlergrenzen mit übereinstimmtpm_3. Dies geschieht mit derselben Transpilierungszeit wie bei der Standardeinstellung, da die Umschreibung im Vergleich zur stochastischen Suche von SABRE im Wesentlichen keinen zusätzlichen Aufwand verursacht.
Beachten Sie, dass eine Erhöhung max_iterations von nicht immer positive Auswirkungen hat. In diesem Fall hat es erheblich geholfen, aber bei anderen Schaltkreisen oder Backends führen die zusätzlichen Iterationen möglicherweise nicht zu weiteren Verbesserungen oder beeinträchtigen die Leistung aufgrund einer Überoptimierung eines lokalen Minimums sogar leicht. Generell solltest du und swap_trials so weit wie es dein Zeitbudget zulässt erhöhen layout_trials , da mehr Versuche immer die Chance erhöhen, ein besseres Layout zu finden. Eine Erhöhung max_iterations ist einen Versuch wert, sollte jedoch für Ihren konkreten Anwendungsfall validiert werden. Spezialisierte Pässe wie StarPreRouting verfolgen einen ähnlichen Ansatz, sind jedoch stärker von der Schaltung abhängig: Sie sind nur dann hilfreich, wenn die Schaltung tatsächlich die Struktur enthält, auf die sie abzielen. Der Nutzen ist groß, sofern dies zutrifft, und andernfalls gleich Null, aber ein Versuch kostet im Grunde nichts.
Beispiel für groß angelegte Hardware
Neben der Anpassung der Versuchsanzahl unterstützt SABRE auch die individuelle Anpassung der Routing-Heuristik. SABRE bietet drei Heuristiken:
basic: Ein einfacher „Greedy“-Ansatz, bei dem der Tausch ausgewählt wird, der die unmittelbare Entfernung zum nächsten Tor minimiert.decay(Standard): Die Gewichtung der Qubits erfolgt dynamisch auf der Grundlage der jüngsten Aktivitäten, wodurch wiederholte Austausche bei denselben Qubits vermieden werden.lookahead: Schätzt die zukünftigen Routing-Kosten, indem es die bevorstehenden Gates vorausschauend berücksichtigt und so möglicherweise bessere Swap-Sequenzen ermittelt.
Um eine benutzerdefinierte Heuristik zu verwenden, erstellen Sie einen SabreSwap Durchlauf und verbinden Sie ihn über den routing_pass Parameter mit SabreLayout .
SabreLayoutDem Vergleich wird ein vierter Pass-Manager hinzugefügt: pm_star_hw, der die Standard-/EinstellungenSabreSwap beibehält, aber zur Initialisierungsphase hinzufügt StarPreRouting . Bei dieser Größenordnung (100 Qubits) ist die SABRE-Suche schwieriger, und die Umwandlung von einer Sternstruktur in eine lineare Kette erweist sich als klarer Vorteil, da ein Heron-Prozessor über ausreichend lange lineare Pfade verfügt, um die resultierende Schaltung aufzunehmen.
Hier vergleichen wir alle drei SABRE-Heuristiken sowie StarPreRouting deren Skalierbarkeit anhand eines 100-Qubit-GHZ-Schaltkreises. Wir führen mehrere Layout-Versuche mit unterschiedlichen Startwerten für die SABRE-Konfigurationen durch, wählen aus jedem Versuch die beste transpilierte Schaltung aus und übermitteln diese zusammen mit dem StarPreRouting Ergebnis an die reale Hardware.
Schritte 1–4 in einem einzigen Codeblock zusammengefasst
Hier wird der gesamte Arbeitsablauf in größerem Maßstab dargestellt. SabreLayoutWenn als für routing_pass verwendet SabreSwap wird, wird pro Aufruf nur ein Layout-Versuch durchgeführt; daher durchläuft die folgende Codezelle die Startwerte in einer Schleife, um den Layout-Raum zu erkunden.
Wir verwenden denselben wrap_sabre Helfer, der im oben beschriebenen Schritt 2 (im kleinen Maßstab) definiert wurde, und fügen einen analogen wrap_routing Helfer hinzu, da die routing Stufe am Index [1] ebenfalls ein ConditionalController([BarrierBeforeFinalMeasurements, routing_pass], ...) ist – würde man sie einfach so ersetzen, würden ebenfalls die Schutzbarriere und das _swap_condition Gating wegfallen.
# -------------------------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']})"
)Output:
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:
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")Output:
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:
Mean fidelity:
basic: 0.0344
decay: 0.1298
lookahead: 0.1857
StarPreRouting: 0.3295
Analyse
Die Streudiagramme zeigen bei allen drei SABRE-Heuristiken erhebliche Schwankungen zwischen den einzelnen Samen, was verdeutlicht, wie wichtig es ist, mehrere Layout-Versuche durchzuführen, anstatt sich auf eine einzige Transpilierung zu verlassen. Die StarPreRouting Kurve verläuft über alle Startwerte hinweg im Wesentlichen flach, da die Umwandlung von einer Sternstruktur in eine lineare Kette angesichts der Struktur deterministisch ist; das nachgelagerte SABRE-Routing hat bei einer linearen Kette nur sehr wenig Spielraum, sodass der Startwert nahezu keinen Einfluss auf die endgültige Tiefe oder Größe hat.
Den Transpilierungsergebnissen zufolge schneiden sowohl die decay als auch lookahead die Heuristik durchweg deutlich besser ab basic als. Die basic Heuristik ist zwar schnell, verwendet jedoch eine einfache „Greedy“-Strategie, die oft zu wesentlich tieferen Schaltkreisen führt. Bei dieser GHZ-Schaltung mit Sterntopologie führt lookahead in der Regel zu der geringsten „ 2Q “-Tiefe und der geringsten Anzahl an Gatter unter den SABRE-Heuristiken, da sich ihre vorausschauende Kostenfunktion gut für Schaltungen mit weitreichenden Verbindungsmustern eignet. StarPreRouting, übertrifft jedoch alle drei bei weitem: Indem der Stern vor der Routierung in eine lineare Kette umgeschrieben wird, umgeht es das Suchproblem vollständig und liefert eine Schaltung, die der Rest des Transpilers mit minimalem zusätzlichem SWAP-Aufwand auf einen linearen Pfad abbilden kann.
Dieser Vorteil schlägt sich direkt in der Hardware-Genauigkeit nieder. Eine geringere Tiefe des „ 2Q “ und eine geringere Gate-Anzahl führen nicht immer eins zu eins zu einer höheren Genauigkeit (die spezifischen physikalischen Qubits, die ein Layout verwendet, und deren Kalibrierung zur Laufzeit spielen ebenfalls eine Rolle), aber wenn der Unterschied in der Tiefe so groß ist wie der zwischen SABRE und StarPreRouting hier, setzt sich der strukturbewusste Ansatz eindeutig durch, da die Schaltung weitaus weniger Dekohärenz und weitaus weniger Zwei-Qubit-Fehlerereignisse aufweist. Das Treue-Balkendiagramm zeigt StarPreRouting , dass sogar deutlich vor der besten SABRE-Heuristik liegt, während basic deutlich hinter den anderen zurückbleibt, da seine wesentlich tieferen Schaltkreise die meisten Fehler akkumulieren.
Das Wichtigste auf einen Blick:
- Unter den SABRE-Heuristiken sind und
lookaheadbei nicht-trivialen Schaltkreisen deutlich besser alsbasic.decayWählen Sie für Produktions-Workloads eine der beiden Optionen. - Die beste SABRE-Heuristik hängt von Ihrer Schaltung und Ihrer Hardware ab. Das Testen mehrerer Heuristiken mit mehreren Startwerten ist die zuverlässigste Strategie.
- Wenn Sie noch mehr Layouts untersuchen möchten, erhöhen Sie
swap_trials(undlayout_trialswenn Sie keinen benutzerdefinierten Routing-Durchlauf festlegen), anstatt die Arbeit auf entfernte Knoten zu verteilen. Die SABRE-Passes parallelisieren die Versuche bereits über lokale Threads hinweg, und der Aufwand pro Versuch ist so gering, dass der Overhead durch die Verteilung in der Regel die Geschwindigkeitssteigerung übertrifft. - Wenn die Schaltung eine bekannte spezielle Struktur aufweist, kann die Anwendung eines strukturorientierten Durchlaufs – beispielsweise
StarPreRoutingvor SABRE – eine Verbesserung um eine Größenordnung bewirken, die durch keine noch so intensive SABRE-Optimierung erreicht werden kann. Dies ist kein Ersatz für SABRE:StarPreRoutingEs hilft nur, wenn die Schaltung tatsächlich Stern-Teilstromkreise enthält und das Backend über einen ausreichend langen linearen Pfad verfügt. Es lohnt sich, die Pass-Bibliothek nach Übereinstimmungen zu durchsuchen, sobald Sie die Form Ihrer Schaltung kennen.
Nächste Schritte
Wenn Sie diese Arbeit interessant fanden, könnten Sie auch an folgenden Materialien interessiert sein:
SabreLayoutAPI-Referenz : Vollständige Parameterdokumentation- SABRE-Artikel : Der ursprüngliche SABRE-Algorithmus für Layout und Verlegung
- LightSABRE Artikel : Die algorithmischen Verbesserungen, die der aktuellen SABRE-Implementierung von Qiskit zugrunde liegen
- Schreiben Sie einen benutzerdefinierten Transpiler-Durchlauf : Erstellen Sie Ihre eigene Transpiler-Logik
- Transpiler-Plugins : Erweiterung der Transpiler-Pipeline von Qiskit um Pässe von Drittanbietern
- DAG-Darstellung : Verständnis des intern vom Transpiler verwendeten gerichteten azyklischen Graphen
Tutorial-Umfrage
Bitte nehmen Sie an dieser kurzen Umfrage teil, um uns Ihr Feedback zu diesem Lernprogramm zu geben. Ihre Erkenntnisse helfen uns, unser Inhaltsangebot und unsere Nutzererfahrung zu verbessern.