SABRE를 활용한 트랜스파일 최적화
사용 시간 예상: Heron r2 프로세서에서 1분 (참고: 이는 예상치이며 실제 런타임은 다를 수 있습니다.)
학습 목표
이 튜토리얼을 마치면 다음 사항을 이해할 수 있습니다:
- SABRE 파라미터(
layout_trials,swap_trials,max_iterations)를 구성하여 트랜스파일 품질을 향상시키는 방법 - 트랜스파일 런타임과 회로 품질(깊이 및 gate 수) 사이의 균형
- SABRE 라우팅 휴리스틱(
basic,decay,lookahead)을 커스터마이징하고 하드웨어에서 성능을 비교하는 방법
사전 요구 사항
이 튜토리얼을 시작하기 전에 다음 주제에 익숙해지기를 권장합니다:
- 회로 트랜스파일: Qiskit에서의 트랜스파일 개요
- Transpiler 단계: 레이아웃과 라우팅 단계
- 프리셋 패스 관리자 구성: 최적화 수준 커스터마이징
배경
트랜스파일은 양자 회로를 특정 양자 하드웨어와 호환되는 형태로 변환합니다. 두 가지 핵심 단계는 qubit 레이아웃 선택(논리적 qubit을 물리적 qubit에 매핑)과 gate 라우팅 (다중 qubit gate가 장치 연결성을 따르도록 SWAP gate 삽입)입니다.
SABRE (SWAP 기반 양방향 휴리스틱 탐색 알고리즘)는 레이아웃과 라우팅 모두를 최적화합니다. IBM® Heron 프로세서와 같이 복잡한 커플링 맵을 갖는 장치에서 대규모 회로(100개 이상의 qubit)에 특히 효과적입니다. SABRE는 SWAP gate를 최소화하고 회로 깊이를 줄여 실행 충실도를 향상시킵니다. LightSABRE 알고리즘의 최근 개선 사항은 런타임과 gate 수를 더욱 줄여 줍니다.
이 튜토리얼에서는 먼저 SabreLayout을 다양한 파라미터로 구성하여 소규모 GHZ 회로를 최적화하고 실행 충실도에 미치는 영향을 관찰합니다. 그런 다음 실제 하드웨어에서 SABRE 라우팅 휴리스틱을 대규모로 비교합니다.
요구 사항
이 튜토리얼을 시작하기 전에 다음 항목이 설치되어 있는지 확인하세요:
- Qiskit SDK v2.0 이상, 시각화 지원 포함
- Qiskit Runtime v0.22 이상 (
pip install qiskit-ibm-runtime) - Qiskit Aer (
pip install qiskit-aer)
설정
# 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
소규모 시뮬레이터 예제
이 섹션에서는 실제 Backend의 노이즈 모델을 기반으로 한 노이즈 시뮬레이터를 사용하여 다양한 SabreLayout 구성이 트랜스파일 품질과 실행 충실도 모두에 미치는 영향을 보여 줍니다. 실제 하드웨어 캘리브레이션 데이터에서 파생된 노이즈 모델을 갖춘 qiskit_aer를 사용하면 하드웨어 크레딧을 소모하지 않고 트랜스파일을 테스트할 수 있습니다.
Step 1: 고전적 입력을 양자 문제로 매핑
15개의 qubit를 가진 별형 토폴로지 GHZ 회로를 구성합니다. 첫 번째 qubit이 허브 역할을 하며, CNOT gate가 다른 모든 qubit과 직접 연결됩니다. 이 토폴로지는 장치의 커플링 맵에 직접 매핑되지 않기 때문에 어려운 레이아웃 문제를 만들어 냅니다.
또한 qubit 쌍 전체에서 얽힘 상관관계 를 측정하기 위한 ZZ 연산자도 정의합니다.

