Aller au contenu principal

Optimisation de la transpilation avec SABRE

Estimation d'utilisation : 1 minute sur un processeur Heron r2 (REMARQUE : Il s'agit uniquement d'une estimation. Ton temps d'exécution peut varier.)

Objectifs d'apprentissage

Après avoir suivi ce tutoriel, tu devrais comprendre :

  • Comment configurer les paramètres de SABRE (layout_trials, swap_trials, max_iterations) pour améliorer la qualité de la transpilation
  • Les compromis entre le temps de transpilation et la qualité du circuit (profondeur et nombre de portes)
  • Comment personnaliser l'heuristique de routage SABRE (basic, decay, lookahead) et comparer leurs performances sur du matériel réel

Prérequis

Nous te suggérons de te familiariser avec les sujets suivants avant de suivre ce tutoriel :

Contexte

La transpilation convertit les circuits quantiques en formes compatibles avec du matériel quantique spécifique. Deux étapes clés consistent à choisir un placement des qubits (correspondance entre les qubits logiques et les qubits physiques) et le routage des portes (insertion de portes SWAP pour que les portes multi-qubits respectent la connectivité du dispositif).

SABRE (SWAP-Based Bidirectional heuristic search algorithm) optimise à la fois le placement et le routage. Il est particulièrement efficace pour les circuits à grande échelle (100+ qubits) sur des dispositifs avec des cartes de couplage complexes, comme les processeurs IBM® Heron. SABRE minimise les portes SWAP et réduit la profondeur du circuit, améliorant ainsi la fidélité d'exécution. Les améliorations récentes de l'algorithme LightSABRE réduisent encore les temps d'exécution et le nombre de portes.

Dans ce tutoriel, tu vas d'abord configurer SabreLayout avec différents paramètres pour optimiser un petit circuit GHZ et observer l'impact sur la fidélité d'exécution. Ensuite, tu compareras les heuristiques de routage de SABRE à grande échelle sur du matériel réel.

Prérequis techniques

Avant de commencer ce tutoriel, assure-toi d'avoir installé les éléments suivants :

  • Qiskit SDK v2.0 ou ultérieur, avec le support de visualisation
  • Qiskit Runtime v0.22 ou ultérieur (pip install qiskit-ibm-runtime)
  • Qiskit Aer (pip install qiskit-aer)

Configuration

# 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

Exemple à petite échelle sur simulateur

Dans cette section, un simulateur bruité basé sur le modèle de bruit du backend réel est utilisé pour montrer comment différentes configurations de SabreLayout affectent à la fois la qualité de la transpilation et la fidélité d'exécution. L'utilisation de qiskit_aer avec un modèle de bruit dérivé des données de calibration du matériel réel te permet de tester la transpilation sans consommer de crédits matériels.

Étape 1 : Traduire les entrées classiques en un problème quantique

Nous construisons un circuit GHZ en topologie étoile avec 15 qubits. Le premier qubit est le concentrateur, avec des portes CNOT le reliant directement à chaque autre qubit. Cette topologie crée un problème de placement complexe car elle ne se mappe pas trivialement à la carte de couplage du dispositif.

Nous définissons également des opérateurs ZZ pour mesurer les corrélations d'intrication Z0Zi\langle Z_0 Z_i \rangle entre les paires de qubits.

ghz_star_topology.png

Quand tu connais la structure du circuit

SABRE est un algorithme généraliste et ne fait aucune hypothèse sur la structure du circuit. Pour ce circuit GHZ en topologie étoile, un routage optimal est en fait connu : la passe StarPreRouting détecte les sous-circuits en étoile et les réécrit en une chaîne linéaire qui se mappe directement sur tout backend disposant d'un chemin linéaire suffisamment long. Ce tutoriel se concentre sur SABRE car il fonctionne pour des circuits arbitraires, mais si tu sais que ton circuit a une structure spéciale claire, appliquer une passe spécialisée comme StarPreRouting avant le routage peut surpasser n'importe quelle recherche heuristique.

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]

