はじめに
前回の記事でOQTOPUSのクラウド層・バックエンド層をゼロからローカルに構築したので、今回はその中のTranquというトランスパイラ関連コンポーネントに焦点を当て、「optimization_levelを変えると実際に回路がどう変わるのか」を実機環境で検証してみた話を書く。
Tranquとは何か
Tranquは、大阪大学とTIS株式会社の共同研究で開発された、複数の量子回路ライブラリ・フォーマットに対応したトランスパイラのワンストップフレームワーク(参考文献参照)。
NISQ時代の量子コンピュータでは、どのトランスパイラを使うかによってtranspile後のゲート数や忠実度が大きく変わり得るが、「あらゆる回路に対して最適なトランスパイラ」は存在しないため、本来は回路の特性に応じて複数のトランスパイラを使い分けたい。しかしトランスパイラごとに対応するプログラム形式(Qiskit, OpenQASM3, ...)やAPIがバラバラで、切り替えながら比較するのは煩雑という課題があった。Tranquは「プログラム変換」「デバイス情報変換」「統一インターフェースでのトランスパイル実行」「実行前後の統計情報出力」などを提供することで、この課題を解決する。
from tranqu import Tranqu
tranqu = Tranqu()
result = tranqu.transpile(
program=program,
program_lib="openqasm3",
transpiler_lib="qiskit",
)
OQTOPUSのアーキテクチャの中では、Engine内部のPre-Process段階で「各種トランスパイラを橋渡しする中間層」として位置づけられており、gRPCサーバとして起動してEngineからのリクエストに応答する(参考文献参照)。詳しくは後述する。
環境
前回構築したcloud-local(my-cloud)に加えて、oqtopus backend init的な手順でbackendテンプレート(my-backend)も構築済みの状態から始める。
$ oqtopus backend status
core: Running (PID 1217)
sse_engine: Running (PID 1189)
mitigator: Running (PID 1092)
estimator: Running (PID 1121)
combiner: Running (PID 1160)
tranqu: Running (PID 1059)
gateway: Running (PID 1043)
OQTOPUS backendにおけるTranquの実体
Tranqu自体はpip install tranquで入る単体のライブラリだが、oqtopus backendが展開するtranquマイクロサービスはこれをそのままpipで使っているわけではない。
-
Tranqu(ライブラリ):
pip install tranquで入る。前述のTranqu().transpile(...)のように、単体でも使える。 -
Tranqu Server:
oqtopus-team/tranqu-serverという別リポジトリで、TranquライブラリをgRPCサービスとしてラップしたアプリケーション。これがoqtopus backendが展開するtranquマイクロサービスの実体。
EngineのパイプラインにおけるTranquの位置づけ
core(Engine本体)の設定ファイル(config/core/config.yaml)を見ると、1ジョブが通るパイプラインの全体像と、Tranquがどこに挟まっているかが分かる。
pipeline_executor:
pipeline:
- job_repository_update_step
- multi_manual_step
- tranqu_step
- estimator_step
- ro_error_mitigation_step
- mp_auto_combining_step
- buffer
- sse_step
- device_gateway_step
job_buffer: buffer
exception_handler: pipeline_exception_handler
tranqu_stepはmulti_manual_stepの次、estimator_stepの前に挟まっている。同じ設定ファイルのdi_container.registryには各ステップ・各コンポーネントの実体(接続先アドレス等)も定義されており、関係する部分だけ抜粋するとこう。
job_fetcher:
_target_: oqtopus_engine_core.fetchers.RepositoryJobFetcher
interval_seconds: ${JOB_FETCHER_INTERVAL_SECONDS, 10}
# ...
job_repository:
_target_: oqtopus_engine_core.repositories.OqtopusCloudJobRepository
url: ${JOB_REPOSITORY_URL, "http://localhost:8888"}
# ...
tranqu_step:
_target_: oqtopus_engine_core.steps.TranquStep
tranqu_address: ${TRANQU_ADDRESS, "localhost:52020"}
default_transpiler_info:
transpiler_lib: qiskit
transpiler_options:
optimization_level: 1
device_gateway_step:
_target_: oqtopus_engine_core.steps.DeviceGatewayStep
gateway_address: ${GATEWAY_ADDRESS, "localhost:52021"}
ポイントは以下の通り。
-
job_fetcher(RepositoryJobFetcher)がjob_repository(OqtopusCloudJobRepository、既定URLhttp://localhost:8888= provider-api)へポーリングして新規ジョブ(status=submitted)を取得し、パイプラインへ投入する -
tranqu_stepはtranqu_addressで指定したアドレスへgRPCでリクエストし、transpiler_info省略時のデフォルトとしてtranspiler_lib=qiskit, optimization_level=1を使う -
device_gateway_stepはgateway_addressで指定したアドレスへgRPCでリクエストする
ジョブ投入時にtranspiler_infoを明示的に指定しなければ、transpiler_lib=qiskit・optimization_level=1がデフォルトとして使われることが設定から分かる。つまり普段何も考えずにジョブを投げていても、実は毎回optimization_level=1でtranspileされている。
Mirror回路とは
Mirror回路は、ある回路$U$の後ろにその逆回路$U^\dagger$をそのままつなげただけの回路。ユニタリ演算子として見ると
$$U^\dagger U = I$$
なので、雑音のない理想的な環境で実行すれば、初期状態(例えば$|00\ldots0\rangle$)のまま何も変化せずに戻ってくるはずである。実機は個々のゲートに誤差(雑音)を含むため、$U$と$U^\dagger$を通した後の状態は理想的なIdentityから少しずつズレていく。このズレ(=初期状態のまま観測される確率、往復忠実度)を測ることで、回路の理想分布をいちいち解析的に計算しなくても、回路の複雑さ・深さに応じて誤差がどれだけ蓄積するかを評価できるため、実機ベンチマークの手法としてよく使われる。
ただしこれが成り立つのは、実機に送られる回路が本当に$U \to U^\dagger$のままである場合に限られる。transpileの最適化パスは回路の意味(ユニタリ)を保ったまま簡略化を行うので、$U$の末尾のゲートと$U^\dagger$の先頭のゲートのように隣接する逆ゲート対を見つけると、それらを相殺して消してしまう。相殺が進むと実機に送られる回路自体がほとんど何もしない(ほぼIdentityな)ものに削られてしまい、実機の雑音を受ける前に回路そのものが短くなってしまう。この場合、実機にどれだけ強い雑音があっても測定結果はほぼ100%初期状態に留まり、「回路は健全に見えるが実際には雑音を測れていない」という見せかけの健全性が起こり得る。
これを避ける一般的な方法としては、$U$と$U^\dagger$の境目に最適化を止める「barrier」を挿入する手がよく知られているが、OQTOPUS側(Qiskitベースのtranspiler)でbarrierがどう扱われるかは今回確認していない。もう一つの方法として、最適化パス自体を丸ごと止めるoptimization_level=0を使う手も考えられるが、これも実際に効くかどうかは試してみないと分からない。
というわけで実機と同じ実行経路のシミュレータで確かめてみる。回路はH→CX(0,1)→CX(0,1)→Hという、数学的には恒等演算(H・CX・CX・H = I)になる「往復」回路。
from quri_parts.circuit import QuantumCircuit
from quri_parts_oqtopus.backend import OqtopusSamplingBackend
def build_mirror_circuit() -> QuantumCircuit:
circuit = QuantumCircuit(2)
circuit.add_H_gate(0)
circuit.add_CNOT_gate(0, 1)
circuit.add_CNOT_gate(0, 1)
circuit.add_H_gate(0)
return circuit
backend = OqtopusSamplingBackend()
for level in (0, 1, 2, 3):
transpiler_info = {
"transpiler_lib": "qiskit",
"transpiler_options": {"optimization_level": level},
}
job = backend.sample(
build_mirror_circuit(), device_id="qulacs", shots=1000,
transpiler_info=transpiler_info,
)
result = job.result()
stats = job.job_info["transpile_result"]["stats"]
print(level, result.counts, stats["after"])
結果は以下の通り。恒等演算なので、どのlevelでも測定結果は理論通り|00>(bits=0)に完全に集中した。
| optimization_level | counts | n_gates(after) | depth(after) |
|---|---|---|---|
| 0 | {0: 1000} |
10 | 9 |
| 1 | {0: 1000} |
2 | 1 |
| 2 | {0: 1000} |
2 | 1 |
| 3 | {0: 1000} |
2 | 1 |
optimization_level=0のtranspile後回路を見ると、H→RZ/SX基底への変換とCX→CXは残ったまま(相殺されていない)。一方optimization_level=1以降は、測定以外すべて消えて完全にIdentityへ潰れる。
つまり、懸念していた通りの挙動が実機と同じ動作環境でも確認できた: デフォルト(level=1)のままmirror回路の往復忠実度を測ると、実際の誤差に関わらず常に忠実度1.0が出てしまう「見せかけの健全性」が発生する。回避策として挙げたoptimization_level=0は、少なくともこのbackend環境では実際に効くことが確認できた(barrierを使う方法については未検証)。
段階的な圧縮も確認する(冗長GHZ回路)
mirror回路は恒等演算なので「level=0(潰れない)」と「level>=1(完全に潰れる)」の二値的な挙動しか見えない。optimization_levelを上げるほど段階的に圧縮されていく様子を見るために、わざと冗長なゲートを仕込んだ3qubitのGHZ回路を用意した。
def build_redundant_ghz_circuit() -> QuantumCircuit:
circuit = QuantumCircuit(3)
circuit.add_H_gate(0)
circuit.add_X_gate(0)
circuit.add_X_gate(0) # 隣接ペア(単純な相殺で消えるはず)
circuit.add_Z_gate(0)
circuit.add_CNOT_gate(0, 1)
circuit.add_Z_gate(0) # CXを挟んだZ・Z(理由は後述、可換性を使って消せるはず)
circuit.add_CNOT_gate(1, 2)
circuit.add_Y_gate(2)
circuit.add_Y_gate(2) # 隣接ペア(単純な相殺で消えるはず)
return circuit
理論上、この回路はH(0)→CX(0,1)→CX(1,2)という素のGHZ状態生成回路と完全に等価。X・X=I、Y・Y=Iはゲートが隣接しているのでそのまま相殺だと分かりやすいが、CX(0,1)を挟んだ2つのZ(0)(制御qubit側)が消える理由は少し補足が要る。
CX(0,1)は「qubit0(制御)が$|0\rangle$か$|1\rangle$かに応じて、qubit1に何もしないかXを掛けるかを切り替える」ゲートで、この判定はqubit0が計算基底($|0\rangle$・$|1\rangle$)のどちらかということだけで決まる。一方Zは$Z|0\rangle=|0\rangle$、$Z|1\rangle=-|1\rangle$で、qubit0を$|0\rangle$と$|1\rangle$の間で入れ替えたりはせず位相を付けるだけなので、CX(0,1)の前にZを置いても後に置いても、CXの「制御qubitが$|0\rangle$か$|1\rangle$か」という判定結果自体は変わらない。つまり$Z(0)$とCX(0,1)は可換で、
$$Z(0) \cdot \mathrm{CX}(0,1) \cdot Z(0) = \mathrm{CX}(0,1) \cdot Z(0) \cdot Z(0) = \mathrm{CX}(0,1) \cdot I = \mathrm{CX}(0,1)$$
のように、後ろの$Z(0)$をCXの反対側まですり抜けさせて手前の$Z(0)$と隣り合わせにできれば、$Z \cdot Z = I$で相殺できる。ただし回路の見た目上は2つの$Z(0)$の間にCX(0,1)が挟まっているため、「隣接するゲート同士をそのまま相殺する」だけの単純な最適化では検出できず、「ゲートを可換な相手の向こう側まで動かしてから相殺する」処理が必要になる。これがどのoptimization_levelから効き始めるかを見たい、というのがこの回路を仕込んだ狙い。
というわけで、どれだけ最適化されても(=どのoptimization_levelでも)測定結果は「000」「111」がほぼ半々になるはず。
for level in (0, 1, 2, 3):
transpiler_info = {"transpiler_lib": "qiskit", "transpiler_options": {"optimization_level": level}}
job = backend.sample(build_redundant_ghz_circuit(), device_id="qulacs", shots=2000, transpiler_info=transpiler_info)
result = job.result()
...
結果:
| optimization_level | n_gates(after) | depth(after) | P(|000>または|111>) |
|---|---|---|---|
| 0 | 55 | 35 | 1.000 |
| 1 | 21 | 9 | 1.000 |
| 2 | 18 | 10 | 1.000 |
| 3 | 8 | 6 | 1.000 |
期待通り、optimization_levelを上げるほど段階的にゲート数・深さが圧縮され、かつどのlevelでも正しさ(理論分布との一致)は変わらないことが確認できた。中身をもう少し追うと:
-
level 0→1(55→21): 最も大きい落差。level=0はqubitの初期配置(layout)が単純なため、回路が使う3量子ビット(仮想qubit0,1,2)のうち1と2にあたる部分がデバイス上で隣接しておらず、SWAP相当のゲートが挿入されていた(2量子ビットゲート数が回路本来の2個から5個に増えていた)。level=1以降はlayout選択自体が賢くなり、SWAPなしで済む配置(コネクティビティ上で連結した3量子ビット)が選ばれるようになる。ここに
X・X/Y・Yの単純な隣接ゲート相殺も加わり、大きく減っている。 -
level 1→2(21→18): 2量子ビットゲート数は2のまま変わらず、1量子ビットゲートだけが19→16に減った。前述の「
CX(0,1)を挟んだ2つの$Z(0)$」を可換性で相殺する処理は、level 1ではなくlevel 2から効き始めているらしいことが実測から読み取れる(Qiskitのパス構成まで追ってはいないが、挙動としてはCommutativeCancellation系のパスがlevel 2以降で有効になっている、という理解と整合する)。 - level 2→3(18→8): 最も踏み込んだ最適化。2量子ビットゲート周りの再合成が働き、最終的には元の回路が本来必要とする2個の2量子ビットゲートまで戻り、周辺の1量子ビット補正もほぼ刈り込まれた。
optimization_level=0〜3の実際のtranspile後回路を並べると、段階的に圧縮されていく様子が一目瞭然。
まとめ
- OQTOPUS backendの
tranquはpipライブラリではなく、Tranquライブラリをラップした「Tranqu Server」というgRPCマイクロサービスとしてEngineに組み込まれている -
transpiler_infoを省略した場合のデフォルトはtranspiler_lib=qiskit, optimization_level=1(coreの設定ファイルで確認) - mirror回路(H-CX-CX-H)は
optimization_level=0でのみ回路が残り、>=1では完全にIdentityへ潰れることを実機と同じ動作環境で確認した。デフォルト(level=1)のままmirror回路の往復忠実度を測ると「見せかけの健全性」が発生し得ることが実測で裏付けられた - 冗長ゲートを仕込んだGHZ回路では、optimization_levelを上げるほど段階的にゲート数が圧縮され(55→21→18→8)、かつどのlevelでも測定結果の正しさは変わらないことを確認した
今回のバックエンドはqulacsシミュレータだが、実機とほぼ同じ実行経路に載せているため、実機でも同じ結果になると考えている。









