0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OQTOPUS local backendでトランスパイラ(Tranqu)を試してみた

0
Last updated at Posted at 2026-08-08

はじめに

前回の記事で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_stepmulti_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_steptranqu_addressで指定したアドレスへgRPCでリクエストし、transpiler_info省略時のデフォルトとしてtranspiler_lib=qiskit, optimization_level=1を使う
  • device_gateway_stepgateway_addressで指定したアドレスへgRPCでリクエストする

ジョブ投入時にtranspiler_infoを明示的に指定しなければ、transpiler_lib=qiskitoptimization_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)になる「往復」回路。

tranqu_mirror_source_circuit.png

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

tranqu_mirror_gatecount.png

optimization_level=0のtranspile後回路を見ると、H→RZ/SX基底への変換とCX→CXは残ったまま(相殺されていない)。一方optimization_level=1以降は、測定以外すべて消えて完全にIdentityへ潰れる。

tranqu_mirror_circuit_level0.png

tranqu_mirror_circuit_level1.png

つまり、懸念していた通りの挙動が実機と同じ動作環境でも確認できた: デフォルト(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

tranqu_compression_source_circuit.png

理論上、この回路はH(0)→CX(0,1)→CX(1,2)という素のGHZ状態生成回路と完全に等価。X・X=IY・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

tranqu_compression_gatecount.png

期待通り、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=03の実際のtranspile後回路を並べると、段階的に圧縮されていく様子が一目瞭然。

tranqu_compression_circuit_level0.png

tranqu_compression_circuit_level1.png

tranqu_compression_circuit_level2.png

tranqu_compression_circuit_level3.png

まとめ

  • 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シミュレータだが、実機とほぼ同じ実行経路に載せているため、実機でも同じ結果になると考えている。

参考文献

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?