SABRE는 범용 알고리즘으로 회로 구조에 대한 가정을 하지 않습니다. 이 별형 토폴로지 GHZ 회로의 경우 최적 라우팅이 실제로 알려져 있습니다: StarPreRouting 패스는 별형 서브 회로를 감지하고 충분히 긴 선형 경로를 가진 Backend에 직접 매핑할 수 있는 선형 체인으로 재작성합니다. 이 튜토리얼은 임의의 회로에서 작동하는 SABRE에 초점을 맞추고 있지만, 회로에 명확한 특수 구조가 있다는 것을 안다면 라우팅 전에 StarPreRouting과 같은 특수 패스를 적용하면 어떤 휴리스틱 검색보다 더 나은 성능을 발휘할 수 있습니다.
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]
Step 2: 양자 하드웨어 실행을 위한 문제 최적화
기본 optimization_level=3 프리셋 패스 관리자는 이미 SabreLayout을 사용하지만 보수적인 기본값을 가집니다. 더 강력한 설정의 영향을 살펴보기 위해, 해당 패스를 더 공격적인 검색을 위해 구성된 커스텀 SabreLayout으로 교체하고, 레이아웃 단계의 다른 모든 패스는 그대로 유지합니다. 별도의 비교 대상으로, 네 번째 패스 관리자는 기본 SabreLayout을 유지하면서 init 단계에 StarPreRouting을 추가합니다. StarPreRouting은 구조 인식 패스로, 별형 서브 회로를 감지하고 라우팅 전에 선형 체인으로 재작성합니다.
워크플로우는 다음과 같습니다:
- 기본 패스 관리자를 검사하여
SabreLayout이layout단계 어디에 위치하는지 확인합니다. - 해당 패스를
PassManager.replace(index, passes=...)를 사용하여 커스텀SabreLayout인스턴스로 교체하고,pm.init += StarPreRouting()으로pm_star변형을 구성합니다. - 네 가지 패스 관리자 모두 실행하고 지표를 비교합니다.
네 가지 구성은 다음과 같습니다:
| 구성 | 설명 |
|---|---|
pm_1 (기본) | 기본 레벨-3 프리셋 (SabreLayout, max_iterations=4, layout_trials=20, swap_trials=20) |
pm_2 | 커스텀 SabreLayout (max_iterations=4, layout_trials=200, swap_trials=200) |
pm_3 | 커스텀 SabreLayout (max_iterations=8, layout_trials=200, swap_trials=200) |
pm_star | 기본 프리셋에 init 단계에 StarPreRouting 추가 |
핵심 SABRE 파라미터:
layout_trials/swap_trials: SABRE가 탐색하는 후보 레이아웃과 라우팅 솔루션의 수를 제어합니다. 시도 횟수를 늘리면 SABRE가 더 넓은 탐색 공간을 샘플링하여 더 나은 솔루션을 찾을 가능성이 높아집니다.max_iterations: SABRE가 각 후보에 대해 수행하는 전방-후방 라우팅 정제 사이클 수를 제어합니다. SABRE는 라우팅 피드백으로부터 학습하여 반복적으로 레이아웃을 개선하므로, 반복이 많을수록 개선이 더 좋아집니다.
두 가지 모두 더 긴 트랜스파일 시간이라는 비용이 따르지만, 결과 회로는 더 짧고 gate가 적어 실제 하드웨어에서의 디코히어런스와 gate 오류가 직접적으로 줄어듭니다.
Step 2a: 기본 패스 관리자 검사. StagedPassManager는 단계(init, layout, routing, translation, optimization, scheduling)로 구성되며, 각 단계 자체는 PassManager입니다. 단계에서 .draw()를 호출하면 패스를 그래프로 렌더링하여 SabreLayout이 어디에 있는지 확인할 수 있습니다.
# 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()

