Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

コンパイルとトランスパイル: 内部の仕組み

タグ: tutorial

このチュートリアルではQamomileの@qkernelがどのような処理フローを経て、Python関数から量子回路へと変換されるのかを、コンパイラの内部の視点から見ていきます。ユーザーが見るのは@qkernelを書き、transpiler.transpile(...)を呼び、executableを受け取る、という流れです。この章ではそのブラックボックスを開きます。

対象読者はコントリビュータ、つまり以下のようなニーズを持つ方です:

小さな@qkernelTranspilerのステップ実行用公開APIでパイプラインを1段ずつ通し、各ステップで中間表現を確認します。そして同じプランを2つのバックエンド(QiskitとQURI Parts)がどのように異なる回路へ変換するかを比較します。

# 最新のQamomileをpipからインストールします!
# !pip install qamomile
# # or
# !uv add qamomile

1. パイプラインの全体像

Transpiler.transpile()qamomile/circuit/transpiler/transpiler.pyに、10個のパスの合成として記述されています。各ステージはフロントエンド → インライン化 → 解析 → emissionという4つの帯に分かれ、**BlockKind**の遷移で区切られます:

QKernel
   │  to_block                    (トレーシング: Python AST → IR)
   ▼
Block [HIERARCHICAL]
   │  substitute                  (ルールベースの置換、オプション)
   │  resolve_parameter_shapes    (Vectorのshape次元を具体化)
   │  inline                      (CallBlockOperationsを展開)
   ▼
Block [AFFINE]
   │  unroll_recursion            (inline ↔ partial_evalの反復)
   │  affine_validate             (アフィン型のセーフティネット)
   │  partial_eval                (定数畳み込み + コンパイル時if)
   │  analyze                     (依存グラフ + I/O検証)
   ▼
Block [ANALYZED]
   │  validate_symbolic_shapes    (未解決のVector次元を拒否)
   │  plan                        (C→Q→Cにセグメント化)
   ▼
ProgramPlan
   │  emit                        (バックエンド固有のコード生成)
   ▼
ExecutableProgram[T]

どのパスも冪等で、Transpilerの公開メソッドとして公開されています。そのため1つずつ実行して、間でBlockを出力できます。これがQamomileで最も役立つデバッグ手法です。

2. IRの用語

パスを実行する前に、出力することになる対象に名前を付けておきましょう。

Block

Block (qamomile.circuit.ir.block) はパイプラインを流れるコンテナです。以下を保持します:

  • operations: Operationインスタンスの順序付きリスト

  • input_values / output_values: カーネルのシグネチャに対応するSSAのValue

  • parameters: 未バインドのパラメータ名からValueへの辞書

  • kind: どの不変条件が現在成立しているかを示すBlockKindタグ(TRACEDHIERARCHICALAFFINEANALYZED

BlockKind

BlockKindはパイプラインのステートマシンです。各パスはkindに対する事前条件を持ち、成功時にkindを進めます。進行は単調です:

TRACED  →  HIERARCHICAL  →  AFFINE  →  ANALYZED

余談: なぜ「AFFINE」と呼ぶのか

プログラミング言語の型理論では、ある値を何回使ってよいかで型を3種類に分けます:

区分使用回数
Unrestricted(通常の型)0回以上、何度でもPythonのint、古典ビット。コピー可能な値。
Affine高々1回(使わなくてもよい)量子ビット
Linearちょうど1回(捨てるのも禁止)「必ず消費せよ」と強制したい値

量子の場合、no-cloning theorem(量子状態は複製できない)によって「同じ値を2回使う」のは物理的に不可能です。つまりqqmc.h(q)で消費したら、その古いqはもう使えず、新しい版q'が生まれる — これがまさにaffine(高々1回)の定義に一致します。

ちなみに「使わずに捨てる」ことは許容されます(q = qmc.h(q)のあとqを返さず捨ててもエラーにはならない。最終的には測定で消費されるのが健全ですが、型制約としては"linear"ほど厳しくない)。そのためQamomileは"linear"ではなく"affine"という呼び方を選んでいます。

BlockKind.AFFINEは、このaffine型不変条件(「各量子値は高々1回だけ使われている」)が検証可能な状態でブロックが仕上がっていることを意味します。実際の検証はAffineValidationPassが担当し、違反するとAffineTypeErrorが送出されます。

ValueOperation

Value (qamomile.circuit.ir.value) はSSAスタイルの型付き値です。Qubitに限らずFloatUIntBitなどすべての値がValueとして表現されます。ゲート適用や古典演算でその値が更新されるたびに、Value.next_version()が新しいValueを生成します。このときversionuuidは新しくなりますが、logical_idと型・メタデータは保たれます。

logical_idは「SSAのバージョンをまたいで同じ論理的な変数を指す」ための安定した識別子です。たとえばq = qmc.h(q)で新しいValueが作られても、元のqと同じlogical_idを持ちます。これは物理量子ビットへのマッピングではなく、IR上で「同じ変数の別バージョン」を結びつけるためのもので、FloatパラメータやBitなどにも同じ仕組みが使われます(バックエンドの物理量子ビット割り当ては後段のemitResourceAllocatorが決めます)。

メタデータで値をパラメータ(with_parameter("theta"))や定数(with_const(2.0))としてタグ付けできます。

Operationはオペレーション階層の基底クラスです。サブクラスには以下があります:

サブクラス用途ファイル
GateOperationHRXCX、…ir/operation/gate.py
MeasureOperation測定ir/operation/measurement.py
ForOperationIfOperationWhileOperation制御フローir/operation/control_flow.py
CallBlockOperation別のBlockの呼び出し(inlineで除去)ir/operation/call_block_ops.py

制御フロー系のOperationはすべてHasNestedOpsプロトコル(nested_op_lists() / rebuild_nested())を実装しているので、パスは各Operationの型を特別扱いせず、ループや分岐の本体へ統一的に踏み込めます。

どのOperationoperation_kindQUANTUMCLASSICALHYBRIDCONTROL)を報告します。これはplanステージがブロックを古典・量子・期待値のステップへセグメント化する際に使います。