Étape 2 : Optimiser le problème pour l'exécution sur du matériel quantique

Le gestionnaire de passes prédéfini par défaut optimization_level=3 utilise déjà SabreLayout, mais avec des paramètres conservateurs. Pour explorer l'impact de paramètres plus agressifs, cette passe est remplacée par un SabreLayout personnalisé configuré pour une recherche plus agressive, tandis que toutes les autres passes de l'étape de placement restent intactes. À titre de point de comparaison supplémentaire, un quatrième gestionnaire de passes conserve le SabreLayout par défaut mais ajoute StarPreRouting à l'étape d'initialisation. StarPreRouting est une passe sensible à la structure qui détecte les sous-circuits en étoile et les réécrit en une chaîne linéaire avant le routage.

Le flux de travail est :

  1. Inspecter le gestionnaire de passes par défaut pour voir où SabreLayout se trouve dans l'étape layout.
  2. Remplacer cette passe par une instance personnalisée de SabreLayout en utilisant PassManager.replace(index, passes=...), et construire la variante pm_star avec pm.init += StarPreRouting().
  3. Exécuter les quatre gestionnaires de passes et comparer les métriques.

Les quatre configurations sont :

ConfigDescription
pm_1 (défaut)Préréglage niveau 3 par défaut (SabreLayout avec max_iterations=4, layout_trials=20, swap_trials=20)
pm_2SabreLayout personnalisé (max_iterations=4, layout_trials=200, swap_trials=200)
pm_3SabreLayout personnalisé (max_iterations=8, layout_trials=200, swap_trials=200)
pm_starPréréglage par défaut avec StarPreRouting ajouté à l'étape d'initialisation

Paramètres clés de SABRE :

  • layout_trials / swap_trials : Contrôlent combien de placements candidats et de solutions de routage SABRE explore. Augmenter le nombre d'essais signifie que SABRE échantillonne un espace de recherche plus large, augmentant les chances de trouver une meilleure solution.
  • max_iterations : Contrôle combien de cycles de raffinement du routage aller-retour SABRE effectue sur chaque candidat. SABRE améliore itérativement le placement en apprenant des retours du routage, donc plus d'itérations signifie de meilleures améliorations.

Les deux ont un coût en termes de temps de transpilation plus long, mais les circuits résultants sont plus courts et utilisent moins de portes, ce qui réduit directement la décohérence et les erreurs de portes sur du matériel réel.

Étape 2a : Inspecter le gestionnaire de passes par défaut. Un StagedPassManager est composé d'étapes (init, layout, routing, translation, optimization, scheduling), chacune étant elle-même un PassManager. Appeler .draw() sur une étape affiche ses passes sous forme de graphe afin de voir où SabreLayout se trouve.

# 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

Dans le diagramme ci-dessus, la passe SabreLayout que nous voulons personnaliser se trouve dans le ConditionalController à la position [2] de l'étape de placement. Ce contrôleur fait deux choses :

  • Il conditionne SabreLayout pour qu'il ne s'exécute que lorsque VF2Layout en [1] n'a pas réussi à trouver un mappage parfait (sinon, le placement VF2 parfait est conservé).
  • Il précède SabreLayout d'une passe BarrierBeforeFinalMeasurements qui protège les mesures contre la réorganisation pendant le routage interne de SabreLayout.

Si nous faisons simplement replace(index=2, passes=sl_2), les deux comportements sont abandonnés. Pour les conserver, nous réencapsulons notre SabreLayout personnalisé dans le même ConditionalController (avec la même condition et la barrière protectrice) avant de le substituer.

Étape 2b : Construire les passes SabreLayout personnalisées et remplacer celle par défaut.

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