위 다이어그램에서 커스터마이즈하려는 SabreLayout 패스는 레이아웃 단계의 위치 **[2]**에 있는 ConditionalController 안에 있습니다. 해당 컨트롤러는 두 가지 역할을 합니다:
- [1]의
VF2Layout이 완벽한 매핑을 찾지 못한 경우에만SabreLayout이 실행되도록 제한합니다(그렇지 않으면 완벽한 VF2 레이아웃이 유지됩니다). SabreLayout앞에BarrierBeforeFinalMeasurements패스를 배치하여 SabreLayout의 내부 라우팅 중에 측정이 재정렬되지 않도록 보호합니다.
단순히 replace(index=2, passes=sl_2)를 하면 두 동작 모두 제거됩니다. 이를 유지하려면 커스텀 SabreLayout을 교체하기 전에 동일한 ConditionalController(동일한 조건과 보호 배리어 포함)로 다시 감싸야 합니다.
Step 2b: 커스텀 SabreLayout 패스를 빌드하고 기본값을 교체합니다.
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()

위치 **[2]**는 이제 ConditionalController로 다시 바뀌었습니다 — 기본값과 형태는 동일하지만 내부 SabreLayout은 커스텀 것입니다(pm_3의 경우 layout_trials=200, swap_trials=200, max_iterations=8; pm_2는 max_iterations=4만 다릅니다). 보호 배리어와 _vf2_match_not_found 게이팅이 보존되므로, pm_2/pm_3와 pm_1의 유일한 차이점은 SABRE 구성 자체입니다. pm_star는 기본 SabreLayout을 유지하고 init 단계 마지막에 StarPreRouting만 추가합니다.
Step 2c: 각 패스 관리자를 실행하고 비교합니다.
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%
세 가지 수정된 패스 관리자 모두 기본값보다 낮은 2Q 깊이를 가진 회로를 생성했습니다. 공격적인 SABRE 구성(pm_2와 pm_3)은 더 긴 트랜스파일 시간과 더 넓은 검색을 교환하는 반면, pm_star는 회로의 별형 구조를 활용하여 추가적인 트랜스파일 비용 없이 더 얕은 결과를 생성합니다. 정확한 이득은 실행마다 다를 수 있지만 일반적인 추세는 일관됩니다: 더 많은 SABRE 시도와 반복은 휴리스틱이 더 넓은 공간을 검색하게 하고, StarPreRouting과 같은 구조 인식 패스는 회로 형태가 일치할 때 검색 자체를 완전히 우회할 수 있습니다.
이 소규모(15 qubit)에서도 개선의 여지는 세 가지 접근법 모두 기본값을 능가하기에 충분합니다. 더 큰 회로(100개 이상의 qubit)에서는 검색 공간이 극적으로 증가하며 시도 횟수 증가와 구조 인식 패스 모두의 이점이 훨씬 더 두드러집니다. 이는 대규모 섹션에서 보여 줄 것입니다.
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()

Step 3: Qiskit 프리미티브를 사용한 실행
각 트랜스파일된 회로를 Aer EstimatorV2를 사용하여 실제 Backend의 노이즈 모델로 10번 실행합니다. 노이즈가 있는 시뮬레이션 결과는 실행마다 다르기 때문에 여러 번 평균을 내면 더 신뢰할 수 있는 충실도 추정치를 얻고 오차 막대로 통계적 불확실성을 정량화할 수 있습니다.
# 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
이것은 소규모 회로이기 때문에 네 가지 구성 모두에서 충실도 값이 상대적으로 가깝게 나옵니다. 회로가 충분히 짧아서 하드웨어 노이즈가 가장 덜 최적화된 버전에도 크게 패널티를 주지 않습니다. 평균 충실도는 대체로 2Q 깊이를 추적합니다: 가장 얕은 회로인 pm_3와 pm_star는 가장 높은 충실도를 달성하고 오차 막대 내에서 사실상 동점입니다. pm_2는 유용한 반례입니다: 2Q 깊이가 pm_1보다 낮음에도 불구하고 평균 충실도가 약간 낮게 나오는데, 이는 깊이와 충실도의 연결이 결정론적이라기보다 통계적임을 상기시켜 줍니다. 레이아웃이 선택하는 특정 qubit과 실행 시점에서 해당 qubit의 캘리브레이션도 중요합니다.
Step 4: 후처리 및 원하는 고전적 형식으로 결과 반환
다음으로, qubit 거리의 함수로 얽힘 상관관계 를 단일 충실도 지표인 평균 상관관계와 함께 플롯합니다. 이상적인(노이즈 없는) 경우, 모든 상관관계는 1이 됩니다. 현실적인 노이즈에서는 각 추가 gate가 오류를 도입하고 각 추가 시간 단계가 디코히어런스를 허용하므로, 깊이가 낮고 gate(특히 2-qubit gate)가 적은 트랜스파일된 회로는 얽힘을 더 잘 보존해야 합니다.
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()

