この記事は国産NPUに未対応モデルを移植した話から派生したコンセプト解説です。あちらの記事が「実際にどう突破したか」なら、こちらの記事はその土台にある**「モデルをコンパイルするとは一体どういう意味なのか」**を最初から解きほぐします。ディープラーニングを少し知っている方なら無理なく読めるように書きました。
「コンパイル」というおなじみの言葉から
プログラミングにおけるコンパイルとは、人が読むソースコード(C、Rustなど)を、CPUがそのまま実行する機械語へとあらかじめ翻訳する工程です。翻訳を先に済ませておく代わりに、実行時は速くなります。
ニューラルネットのコンパイルもまったく同じ発想です。PyTorchで書いたモデルを、特定のアクセラレータ(GPU・NPUなど)がそのまま実行できる形へとあらかじめ翻訳しておきます。たった一つ違うのは、翻訳の出発点です。
- 一般的なコンパイラの入力 = テキストのソースコード
- ニューラルネットコンパイラの入力 = 演算グラフ(computation graph)
演算グラフとは、「このテンソルに行列積 → 次にアテンション → 次に正規化 → …」のように、テンソル演算がつながった有向グラフです。モデルのforward()が行うことを図として展開したもの、と考えればよいでしょう。
ただし「一直線に流れるパイプライン」は教科書に出てくる単純なモデルの話であって、実際のモデルは複数のモジュールが分岐し合流するグラフです。たとえば画像編集モデル(Qwen-Image-Edit系)は、入力画像・編集プロンプト・タイムステップがそれぞれ別々のエンコーダを通り、一つのバックボーンで合流し、デノイジングループを回りながらフィードバックされます。
flowchart TD
IMG["入力画像"] --> VAE["VAEエンコーダ"]
IMG --> VIT["ビジョンエンコーダ<br/>Qwen2.5-VL"]
TXT["編集プロンプト"] --> TENC["テキストエンコーダ<br/>LLM"]
TS["timestep t"] --> TEMB["時間埋め込み"]
VAE --> LAT["画像潜在トークン"]
VIT --> COND["意味条件トークン"]
TENC --> TTOK["テキストトークン"]
LAT --> BB
COND --> BB
TTOK --> BB
TEMB --> BB
subgraph BB["MMDiTバックボーン · マルチモーダルジョイントアテンション × Nブロック"]
direction TB
JA["ジョイントセルフアテンション<br/>画像 ↔ テキスト"] --> FF["MLP"]
FF -. 残差 .-> JA
end
BB --> EPS["予測ノイズ ε"]
EPS --> LOOP{"デノイジングループ"}
LOOP -->|"次のステップ t−1"| BB
LOOP -->|"完了"| VDEC["VAEデコーダ"]
VDEC --> OUT["編集された画像"]同じ入力(画像)が二手に分岐して別々のエンコーダに入り、テキスト・タイムステップとともにバックボーンで**合流(merge)**し、ブロックの中には残差接続が、外側にはデノイジングのフィードバックループがあります。コンパイラが実際に向き合うグラフは、このように分岐・合流・反復が絡み合った形です。
ここで一つ重要な事実があります。一般的なコンパイラの前段である字句解析(lexical analysis、ソーステキストをトークンに分割する)とパース(parsing、トークンを構文木にする)は、ニューラルネットのコンパイルには存在しません。入力がすでに構造化されたグラフ(一般的なコンパイラでいえばAST/IRに相当)として与えられるからです。そのためニューラルネットのコンパイルは、コンパイラパイプラインの前段をまるごとスキップし、事実上IR最適化とコード生成の段階から始まります。
では、なぜコンパイルするのか? — eager実行の代償
PyTorchのデフォルトの実行方式はeager実行です。Pythonのコードが1行実行されるたびに、それに対応する演算カーネルをその場で即座に呼び出します。会話するように一文ずつ通訳してくれる通訳者に近いイメージです — 柔軟で、途中で値を出力しながらデバッグするのも簡単です。
問題はコストです。演算の一つ一つごとにPython解釈 → カーネルディスパッチ → 実行が繰り返され、このオーバーヘッドが毎回少しずつ違って乗ってきます(=遅延がばらつく、ジッタ)。汎用GPUはカーネルが非常に速く柔軟なので、この方式でも十分よく動きますが、NPUのような特化型アクセラレータは何をどの順序で実行するかを事前に把握してこそ本来の性能が出ます。
コンパイルとは、この通訳者を翻訳書に変える作業です。実行前にグラフ全体を受け取り、一つの決定的な実行単位として固めておけば、リクエストごとにその場で決めることがなくなり、実行がばらつきません。
言葉よりも実際に見るほうが早いです。以下で同じモデルをeagerとコンパイルの二つの方式で何度も回し、遅延分布がどう違うかを比べてみてください。
eagerはopを一つずつディスパッチするため平均も高く、分布が広く散らばります。コンパイルは単一のグラフを決定的に実行するので、ほぼ一点に集まります。低遅延・低ジッタが求められる実サービスにおいてコンパイルが持つ価値が、ここに現れます。
コンパイラがその間に行うこと
modelを一つ渡すと、コンパイラはおおよそ5つの段階を経ます。
flowchart TD
A["① グラフキャプチャ<br/>trace / export"] --> B["② 演算融合・最適化"]
B --> C["③ ハードウェアカーネルへの落とし込み<br/>lowering"]
C --> D["④ 静的shapeの固定"]
D --> E["⑤ メモリプランニング"]
E --> F(["実行バイナリ"])① グラフキャプチャ — Pythonをグラフへ
まず、モデルが実際にどんな演算を行うのかをグラフとして抽出します。2つの方式があります。
- trace (
torch.jit.trace): サンプル入力を一度実際に流し込み、通った演算を記録する。シンプルですが、ifで分かれる分岐のようなものは「そのとき通った経路」だけが捕捉されます。 - export (
torch.export): コードを静的に解析し、制御フローまで含めたグラフを抽出する。より頑健です。
② 演算融合 — 複数の演算を一つに
隣接する演算を一つにまとめます。たとえば行列積 → バイアス加算 → 活性化関数を別々に実行すると、中間結果をメモリに3回書き込んでは読み込みます。これを一つの融合カーネルにまとめれば、メモリの往復が減って格段に速くなります。
flowchart LR
subgraph before["融合前 — カーネル3回、メモリ往復 多"]
direction LR
M1["matmul"] --> B1["+bias"] --> A1["gelu"]
end
subgraph after["融合後 — カーネル1回"]
direction LR
F1["fused_matmul_bias_gelu"]
end
before -.最適化.-> after③ ハードウェアカーネルへの落とし込み(lowering)
標準的な演算(行列積、アテンション…)の一つ一つを、そのチップ専用の命令・カーネルへと翻訳します。GPU側の例を見ると感覚がつかめます。PyTorch torch.compileのデフォルトバックエンド(TorchInductor)は、演算をOpenAI Tritonへと落とし込みます。TritonはGPUカーネルをPython風の文法で記述する言語で、コンパイラが融合された演算に合わせたカーネルをTritonコードとしてその場で生成し、それをさらにGPUバイナリ(PTX/cubin)へと落とし込みます。ベンダーがあらかじめ用意したライブラリカーネル(cuBLASなど)を呼ぶ代わりに、グラフに合わせてカーネルを生成するわけです。NPUコンパイラも発想は同じです — コード生成バックエンドがTritonではなくそのチップ専用である、という点だけが異なります。
④ 静的shapeの固定
入力サイズを事前に確定させます(例: [1, 512])。サイズが固定されると実行時の分岐が消えて決定的な経路ができあがり、融合やメモリプランニングもより積極的に行えます。NPU関連の記事で「固定512トークン」が繰り返し登場するのはこのためです。代償は柔軟性です — コンパイル時点で決めたサイズを外れる入力は(再コンパイルするかパディングしなければ)入れられません。
⑤ メモリプランニング
各演算が使うバッファの配置と再利用を実行前に確定させます。実行中にメモリをその場で確保・解放しないので、その分だけ速く、一定になります。
コンパイルの成果物はどんな姿か
成果物は、ハードウェアがそのまま食べられるバイナリです(Rebellionsなら.rbln)。シリアライズされた形なのでそのまま開いて見ることはできませんが、内部のグラフを概念的に展開するとこんな姿になります。
# compiled_model — 内部グラフ(概念的な表現)
target: RBLN-CA22 dtype: fp16
in %input_ids : i64[1, 512]
in %attention_mask : i64[1, 512]
%emb = embedding %input_ids -> f16[1,512,768]
%h0 = layernorm %emb
# × N encoderレイヤー:
%a = sdpa_attention %h{i}, mask=%attention_mask @kernel=attn_fused
%m = geglu_mlp %a @kernel=geglu_fused
...
%pool = mean_pool %enc, mask=%attention_mask -> f16[1,768]
%log = linear %pool, W=const[768,2] -> f16[1,2]
out %log
先ほど見た段階がそのまま表れています — 演算が@kernel=..._fusedという形で専用カーネルにマッピングされ、すべてのテンソルのshapeが[1,512,...]と静的に固定されています。
すでに存在するもの — ONNXとコンパイルのエコシステム
ここまで「コンパイラ」を一つにひとまとめにしてきましたが、実際には複数の標準やツールが層をなしています。そのハブに頻繁に登場するのが**ONNX(Open Neural Network Exchange)**です。
ONNXはコンパイラではなく、演算グラフを収める公開標準フォーマットです。PyTorchであれTensorFlowであれ、どこで作ったモデルでもONNXへエクスポート(export)すれば、そのグラフを読める複数のランタイム・コンパイラがそれぞれのバックエンドで実行します。モデルを作ったフレームワークとデプロイ先を**分離(decouple)**する共通語というわけです。
flowchart TD
PT["PyTorch"] --> ONNX
TF["TensorFlow"] --> ONNX
JAX["JAX"] --> ONNX
ONNX["ONNX · 共通グラフ標準"] --> ORT["ONNX Runtime"]
ONNX --> TRT["TensorRT · NVIDIA"]
ONNX --> OV["OpenVINO · Intel"]
ONNX --> NPU["ベンダーNPUコンパイラ"]よく出会うツールを性格別に整理すると、こうなります。
| ツール | 性格 | 役割 |
|---|---|---|
| ONNX | 標準フォーマット | フレームワーク間のグラフ交換(それ自体はコンパイラではない) |
| ONNX Runtime | ランタイム + 最適化 | グラフを最適化したうえで複数の実行バックエンド(Execution Provider)で実行 |
| TensorRT | コンパイラ + ランタイム | NVIDIA GPU専用の高性能推論 |
| Apache TVM | コンパイラスタック | CPU/GPU/アクセラレータなど多様なバックエンドを狙うオープンなコンパイラ |
| XLA | コンパイラ | JAX・TensorFlow(およびtorch/XLA)、TPU中心 |
| torch.compile | コンパイラ | PyTorch内蔵 — TorchInductor → Triton |
| OpenVINO | コンパイラ + ランタイム | Intel CPU/iGPU/NPU対象 |
| optimum-rbln / rebel-compiler | ベンダースタック | Rebellions NPU対象(このシリーズの事例) |
大きな流れはたいてい① フレームワークからグラフを抽出し(キャプチャ/export) → ② ONNXのような共通表現を経るか、あるいは直接 → ③ ターゲットバックエンド向けにコンパイルする、という流れです。NPUベンダーの多くもONNX入力を受け付けます。ただし今回のシリーズでは、ONNXを一枚かませる代わりに、PyTorchネイティブのグラフ(torch.export)をベンダーコンパイラに直接渡す経路を選びました — アーキテクチャが微妙に食い違うモデルを扱うときは、フォーマット変換を一段減らすほうがデバッグの表面積が小さかったからです。
トレードオフ — 何を得て何を失うか
| eager(インタプリタ) | コンパイル(翻訳書) | |
|---|---|---|
| 遅延 | 高くジッタ大 | 低く一定 |
| 柔軟性 | 任意のPython・動的shape OK | 固定グラフ・固定shape |
| デバッグ | 簡単(値を即確認) | 難しい(グラフが固まる) |
| 準備コスト | なし | コンパイル時間が必要 |
まとめると、コンパイルとは**「柔軟なインタプリタ」を「決定的な実行バイナリ」に変える**ことです。柔軟性を差し出して、低遅延・低ジッタ・効率を得ます。
では、いつコンパイルするのか
- 研究・プロトタイピング、入力サイズが毎回変わる場合 → eagerで十分です。柔軟性に価値があります。
- 実サービスの低遅延推論、エッジ・オンデバイス、特化型アクセラレータ(NPU) → コンパイルが勝ちます。特にNPUは事実上コンパイルが必須です。
つまり「コンパイルが常に正解」ではありません。柔軟性が重要な局面ではeagerが、遅延・安定性・効率が重要な局面ではコンパイルが適しています。国産NPUに実際のモデルを載せながら、このトレードオフをどう乗り越えたかは移植実験記に続きます。