import qamomile.circuit as qmc
from qamomile.circuit.ir import pretty_print_block
from qamomile.circuit.ir.block import BlockKind
from qamomile.circuit.ir.operation.call_block_ops import CallBlockOperation
from qamomile.circuit.ir.operation.control_flow import ForOperation
from qamomile.qiskit import QiskitTranspiler

transpiler = QiskitTranspiler()

3. 題材となるカーネル

出力できる程度に小さく、複数のステージを動かせる程度に豊かなカーネルが必要です:

  • ヘルパーとなる@qkernelinlineを動かすため)

  • qmc.range(n)を駆動するUIntパラメータ(partial_evalを動かすため)

  • 未バインドのまま残すFloatパラメータ(emitのパラメータ処理を動かすため)

@qmc.qkernel
def entangle_pair(q0: qmc.Qubit, q1: qmc.Qubit) -> tuple[qmc.Qubit, qmc.Qubit]:
    """ヘルパーサブルーチン。呼び出し元にインライン展開されます。"""
    q0 = qmc.h(q0)
    q0, q1 = qmc.cx(q0, q1)
    return q0, q1


@qmc.qkernel
def demo_kernel(n: qmc.UInt, theta: qmc.Float) -> qmc.Vector[qmc.Bit]:
    q = qmc.qubit_array(n, name="q")

    q[0] = qmc.h(q[0])
    for i in qmc.range(n - 1):
        q[i], q[i + 1] = entangle_pair(q[i], q[i + 1])
        q[i + 1] = qmc.rz(q[i + 1], theta)

    return qmc.measure(q)

n=3をコンパイル時にバインドし、thetaはバックエンドパラメータとして保持してトランスパイルします。

def summarise(block):
    """Blockのコンパクトなサマリー。各パスの後で呼び出します。"""
    by_kind = {}
    for op in block.operations:
        by_kind[type(op).__name__] = by_kind.get(type(op).__name__, 0) + 1
    return (
        f"kind={block.kind.name:13s} "
        f"ops={len(block.operations):>2d} "
        f"breakdown={by_kind}"
    )

1行サマリーでは十分でないときのために、qamomile.circuit.ir.pretty_print_blockBlockのMLIR風テキストダンプを返します。各パスの前後で何がどう変わったかを目で確認するには、こちらが最速です。depth引数でCallBlockOperationの展開深さを制御できるので、たとえばdepth=1なら「inlineが実行したら何が起こるか」を先取りして眺められます。

4. ステージごとのウォークスルー

各パスを手動で実行していきます。上のsummariseヘルパーがステージごとに1行ずつ出力するので、BlockKindとOperationの内訳を比較しやすくなります。pretty_print_blockはステージの詳細を追いたいタイミングで差し込んでください。

4.1 to_block — Python関数のトレーシング

to_blockはデコレート済み関数をトレーサコンテキスト下で実行します。qmc.h(...)qmc.range(...)entangle_pair(...)の各呼び出しはOperationとしてBlockに記録されます。他の@qkernelへの呼び出しはCallBlockOperationになり、本体はまだインライン展開されません。

bindings = {"n": 3}
parameters = ["theta"]

block = transpiler.to_block(demo_kernel, bindings=bindings, parameters=parameters)
pretty_print_block(block)
print("after to_block:   ", summarise(block))
print("parameters:       ", list(block.parameters))
print("CallBlockOps:     ", sum(1 for op in block.operations if isinstance(op, CallBlockOperation)))
# 注意: `CallBlockOperation`は`ForOperation`の本体内部にも存在しうるので、
# 必ずしもトップレベルのリストにあるとは限りません。
after to_block:    kind=HIERARCHICAL  ops= 5 breakdown={'QInitOperation': 1, 'GateOperation': 1, 'BinOp': 1, 'ForOperation': 1, 'MeasureVectorOperation': 1}
parameters:        ['theta']
CallBlockOps:      0

ブロックの中身を実際にテキストで眺めてみましょう。pretty_print_blockBlockをMLIR風のインデント付きテキストへ整形します。for本体にcall entangle_pair(...)がまだ生きていることが確認できます。