결과는 트랜스파일 품질과 실행 충실도 사이의 명확한 연결을 보여 주며, 몇 가지 유용한 주의 사항이 있습니다:
pm_1(기본): 기준선. 시도 횟수 20회와 4번의 반복만으로 SABRE의 최적화 여지가 제한되어 SABRE 전용 회로 중 가장 깊은 회로가 됩니다.pm_2(더 많은 시도): 열 배 더 많은 후보를 탐색하면 약간 더 얕은 레이아웃을 찾을 수 있지만, 이 규모에서는 깊이 이득이 작기 때문에 평균 충실도는 거의 변화 없이(노이즈 내에서 기준선 아래로 떨어질 수도 있습니다) 유지됩니다.pm_3(더 많은 시도 + 더 많은 반복):max_iterations를 8로 두 배로 늘리면 SABRE에 더 많은 정제 사이클이 주어져 SABRE 전용 회로 중 가장 얕은 회로와 비교에서 가장 높은 평균 충실도를 생성합니다.pm_star(기본 + StarPreRouting): 기본 프리셋의 init 단계에StarPreRouting을 추가합니다. 구조 인식 재작성은 별형을 트랜스파일러의 나머지 부분이 장치의 선형 경로에 매핑할 수 있는 선형 체인으로 접혀, 전체적으로 가장 얕은 회로를 생성하고(pm_3보다 약간 더 나음) 오차 막대 내에서pm_3와 동등한 충실도를 달성합니다. 재작성이 SABRE의 확률적 검색에 비해 본질적으로 무료이므로 기본값과 동일한 트랜스파일 시간으로 이를 수행합니다.
max_iterations를 늘리는 것이 항상 긍정적인 영향을 주지는 않습니다. 이 경우에는 상당히 도움이 되었지만, 다른 회로나 Backend에서는 추가 반복이 더 이상의 개선을 가져오지 않거나 지역 최솟값의 과도한 최적화로 인해 성능이 약간 저하될 수도 있습니다. 일반적으로 layout_trials와 swap_trials는 시간 예산이 허용하는 한 최대한 늘려야 합니다. 더 많은 시도는 항상 더 나은 레이아웃을 찾을 가능성을 높이기 때문입니다. max_iterations를 늘리는 것은 테스트할 가치가 있지만 특정 사용 사례에 대해 검증해야 합니다. StarPreRouting과 같은 특수 패스는 취지상 유사하지만 더 회로에 의존적입니다: 실제로 대상으로 하는 구조가 회로에 포함된 경우에만 도움이 됩니다. 해당되는 경우 이득이 크고 그렇지 않으면 0이지만, 시도하는 데 본질적으로 비용이 들지 않습니다.
대규모 하드웨어 예제
시도 횟수 조정 외에도, SABRE는 라우팅 휴리스틱 커스터마이징을 지원합니다. SABRE는 세 가지 휴리스틱을 제공합니다:
basic: 다음 gate까지의 즉각적인 거리를 최소화하는 스왑을 선택하는 단순한 탐욕적 접근법입니다.decay(기본값): 최근 활동을 기반으로 qubit에 동적으로 가중치를 부여하여 동일한 qubit에 반복적인 스왑을 억제합니다.lookahead: 향후 gate를 미리 살펴봄으로써 미래 라우팅 비용을 평가하여 잠재적으로 더 나은 스왑 시퀀스를 찾습니다.
커스텀 휴리스틱을 사용하려면 SabreSwap 패스를 만들고 routing_pass 파라미터를 통해 SabreLayout에 연결합니다.
네 번째 패스 관리자를 비교에 추가합니다: pm_star_hw는 기본 SabreLayout/SabreSwap 설정을 유지하면서 init 단계에 StarPreRouting을 추가합니다. 이 규모(100 qubit)에서 SABRE 검색은 더 어려워지며, 별형에서 선형 체인으로의 재작성은 명확한 이점이 됩니다. Heron 프로세서는 결과 회로를 수용할 만큼 충분히 긴 선형 경로를 가지고 있기 때문입니다.
여기서는 100-qubit GHZ 회로에서 대규모로 세 가지 SABRE 휴리스틱 모두와 StarPreRouting을 비교합니다. SABRE 구성에 대해 다른 시드로 여러 레이아웃 시도를 실행하고, 각 구성에서 가장 좋은 트랜스파일된 회로를 선택하여 StarPreRouting 결과와 함께 실제 하드웨어에 제출합니다.
단계 1-4를 단일 코드 블록으로 압축
여기서 전체 워크플로우를 더 큰 규모로 모아 봅니다. SabreLayout의 routing_pass로 SabreSwap을 사용할 때는 호출당 하나의 레이아웃 시도만 수행되므로, 다음 코드 셀은 시드를 반복하여 레이아웃 공간을 탐색합니다.
소규모 Step 2(위)에서 정의한 동일한 wrap_sabre 헬퍼를 사용하며, routing 단계의 인덱스 [1]도 ConditionalController([BarrierBeforeFinalMeasurements, routing_pass], ...) 형태이기 때문에 유사한 wrap_routing 헬퍼를 추가합니다. 이를 그대로 교체하면 마찬가지로 보호 배리어와 _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']})"
)

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}")