La position [2] est à nouveau un ConditionalController — identique en forme au défaut, mais le SabreLayout intérieur est notre personnalisé (avec layout_trials=200, swap_trials=200 et max_iterations=8 pour pm_3 ; pm_2 est identique à l'exception de max_iterations=4). La barrière protectrice et le conditionnement _vf2_match_not_found sont préservés, de sorte que la seule différence entre pm_2/pm_3 et pm_1 est la configuration SABRE elle-même. pm_star conserve le SabreLayout par défaut et ajoute uniquement StarPreRouting à la fin de l'étape d'initialisation.

Étape 2c : Exécuter chaque gestionnaire de passes et comparer.

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%

Les trois gestionnaires de passes modifiés ont produit des circuits avec une profondeur 2Q inférieure au défaut. Les configurations SABRE agressives (pm_2 et pm_3) échangent un temps de transpilation plus long contre une recherche plus large, tandis que pm_star exploite la structure en étoile du circuit et produit un résultat encore moins profond sans payer de coût de transpilation supplémentaire. Les gains exacts varieront d'une exécution à l'autre, mais la tendance générale est cohérente : plus d'essais et d'itérations SABRE permettent à la recherche heuristique d'explorer un espace plus large, et les passes sensibles à la structure comme StarPreRouting peuvent complètement contourner cette recherche lorsque la forme du circuit correspond.

Même à cette petite échelle (15 qubits), la marge d'amélioration est suffisante pour que les trois approches surpassent le défaut. Avec des circuits plus grands (100+ qubits), l'espace de recherche croît considérablement et les avantages des deux augmentations d'essais et des passes sensibles à la structure deviennent beaucoup plus prononcés, comme le montrera la section à grande échelle.

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

Étape 3 : Exécuter en utilisant les primitives Qiskit

Nous exécutons chaque circuit transpilé 10 fois en utilisant l'EstimatorV2 d'Aer avec un modèle de bruit dérivé du backend réel. Comme les résultats de simulation bruitée varient entre les exécutions, moyenner sur plusieurs exécutions donne des estimations de fidélité plus fiables et nous permet de quantifier l'incertitude statistique avec des barres d'erreur.

# 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

Parce qu'il s'agit d'un petit circuit, les valeurs de fidélité se situent relativement proches pour les quatre configurations. Les circuits sont suffisamment courts pour que le bruit matériel ne pénalise pas sévèrement même la version la moins optimisée. La fidélité moyenne suit globalement la profondeur 2Q : pm_3 et pm_star, les deux circuits les moins profonds, atteignent les fidélités les plus élevées et sont essentiellement à égalité dans leurs barres d'erreur. pm_2 est un contre-exemple utile : bien que sa profondeur 2Q soit inférieure à celle de pm_1, sa fidélité moyenne finit légèrement plus basse également, ce qui rappelle que le lien profondeur-fidélité est statistique plutôt que déterministe. Les qubits physiques spécifiques sélectionnés par un placement et la calibration de ces qubits au moment de l'exécution comptent aussi.

Étape 4 : Post-traitement et renvoi du résultat au format classique souhaité

Ensuite, on trace les corrélations d'intrication Z0Zi\langle Z_0 Z_i \rangle en fonction de la distance entre qubits, ainsi que la corrélation moyenne comme métrique de fidélité unique. Dans un cas idéal (sans bruit), toutes les corrélations seraient égales à 1. Avec un bruit réaliste, chaque porte supplémentaire introduit une erreur et chaque étape de temps supplémentaire permet la décohérence, donc un circuit transpilé avec moins de profondeur et moins de portes (en particulier les portes à deux qubits) devrait mieux préserver l'intrication.

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

Les résultats montrent un lien clair entre la qualité de la transpilation et la fidélité d'exécution, avec quelques mises en garde utiles :

  • pm_1 (défaut) : Référence. Avec seulement 20 essais et quatre itérations, SABRE a peu de marge pour optimiser, résultant dans le circuit SABRE uniquement le plus profond.
  • pm_2 (plus d'essais) : Explorer dix fois plus de candidats trouve un placement légèrement moins profond, mais la fidélité moyenne est approximativement stable (et peut même descendre en dessous de la référence dans le bruit) car le gain de profondeur est faible à cette échelle.
  • pm_3 (plus d'essais + plus d'itérations) : Doubler max_iterations à 8 donne à SABRE plus de cycles de raffinement, produisant le circuit SABRE uniquement le moins profond et la fidélité moyenne la plus élevée de la comparaison.
  • pm_star (défaut + StarPreRouting) : Ajoute StarPreRouting à l'étape d'initialisation d'un préréglage par défaut sinon identique. La réécriture sensible à la structure réduit l'étoile en une chaîne linéaire que le reste du transpileur mappe sur le chemin linéaire du dispositif, produisant le circuit le moins profond dans l'ensemble (légèrement meilleur que pm_3) et correspondant à pm_3 en fidélité dans les barres d'erreur. Il le fait avec le même temps de transpilation que le défaut, car la réécriture est essentiellement gratuite comparée à la recherche stochastique de SABRE.

Note qu'augmenter max_iterations n'a pas toujours un impact positif. Dans ce cas, cela a aidé de manière significative, mais pour d'autres circuits ou backends, les itérations supplémentaires peuvent ne pas produire d'amélioration supplémentaire, ou même légèrement nuire aux performances en raison d'une sur-optimisation vers un minimum local. En général, tu devrais augmenter layout_trials et swap_trials autant que ton budget en temps le permet, car plus d'essais augmente toujours la chance de trouver un meilleur placement. Augmenter max_iterations vaut la peine d'être testé mais devrait être validé pour ton cas d'usage spécifique. Les passes spécialisées comme StarPreRouting sont similaires dans l'esprit mais plus dépendantes du circuit : elles n'aident que lorsque le circuit contient réellement la structure qu'elles ciblent. Le gain est important lorsqu'applicable et nul sinon, mais elles ne coûtent pratiquement rien à essayer.

Exemple à grande échelle sur matériel réel

En plus d'ajuster le nombre d'essais, SABRE prend en charge la personnalisation de l'heuristique de routage. SABRE propose trois heuristiques :

  • basic : Une approche gloutonne simple qui sélectionne le swap minimisant la distance immédiate à la prochaine porte.
  • decay (défaut) : Pondère dynamiquement les qubits en fonction de leur activité récente, décourageant les swaps répétés sur les mêmes qubits.
  • lookahead : Évalue les coûts de routage futurs en regardant les prochaines portes à venir, trouvant potentiellement de meilleures séquences de swaps.

Pour utiliser une heuristique personnalisée, crée une passe SabreSwap et connecte-la à SabreLayout via le paramètre routing_pass.

Un quatrième gestionnaire de passes est ajouté à la comparaison : pm_star_hw, qui conserve les paramètres SabreLayout/SabreSwap par défaut mais ajoute StarPreRouting à l'étape d'initialisation. À cette échelle (100 qubits), la recherche SABRE est plus difficile, et la réécriture d'une étoile en une chaîne linéaire devient un avantage clair car un processeur Heron a des chemins linéaires suffisamment longs pour accueillir le circuit résultant.

Ici, nous comparons les trois heuristiques SABRE ainsi que StarPreRouting à grande échelle sur un circuit GHZ à 100 qubits. Nous exécutons plusieurs essais de placement avec différentes graines pour les configurations SABRE, sélectionnons le meilleur circuit transpilé de chacune, et les soumettons tous au matériel réel aux côtés du résultat StarPreRouting.

Étapes 1-4 condensées en un seul bloc de code

Ici, le flux de travail complet est assemblé à plus grande échelle. Lorsqu'on utilise SabreSwap comme routing_pass pour SabreLayout, un seul essai de placement est effectué par appel, donc la cellule de code suivante boucle sur les graines pour explorer l'espace de placement.

Nous utilisons le même assistant wrap_sabre défini dans l'étape 2 à petite échelle (ci-dessus), et ajoutons un assistant wrap_routing analogue car l'étape routing à l'index [1] est également un ConditionalController([BarrierBeforeFinalMeasurements, routing_pass], ...) — le remplacer directement supprimerait également la barrière protectrice et le conditionnement _swap_condition.

# -------------------------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

Les graphiques de dispersion montrent une variabilité significative entre les graines pour les trois heuristiques SABRE, ce qui souligne l'importance d'exécuter plusieurs essais de placement plutôt que de se fier à une seule transpilation. La ligne StarPreRouting est essentiellement plate entre les graines car la réécriture d'une étoile en chaîne linéaire est déterministe étant donné la structure ; le routage SABRE en aval a alors très peu de liberté sur une chaîne linéaire, donc la graine a presque aucun effet sur la profondeur ou la taille finale.

D'après les résultats de transpilation, les heuristiques decay et lookahead surpassent systématiquement basic de loin. L'heuristique basic, bien que rapide, utilise une stratégie gloutonne simple qui conduit souvent à des circuits nettement plus profonds. Pour ce circuit GHZ en topologie étoile, lookahead tend à produire la profondeur 2Q et le nombre de portes les plus faibles parmi les heuristiques SABRE, car sa fonction de coût prospective est bien adaptée aux circuits avec des motifs de connectivité à longue portée. StarPreRouting, cependant, éclipse les trois par une marge substantielle : en réécrivant l'étoile en chaîne linéaire avant le routage, il court-circuite complètement le problème de recherche et livre un circuit que le reste du transpileur peut mapper sur un chemin linéaire avec un minimum de SWAPs supplémentaires.

Cet avantage se traduit directement en fidélité sur le matériel. Une profondeur 2Q et un nombre de portes plus faibles ne se traduisent pas toujours un pour un par une fidélité plus élevée (les qubits physiques spécifiques utilisés par un placement et leur calibration au moment de l'exécution comptent aussi), mais lorsque l'écart de profondeur est aussi important que celui entre SABRE et StarPreRouting ici, l'approche sensible à la structure gagne de manière décisive car le circuit accumule bien moins de décohérence et bien moins d'événements d'erreur à deux qubits. Le graphique à barres de fidélité montre StarPreRouting substantiellement en avance sur même la meilleure heuristique SABRE, tandis que basic se situe bien en dessous des autres car ses circuits beaucoup plus profonds accumulent le plus d'erreurs.

Points clés à retenir :

  • Parmi les heuristiques SABRE, decay et lookahead sont substantiellement meilleures que basic pour les circuits non triviaux. Préfère l'une des deux pour les charges de travail en production.
  • La meilleure heuristique SABRE dépend de ton circuit et du matériel. Tester plusieurs heuristiques avec plusieurs graines est la stratégie la plus fiable.
  • Si tu veux explorer encore plus de placements, augmente swap_trials (et layout_trials lorsque tu n'imposes pas de passe de routage personnalisée) plutôt que de distribuer le travail sur des nœuds distants. Les passes SABRE parallélisent déjà les essais sur des threads locaux, et le travail par essai est suffisamment faible pour que le surcoût de distribution domine généralement tout gain de vitesse.
  • Lorsque le circuit a une structure spéciale connue, appliquer une passe sensible à la structure comme StarPreRouting avant SABRE peut apporter une amélioration d'un ordre de grandeur qu'aucun réglage SABRE ne peut égaler. Ce n'est pas un remplacement de SABRE : StarPreRouting n'aide que lorsque le circuit contient réellement des sous-circuits en étoile et que le backend a un chemin linéaire suffisamment long. Il vaut la peine de vérifier la bibliothèque de passes pour des correspondances chaque fois que tu connais la forme de ton circuit.

Prochaines étapes

Si ce travail t'a intéressé, tu pourrais être intéressé par les ressources suivantes :

Recommandations

Enquête sur le tutoriel

Veuillez répondre à cette courte enquête pour nous faire part de tes commentaires sur ce tutoriel. Tes observations nous aideront à améliorer notre contenu et l'expérience utilisateur.

Remarque : Cette enquête est gérée par IBM Quantum et porte sur le contenu du tutoriel (rédigé par IBM). doQumentation fournit le site web, les traductions et l'exécution du code — pour tout retour sur ces aspects, merci d'ouvrir un problème GitHub.

Lien vers l'enquête