print(pretty_print_block(block))
block demo_kernel [HIERARCHICAL] (n: UIntType, theta: FloatType) -> BitType {
  parameters: [theta]
  %q@v0 = QInitOperation()
  %q[const(0)]@v1 = h(%q[const(0)]@v0)
  %_@v0 = const(3) - const(1)
  for %i in range(const(0), %_@v0, const(1)) {
    %_@v0 = %i@v0 + const(1)
    call entangle_pair(%q[%i@v0]@v0, %q[%_@v0]@v0) -> (%q[%i@v0]@v1, %q[%_@v0]@v1)
    %_@v0 = %i@v0 + const(1)
    %_@v0 = %i@v0 + const(1)
    %q[%_@v0]@v1 = rz(%q[%_@v0]@v0, θ=param(theta))
    %_@v0 = %i@v0 + const(1)
  }
  %q_measured@v0 = measure_vector(%q@v0)
}

depth=1を付けると、CallBlockOperationの先が呼び出し行のブレース内にインライン展開された形で表示されます。これは「inlineを1回通したらどうなるか」を先読みしているのと同じ見た目で、次の節の内容を予習できます。

print(pretty_print_block(block, depth=1))
block demo_kernel [HIERARCHICAL] (n: UIntType, theta: FloatType) -> BitType {
  parameters: [theta]
  %q@v0 = QInitOperation()
  %q[const(0)]@v1 = h(%q[const(0)]@v0)
  %_@v0 = const(3) - const(1)
  for %i in range(const(0), %_@v0, const(1)) {
    %_@v0 = %i@v0 + const(1)
    call entangle_pair(%q[%i@v0]@v0, %q[%_@v0]@v0) -> (%q[%i@v0]@v1, %q[%_@v0]@v1) {
      %q0@v1 = h(%q0@v0)
      %q0@v2, %q1@v1 = cx(%q0@v1, %q1@v0)
      return %q0@v2, %q1@v1
    }
    %_@v0 = %i@v0 + const(1)
    %_@v0 = %i@v0 + const(1)
    %q[%_@v0]@v1 = rz(%q[%_@v0]@v0, θ=param(theta))
    %_@v0 = %i@v0 + const(1)
  }
  %q_measured@v0 = measure_vector(%q@v0)
}

ブロックはHIERARCHICALです。他のブロックへの呼び出しや複合ゲートをまだ含む可能性があります。block.parametersは渡した引数parameters=["theta"]を反映しています。parametersない入力は、bindingsでバインドする(nのように)か、トレース時のPythonコードで消費されなければなりません。

4.2 inline — ネストしたブロック呼び出しの平坦化

inlineはすべてのCallBlockOperationを対象ブロックのOperationで置き換え、結果がwell-formedであり続けるようSSA値を置換します。CallBlockOperationが残らなくなるとブロックはAFFINEへ遷移します。

def count_calls(ops):
    total = 0
    for op in ops:
        if isinstance(op, CallBlockOperation):
            total += 1
        # ループ内の呼び出しも数えるため、ネストした制御フロー本体を再帰的に辿ります。
        for child in getattr(op, "nested_op_lists", lambda: [])():
            total += count_calls(child)
    return total


block = transpiler.inline(block)
print("after inline:     ", summarise(block))
print("CallBlockOps (deep):", count_calls(block.operations))
print("is_affine:        ", block.is_affine())
after inline:      kind=AFFINE        ops= 5 breakdown={'QInitOperation': 1, 'GateOperation': 1, 'BinOp': 1, 'ForOperation': 1, 'MeasureVectorOperation': 1}
CallBlockOps (deep): 0
is_affine:         True

再度pretty_print_blockで眺めると、call entangle_pair(...)が消え、h/cxが直接for本体に並んでいることが確認できます。ブロックのkindAFFINEへ進みました。

print(pretty_print_block(block))
block demo_kernel [AFFINE] (n: UIntType, theta: FloatType) -> BitType {
  parameters: [theta]
  %q@v0 = QInitOperation()
  %q[const(0)]@v1 = h(%q[const(0)]@v0)
  %_@v0 = const(3) - const(1)
  for %i in range(const(0), %_@v0, const(1)) {
    %_@v0 = %i@v0 + const(1)
    %q0@v1 = h(%q[%i@v0]@v0)
    %q0@v2, %q1@v1 = cx(%q0@v1, %q[%_@v0]@v0)
    %_@v0 = %i@v0 + const(1)
    %_@v0 = %i@v0 + const(1)
    %q[%_@v0]@v1 = rz(%q[%_@v0]@v0, θ=param(theta))
    %_@v0 = %i@v0 + const(1)
  }
  %q_measured@v0 = measure_vector(%q@v0)
}

inlineループをアンロールしないことに注目してください。ForOperationの本体には、元のentangle_pair本体内にあったforの各反復に対応するGateOperationが1つずつ入っています。インライン化は制御フローを保ちます。アンロールは次です。

4.3 partial_eval — 定数畳み込みとコンパイル時ifの除去

partial_evalは2つのサブパスから構成されます:

  1. ConstantFoldingPass — オペランドがすべて定数(またはバインド済みパラメータ)のBinOp/CompOpを具体値へ畳み込みます。n=3をバインドしたので、qmc.range(n - 1)n - 12に畳み込まれ、ForOperationの境界はリテラル値になります。

  2. CompileTimeIfLoweringPass — 条件がコンパイル時に解決可能なIfOperationを、選ばれた分岐のOperation列で置き換えます。測定結果に依存するIfOperationはここでは触りません。