Mean fidelity:
basic: 0.0344
decay: 0.1298
lookahead: 0.1857
StarPreRouting: 0.3295
분석
산점도는 세 가지 SABRE 휴리스틱 모두에서 시드에 따라 상당한 변동성을 보여 주며, 이는 단일 트랜스파일에 의존하기보다 여러 레이아웃 시도를 실행하는 것의 중요성을 강조합니다. StarPreRouting 라인은 시드 전반에 걸쳐 본질적으로 평평합니다. 별형에서 선형 체인으로의 재작성이 구조 측면에서 결정론적이기 때문입니다. 그 이후의 SABRE 라우팅은 선형 체인에서 자유도가 매우 적어, 시드가 최종 깊이나 크기에 거의 영향을 주지 않습니다.
트랜스파일 결과에서 decay와 lookahead 휴리스틱 모두 basic보다 큰 차이로 일관되게 우수합니다. basic 휴리스틱은 빠르지만, 단순한 탐욕적 전략이 종종 상당히 더 깊은 회로로 이어집니다. 이 별형 토폴로지 GHZ 회로의 경우, lookahead는 SABRE 휴리스틱 중 가장 낮은 2Q 깊이와 gate 수를 생성하는 경향이 있습니다. 그 전향적 비용 함수가 장거리 연결성 패턴을 가진 회로에 잘 맞기 때문입니다. 그러나 StarPreRouting은 세 가지 모두를 상당한 차이로 능가합니다: 라우팅 전에 별형을 선형 체인으로 재작성함으로써 검색 문제 자체를 단축하고, 나머지 트랜스파일러가 최소한의 추가 SWAP으로 선형 경로에 매핑할 수 있는 회로를 제공합니다.
그 이점은 하드웨어 충실도로 직접 이어집니다. 낮은 2Q 깊이와 gate 수가 항상 일대일로 높은 충실도로 변환되는 것은 아니지만(레이아웃이 사용하는 특정 물리적 qubit과 실행 시점의 캘리브레이션도 중요합니다), SABRE와 StarPreRouting 사이의 깊이 차이가 여기서만큼 클 때는 구조 인식 접근법이 결정적으로 승리합니다. 회로가 훨씬 적은 디코히어런스와 2-qubit 오류 이벤트를 축적하기 때문입니다. 충실도 막대 차트는 StarPreRouting이 가장 좋은 SABRE 휴리스틱보다도 상당히 앞서 있음을 보여 주며, basic은 훨씬 더 깊은 회로가 가장 많은 오류를 축적하기 때문에 나머지보다 훨씬 아래에 위치합니다.
핵심 요점:
- SABRE 휴리스틱 중에서
decay와lookahead는 비자명 회로에서basic보다 상당히 우수합니다. 프로덕션 워크로드에는 두 가지 중 하나를 선호하십시오. - 가장 좋은 SABRE 휴리스틱은 회로와 하드웨어에 따라 다릅니다. 여러 시드로 여러 휴리스틱을 테스트하는 것이 가장 신뢰할 수 있는 전략입니다.
- 더 많은 레이아웃을 탐색하려면 원격 노드에 작업을 분산하는 대신
swap_trials(커스텀 라우팅 패스를 고정하지 않을 때는layout_trials도)를 늘리십시오. SABRE 패스는 이미 로컬 스레드 전체에 걸쳐 시도를 병렬화하며, 시도당 작업량이 작아서 분산 오버헤드가 일반적으로 어떤 속도 향상보다도 더 큽니다(속도 향상을 상쇄합니다). - 회로에 알려진 특수 구조가 있을 경우, SABRE 전에
StarPreRouting과 같은 구조 인식 패스를 적용하면 어떤 SABRE 튜닝으로도 달성할 수 없는 자릿수 수준의 개선을 제공할 수 있습니다. 이것은 SABRE의 대체제가 아닙니다:StarPreRouting은 회로에 실제로 별형 서브 회로가 포함되어 있고 Backend에 충분히 긴 선형 경로가 있는 경우에만 도움이 됩니다. 회로 형태를 알고 있을 때는 패스 라이브러리에서 일치하는 것을 확인해 볼 가치가 있습니다.
다음 단계
이 작업에 흥미를 느끼셨다면 다음 자료에도 관심을 가져보실 수 있습니다:
SabreLayoutAPI 참조: 전체 파라미터 문서- SABRE 논문: 레이아웃과 라우팅을 위한 원래 SABRE 알고리즘
- LightSABRE 논문: Qiskit의 현재 SABRE 구현을 지원하는 알고리즘 개선 사항
- 커스텀 Transpiler 패스 작성: 자신만의 트랜스파일 로직 구축
- Transpiler 플러그인: 서드파티 패스로 Qiskit의 트랜스파일 파이프라인 확장
- DAG 표현: Transpiler가 내부적으로 사용하는 방향성 비순환 그래프 이해
튜토리얼 설문 조사
이 튜토리얼에 대한 피드백을 제공하기 위해 짧은 설문 조사에 참여해 주세요. 여러분의 의견은 콘텐츠 제공과 사용자 경험 개선에 도움이 됩니다.
참고: 이 설문 조사는 IBM Quantum에서 제공하며 튜토리얼 콘텐츠 (IBM이 작성)를 다룹니다. doQumentation은 웹사이트, 번역 및 코드 실행을 제공하며, 이에 대한 피드백은 GitHub 이슈 열기를 통해 제출해 주세요.