なお、ForOperation自体はこのパスではアンロールされません。ループ展開は必要であれば後段のemitLoopAnalyzerが判定します(詳しくは5章)。そのためここでForOperationsの個数は減りません。

block = transpiler.partial_eval(block, bindings=bindings)
print("after partial_eval:", summarise(block))
print("ForOperations:    ", sum(1 for op in block.operations if isinstance(op, ForOperation)))
after partial_eval: kind=AFFINE        ops= 4 breakdown={'QInitOperation': 1, 'GateOperation': 1, 'ForOperation': 1, 'MeasureVectorOperation': 1}
ForOperations:     1

UIntを未バインドのまま残してループ境界に使うと、下流のvalidate_symbolic_shapesパスが該当する値の名前とともにQamomileCompileErrorを送出します。これは「このカーネルは実はコンパイル時に構造化されていない」という状況を、後段での分かりにくいクラッシュではなく読みやすいエラーへ変換することを担当するパスです。

4.4 analyze — 依存グラフとI/O検証

analyzeは値の依存グラフを構築し、2つの不変条件をチェックします:

  1. ブロックの入出力が古典であること(量子I/Oはエントリポイントではなくサブルーチンブロックでのみ許可されます)。

  2. OperationKind.QUANTUMのOperation が、古典型オペランドとして測定由来の古典値を受け取らないこと。より具体的には、rx(q, theta)thetaのようなゲートの古典引数が、測定結果から計算された値であってはいけません(rotation角などの古典計算をJITする必要が出るため)。

この規則は動的量子回路を禁止するものではありませんIfOperation/WhileOperationOperationKind.CONTROLなのでこのチェック対象外で、測定結果Bitを条件とする制御フロー(if bit: ... / while bit: ...)は通ります。また、Phiで合流した量子型の値も明示的に例外扱いです。動的量子回路と禁止パターンの具体的な違いは5章で扱います。

成功するとブロックはANALYZEDへ遷移します。

block = transpiler.analyze(block)
print("after analyze:    ", summarise(block))
after analyze:     kind=ANALYZED      ops= 4 breakdown={'QInitOperation': 1, 'GateOperation': 1, 'ForOperation': 1, 'MeasureVectorOperation': 1}

4.5 planProgramPlanへのセグメント化

planは解析済みブロックを辿り、OperationKindごとにOperationをグループ化し、ClassicalStep / QuantumStep / ExpvalStepのエントリからProgramPlanを組み立てます。デフォルトのトランスパイラが使うNisqSegmentationStrategyは**QuantumStepを多くても1つ**に制限します。これが典型的なC→Q→Cパターンです。

plan = transpiler.plan(block)
for i, step in enumerate(plan.steps):
    seg = step.segment
    print(f"  step {i}: {type(step).__name__} ({type(seg).__name__}, {len(seg.operations)} ops)")
print("total unbound parameters:", list(plan.parameters))
  step 0: QuantumStep (QuantumSegment, 4 ops)
total unbound parameters: ['theta']

量子セグメントはqubit_valuesnum_qubitsも持ちます。これによりemitはゲートを配置する前に、バックエンド回路が必要とする量子ビット本数を把握できます。

4.6 emit — バックエンド固有のコード生成

emitはプランを対象バックエンドのEmitPassに渡します。emitパスは具体的な量子ビットインデックスを割り当て、量子セグメントを辿ってバックエンドのGateEmitterプロトコルのメソッド(emit_hemit_rx、…)を呼び出してネイティブ回路を構築します。

executable = transpiler.emit(plan, bindings=bindings, parameters=parameters)
print("parameter_names:  ", executable.parameter_names)
print()
print(executable.quantum_circuit)
parameter_names:   ['theta']

     ┌───┐┌───┐                  ┌─┐                             
q_0: ┤ H ├┤ H ├──■───────────────┤M├─────────────────────────────
     └───┘└───┘┌─┴─┐┌───────────┐└╥┘┌───┐                  ┌─┐   
q_1: ──────────┤ X ├┤ Rz(theta) ├─╫─┤ H ├──■───────────────┤M├───
               └───┘└───────────┘ ║ └───┘┌─┴─┐┌───────────┐└╥┘┌─┐
q_2: ─────────────────────────────╫──────┤ X ├┤ Rz(theta) ├─╫─┤M├
                                  ║      └───┘└───────────┘ ║ └╥┘
q_3: ─────────────────────────────╫─────────────────────────╫──╫─
                                  ║                         ║  ║ 
c: 3/═════════════════════════════╩═════════════════════════╩══╩═
                                  0                         1  2 

残ったパラメータはまさに保持したもの(theta)です。量子ビット数、ループのアンロール、どのCXがどこに入るかといった構造的な決定はすべてコンパイル時に解決されました。

4.7 スキップしたパス

transpile()の一部でありながら明示的に呼ばなかったパスが5つあります:

  • substitute — ユーザーが設定したSubstitutionRuleを適用してブロックのターゲットを置換したり、複合ゲートの戦略を上書きします。TranspilerConfigにルールがない場合はno-opです。

  • resolve_parameter_shapesbindingsが具体的なVectorMatrix値を提供する場合、{name}_dim{i}のshape次元を埋めます。これにより下流でarr.shape[0]が具体的なUIntとして解決されます。

  • unroll_recursion — 自己再帰の@qkernel(例: Suzuki–Trotter、チュートリアル07参照)に対するinline ↔ partial_evalの固定点ループです。再帰が底まで展開されると終了し、bindingsでベースケースに到達できない場合はエラーになります。

  • affine_validate — フロントエンドのチェックをすり抜けたアフィン型違反を捕まえるセーフティネットです。

  • validate_symbolic_shapes — 未解決のVectorshape次元がForOperationの境界に到達した場合、実行可能なエラーメッセージで拒否します。

いずれも冪等かつ安価なので、transpile()は常にこれらを実行します。パスを書く側としては順序に注意するだけで十分です: substituteresolve_parameter_shapesinlineaffine_validateinlinevalidate_symbolic_shapesanalyze(依存グラフが使えるように)に実行されます。

5. 制御フロー (if / for / while) の取り扱い

パイプラインが制御フローをどう扱うかは、フロントエンドで何を受け付けるかから、各パスがそれをどう変形するか、そしてバックエンドが実行時分岐をサポートするかまで、複数のレイヤーに関わります。ここではその全体像を整理します。ユーザー向けの書き方はチュートリアル05にあり、本章はコンパイラ側の視点に絞ります。

5.1 フロントエンドで受け付ける形

@qkernelはトレース前にASTを書き換えます(qamomile/circuit/frontend/ast_transform.pyControlFlowTransformer)。ここで以下のように変換されます:

  • Pythonのif文 → emit_if(cond, true_branch, false_branch, ...)呼び出し

  • for文 → for_loop(start, stop, step)またはfor_items(dict)コンテキストマネージャ

このため、実行時に決まる量子レジスタ・測定結果・未バインドの値を素のPythonのif/forで使ってもトレース時に両分岐・各反復を実行する、という直感的な動作になります。対応するループ記法は:

  • qmc.range(n) — シンボリックなループ境界。nがコンパイル時に定まらなくてもForOperationとしてIRに残せます。

  • qmc.items(d) — 辞書・スパースデータ用。常にコンパイル時にアンロールされます(ForItemsOperation)。

  • 直接のfor i in <runtime_value>: — 拒否されます。必ずqmc.range(...)qmc.items(...)を経由してください。

whilewhile_loopコンテキストマネージャで書きます。条件は測定結果 (Bit) でなければならず、古典変数や定数を条件にすると後段のValidateWhileContractPassで拒否されます。

5.2 IR表現

qamomile/circuit/ir/operation/control_flow.pyに以下が定義されています:

Operationネストリスト条件・境界特記事項
ForOperationoperations(本体)operands = [start, stop, step](いずれもUIntloop_var名を持つ
ForItemsOperationoperations(本体)operands[0]DictValue常にコンパイル時アンロール
IfOperationtrue_operations, false_operationsoperands[0]Bitphi_opsで分岐後の値マージ
WhileOperationoperations(本体)operands[0](初期条件), operands[1](ループキャリー条件)測定結果Bit必須、max_iterationsヒント可

4つともHasNestedOpsを実装しているので、パスはnested_op_lists() / rebuild_nested()経由で本体へ再帰的に入れます。isinstanceのチェーンは書かないのが流儀です。

IfOperationには値をマージするPhiノード (PhiOp) が付きます。両分岐で同じ論理量子ビット・古典変数を異なるバージョンで更新した場合、分岐後に使う側はPhi経由でどちらのバージョンなのかを参照します。

5.3 パスごとの挙動

制御フローは各パスで次のように扱われます:

パスIfOperationForOperationWhileOperation
inline両分岐の本体へ再帰本体へ再帰本体へ再帰
partial_eval条件が定数なら選ばれた分岐で置換CompileTimeIfLoweringPass)。測定結果条件なら保持境界のBinOpは畳み込まれる。アンロールはしない何もしない(ここでは変形対象外)
analyzePhiが依存グラフに反映されるloop_varが本体の依存に入る測定結果条件を量子オペランドと同様に扱う
validate_symbolic_shapes未解決のVectorshape次元が境界にあると拒否
planOperationKind.CONTROLとしてセグメント境界を作る同左同左
emit実行時ifとして出力(バックエンドが対応していれば)LoopAnalyzer.should_unroll()で判定し、必要ならアンロール実行時whileとして出力

LoopAnalyzer.should_unroll()transpiler/passes/emit_support/loop_analyzer.py)の判定基準は:

  1. ループ境界が外側のループ変数に依存する(動的入れ子)

  2. 本体で配列をloop_varでインデックスしている(例: q[i]

  3. loop_varBinOpに現れる(例: i + 12 * i

本チュートリアルのdemo_kernelq[i]q[i + 1]の両方を使うので、条件1, 2, 3に該当してemit時にアンロールされます。これがexecutable.quantum_circuitがフラットな2量子ビット分のCX列になっている理由です。上記のどれにも該当しないループは、バックエンドが対応している限り実行時ループとして回路に残ります。

5.4 量子と古典の依存関係ルール (analyze)

analyzeパスは、量子Operationは測定から派生した古典値に依存してはならないという不変条件を強制します。

# OK: 測定結果Bitを条件に量子ゲートを実行
b = qmc.measure(q)
if b:
    q = qmc.x(q)

# NG: 測定結果から計算した古典値を量子ゲートの引数に使う
b = qmc.measure(q)
x = some_classical(b)
q = qmc.rx(q, x)   # analyzeで拒否

前者は測定結果BitIfOperationの条件として直接使うだけで、量子オペランドの型は変わりません(位相キックバック的な制御は行いません)。後者はJITコンパイルが必要になり、現時点ではサポートしていません。planステージが量子セグメントを1つに制限することがこの保証の裏返しです。

5.5 バックエンドの実行時分岐サポート

実行時のif/while(=測定結果に依存する分岐)が回路まで落ちてくるかはバックエンドのMeasurementModeに依存します(qamomile/circuit/transpiler/gate_emitter.py):

モード実行時if/while用例
NATIVEサポート。条件付きゲートを明示的にemitQiskit(QuantumCircuit.if_test 等)
STATIC非サポート。測定前の状態ベクトル・演算子を返すQURI Parts
RUNNABLEフルサポート。実行時ループ/分岐も含むCUDA-Q (cudaq.run()経由)

非対応モードのバックエンドでIfOperation/WhileOperationを含むカーネルをtranspileしようとすると、emitパスがエラーを送出します。モードを意識してカーネル側で実行時分岐を書くかどうか決めるのがコントリビュータの責任です。

5.6 よくあるエラー

  • ValidationError (analyze) — 測定から派生した古典値を量子ゲートの引数に使った。パターンを書き換えるか、測定の代わりに状態を保つように設計を見直してください。

  • ValidateWhileContractPassエラーwhileの条件が測定結果Bitでない。Pythonの古典変数や定数条件でのループは未サポートです。

  • QamomileCompileError (validate_symbolic_shapes)ForOperationの境界に未解決のVector shape次元が届いた。該当するVectorbindingsで具体化するか、qmc.itemsを使う設計に変えてください。

  • emit時エラーMeasurementMode.STATICのバックエンドに実行時ifが到達した。バックエンドを変えるか、カーネルを別の等価表現で書き直します。

6. ケーススタディ: MeasureQFixedはどうコンパイルされるか

量子位相推定 (QPE) の結果のように、量子ビット列を固定小数点の数値として読み出したい場面があります。QamomileはVector[Qubit]QFixed型へ再解釈し、それをqmc.measureに渡すとFloatが返る、という1行APIを提供しています:

qf = qmc.cast(q, qmc.QFixed, int_bits=0)   # Vector[Qubit] -> QFixed
phase = qmc.measure(qf)                     # QFixed -> Float

これがパイプラインを通るとき、IRレベルで何が起きているのかを追います。MeasureQFixedOperationOperationKind.HYBRID(量子測定 + 古典デコードを1つにまとめた論理操作)として登場し、後段で量子部分と古典部分に分解される、というのが要点です。

6.1 小さなデモカーネル

3量子ビット分のHadamard重ね合わせをQFixedとして測定するだけの最小例を用意します。

@qmc.qkernel
def qfixed_demo(n: qmc.UInt) -> qmc.Float:
    q = qmc.qubit_array(n, name="q")
    for i in qmc.range(n):
        q[i] = qmc.h(q[i])
    qf = qmc.cast(q, qmc.QFixed, int_bits=0)
    return qmc.measure(qf)

analyzeまでパスを進めて、直後の状態を見てみます。

qfixed_bindings = {"n": 3}
qfixed_block = transpiler.to_block(qfixed_demo, bindings=qfixed_bindings)
qfixed_block = transpiler.inline(qfixed_block)
qfixed_block = transpiler.partial_eval(qfixed_block, bindings=qfixed_bindings)
qfixed_block = transpiler.analyze(qfixed_block)
print(pretty_print_block(qfixed_block))
block qfixed_demo [ANALYZED] (n: UIntType) -> FloatType {
  %q@v0 = QInitOperation()
  for %i in range(const(0), const(3), const(1)) {
    %q[%i@v0]@v1 = h(%q[%i@v0]@v0)
  }
  %q_as_qfixed@v0 = cast %q@v0 to QFixed[0.3]
  %qfixed_measured@v0 = measure_qfixed(%q_as_qfixed@v0)
}

末尾はmeasure_qfixedという1行です — Vector[Qubit]QFixed型へcastした値にそのままmeasureが掛かっています。このOperationはoperation_kind=HYBRIDを持っていますが、実際のバックエンドには量子測定命令しかないので、どこかで「量子側の測定」と「古典側のデコード」に切り分ける必要があります。

6.2 どこで切り分けるか — plan段の事前ローワリング

この分解を担当するのがplan段が呼び出すlower_operations()qamomile/circuit/transpiler/passes/separate.py)です。MeasureQFixedOperationを見つけるたびに:

  1. MeasureVectorOperation — QFixedが持つ全量子ビットをVector[Qubit]として測定しVector[Bit]を得る(OperationKind.HYBRIDだが、セグメント化の観点では量子セグメントの末尾に置かれる)

  2. DecodeQFixedOperation — 得られたビット列を固定小数点でデコードしてFloatにする(OperationKind.CLASSICAL

という2つのOperationに置き換えます。planの本題であるセグメント化は、このローワリング結果をそのまま「量子セグメント終わりに測定、その後に古典セグメントでデコード」として並べるだけです。

内部挙動を目で見たいときはlower_operationsを直接呼べます。

from qamomile.circuit.transpiler.passes.separate import lower_operations

lowered = lower_operations(qfixed_block)
print(pretty_print_block(lowered))
block qfixed_demo [ANALYZED] (n: UIntType) -> FloatType {
  %q@v0 = QInitOperation()
  for %i in range(const(0), const(3), const(1)) {
    %q[%i@v0]@v1 = h(%q[%i@v0]@v0)
  }
  %q_as_qfixed@v0 = cast %q@v0 to QFixed[0.3]
  %qfixed_bits@v0 = measure_vector(%qfixed_qubits@v0)
  %qfixed_measured@v0 = decode_qfixed(%qfixed_bits@v0)
}

measure_qfixedが消え、measure_vector(...)decode_qfixed(...)の2行に展開されています。同じFloatがresult側のlogical_idで繋がったままなので、後段の依存解析や値バインドは影響を受けません。

6.3 emit と実行への反映

ローワリング後、ProgramPlanは概念的に以下のようなステップを並べます:

  • QuantumStep(量子セグメント): 回路本体のゲート列 + MeasureVectorOperation。バックエンドのemit_measure_vectorが展開し、各量子ビットごとにemit_measureが呼ばれてビットがクラシカルレジスタに書かれます。

  • ClassicalStep (role=post)(古典セグメント): DecodeQFixedOperationが1つだけ。qamomile/circuit/transpiler/classical_executor.pyのランタイムが、量子実行で得たビット列を受け取ってFloatに変換します。

つまり量子ハードウェア側に届くのはあくまで通常の測定命令です。「QFixed測定」というAPIはコンパイル時の抽象化であって、実行時のバックエンドが何か特殊な命令を解釈するわけではありません。

6.4 どのパスがどのIRを触るかのまとめ

パスMeasureQFixedOperationMeasureVectorOperationDecodeQFixedOperation
to_block生成
inline / partial_eval / analyzeそのまま通過
plan (pre-segmentation lowering)2つに分解して消えるここで生成ここで生成
emit各量子ビットへemit_measure触らない(古典ステップ用のIRなのでバックエンドコード生成対象外)
実行時バックエンド実行器で測定classical_executorがFloatにデコード

この構図は、CastOperation(型の再解釈だけで物理量子ビット割り当てを変えない)と合わせて、「量子リソースを触らずに古典的意味付けだけを変える」Qamomileの型設計パターンの良い例になっています。

7. バックエンドemission: Qiskit vs QURI Parts

どのバックエンドも、qamomile/circuit/transpiler/で定義された2つのプロトコルを実装することでパイプラインに接続します:

  • GateEmitter[T] (gate_emitter.py): 「ゲートをどう描くか」のAPIです。create_circuit(num_qubits, num_clbits) -> Tcreate_parameter(name) -> Any、ゲートごとの約40個のエントリポイント(emit_hemit_rxemit_cx、…)を持ちます。加えてmeasurement_mode: MeasurementModeを告知します:

    モード意味利用バックエンド
    NATIVEemitパスが呼ぶ明示的な測定命令をバックエンドが持つ。Qiskit
    STATICバックエンドは測定前の状態ベクトル・演算子を受け取り、samplerが測定を外部で処理する。QURI Parts
    RUNNABLEバックエンドがランタイム制御フロー付きのmid-circuit測定をサポートする。CUDA-Q (cudaq.run()経由)
  • CompositeGateEmitter[C] (passes/emit.py): オプションです。バックエンドが複合ゲート(QFT、QPE、…)をネイティブ実装でショートカットできるようにします。can_emit(gate_type) -> bool / emit(...) -> boolのコントラクトで、オプトアウトするにはFalseを返します。その場合emitパスはライブラリレベルの分解にフォールバックします。

Transpilerのサブクラスは_create_segmentation_pass_create_emit_passをオーバーライドし、ランタイム側のためにexecutor()も実装することでこれらを接続します。qamomile/qiskit/transpiler.pyは約50行の標準的なリファレンス実装です。

では同じカーネルをQURI Parts経由でトランスパイルして比較してみましょう。QURI Partsはオプション依存です。ローカルで再現するにはpip install 'qamomile[quri_parts]'でインストールしてください。

try:
    from qamomile.quri_parts import QuriPartsTranspiler

    quri_transpiler = QuriPartsTranspiler()
    quri_exe = quri_transpiler.transpile(
        demo_kernel, bindings=bindings, parameters=parameters
    )

    print("backend circuit type: ", type(quri_exe.quantum_circuit).__name__)
    print("parameter_names:      ", quri_exe.parameter_names)
    print()
    for gate in quri_exe.quantum_circuit.gates:
        print(" ", gate)
except ModuleNotFoundError:
    # ``qamomile[quri_parts]``はオプション依存のグループなので、インストールされていない
    # 場合は並列比較をスキップし、このnotebookが引き続き実行できるようにします。
    print("QURI Parts is not installed; skipping the side-by-side output.")
backend circuit type:  LinearMappedParametricQuantumCircuit
parameter_names:       ['theta']

  QuantumGate(name='H', target_indices=(0,), control_indices=(), classical_indices=(), params=(), pauli_ids=(), unitary_matrix=())
  QuantumGate(name='H', target_indices=(0,), control_indices=(), classical_indices=(), params=(), pauli_ids=(), unitary_matrix=())
  QuantumGate(name='CNOT', target_indices=(1,), control_indices=(0,), classical_indices=(), params=(), pauli_ids=(), unitary_matrix=())
  ParametricQuantumGate(name='ParametricRZ', target_indices=(1,), control_indices=(), pauli_ids=())
  QuantumGate(name='H', target_indices=(1,), control_indices=(), classical_indices=(), params=(), pauli_ids=(), unitary_matrix=())
  QuantumGate(name='CNOT', target_indices=(2,), control_indices=(1,), classical_indices=(), params=(), pauli_ids=(), unitary_matrix=())
  ParametricQuantumGate(name='ParametricRZ', target_indices=(2,), control_indices=(), pauli_ids=())

指摘すべき違いは3つあります:

  1. 回路の型。 QiskitはParameterオブジェクトを埋め込んだQuantumCircuitをemitします。QURI PartsはパラメータがQURI PartsのParameterインスタンスであるLinearMappedParametricQuantumCircuitをemitします。どちらもQamomileのparameter_namesを同じ形で往復します。

  2. 測定。 Qiskitの回路はmeasure命令で終わります(measurement_mode=NATIVE)。QURI Partsの回路は測定ゲートを持ちません。サンプリングは実行時にexecutorが処理します(measurement_mode=STATIC)。

  3. 複合ゲート。 カーネルがqmc.qft(...)を使う場合、QiskitのQiskitQFTEmitterQFTGateボックスを配置しますが、QURI Partsバックエンドはライブラリパス経由で分解します。IRは同じですが、実現される回路は異なります。カーネルごとにTranspilerConfig.with_strategies({"qft": "approximate"})で上書きできます。

8. コントリビュータ向けのポインタ

カスタムパスの記述。 qamomile/circuit/transpiler/passes/に配置し、Blockを受け取りBlockを返すようにして、入力kindの事前条件を冒頭でアサートしてください。Operationを辿るときはisinstance(op, ForOperation)のチェーンではなくHasNestedOpsを使ってください。そうすれば将来の制御フローOperationも自動的に扱えます:

def rewrite(ops):
    new_ops = []
    for op in ops:
        if hasattr(op, "nested_op_lists"):
            op = op.rebuild_nested([rewrite(child) for child in op.nested_op_lists()])
        new_ops.append(transform(op))
    return new_ops

新しいバックエンドの追加。 最低限のチェックリスト:

  1. 対象SDK向けにGateEmitter[T]を実装します(TはSDKの回路型)。qamomile/qiskit/emitter.pyから始めるとよいでしょう。

  2. Transpiler[T]をサブクラス化し、_create_segmentation_pass(他に必要がなければNisqSegmentationStrategyを使用)と、StandardEmitPass(your_emitter)を返す_create_emit_passを実装します。

  3. ユーザーがexecutor()を呼べるようにQuantumExecutor[T]のサブクラスを実装します。

  4. オプション: emitされた回路で高レベル構造を保つため、QFT/QPEなどのCompositeGateEmitterを追加します。

transpileエラーのデバッグ。 パスを1つずつ実行し、その間にsummarise(block)で件数の変化を追い、気になるところはpretty_print_block(block)で中身を覗きます。BlockKindが進まない、Operation数が爆発する、例外が送出される、というステージが最初に見るべき場所です。pretty_print_block(block, depth=N)CallBlockOperationの展開深さを変えながらinline前後を比較すると、どこで値が切れたか・どのPhiが漏れたかが読み取りやすくなります。

9. まとめ

パイプラインは4つのkindを遷移していくSSAスタイルのIRです:

  • HIERARCHICAL — 生のトレース、ブロック呼び出しが未展開

  • AFFINE — フラットなOperationと制御フロー、ブロック呼び出しなし

  • ANALYZED — 検証済み、依存グラフ化済み、セグメント化可能

  • ProgramPlanExecutableProgram[T] — セグメント化されemit済み

各パスは限定的な仕事を持ち、BlockKindに対する事前条件を持ちます。Transpilerのステップ実行用APIはすべてのパスを公開しています。カーネルが期待通りに動かないときの主たるデバッグツールとして、またパスやバックエンドを追加するときの拡張点として活用してください。

制御フローの要点:

  • if/forはトレース時にASTが書き換えられ、IfOperation / ForOperation / ForItemsOperation / WhileOperationというIRに変換される

  • partial_evalはコンパイル時ifを除去するが、forのアンロールはemitLoopAnalyzerが判定する

  • analyzeは「量子Operationが測定由来の古典値に依存しないこと」を保証する

  • 実行時分岐を回路まで落とせるかはバックエンドのMeasurementMode次第(NATIVERUNNABLEが必要)