# onw 技術解説 [README に戻る](README.md) ・ **日本語** | [English](TECHNICAL_en.md) ・ [更新履歴](CHANGELOG.md) onw をどうやって Intel NPU に載せたかの説明です。使い方は [README](README.md) にあります。数値は特に断りがない限り、 テスト機(Core Ultra 9 285HX、NPU 3720、Windows 11、OpenVINO 2026.5.0b1)での実測です。 - onw 3.0・3.1:OpenVINO の NPU プラグインとコンパイラーを onw 用に作り直した(onw の NPU キット) - NPU で初めて動かした 4 つ - MoE を NPU で動かす仕組み - Gated DeltaNet(ハイブリッドモデル)を NPU で動かす - 画像エンコーダーまで NPU に載せた経緯 - メモリが足りないとき:エキスパートを SSD から読む - 長い文脈:ページ化した INT8 KV キャッシュ - onw 0.7 でのメモリ削減 - モデルを小さくする(onw convert --compact / --prune) - MTP 先読みと 64 トークン処理(onw 1.4、Lunar Lake) - MoE のプロンプトをまとめて計算する(onw 2.0) - dense モデルのプロンプトを 64 トークンずつ(onw 2.0) - 融合グラフと NF4 版の dense モデル(onw 2.1) - 1 ステップのホスト側の処理(onw 1.4) - 複数トークンのブロックで、ロジットが行ごと 0 になる(onw 1.4 で修正) - NPU で分かった制約(実測) ## onw 3.0:OpenVINO の NPU プラグインとコンパイラーを onw 用に作り直した(onw の NPU キット) onw 3.0 と 2.1.0 の生成速度・プロンプト・専用メモリ・長い会話の比較(Panther Lake) onw 3.0 は、Intel が公開している NPU コンパイラー(npu_compiler)と OpenVINO の NPU プラグインのソースに手を入れて作り直したものを `onw/npukit` に同梱しています(Apache 2.0、変更点は `onw/npukit/patches`)。 **なぜ作り直したか**:2.1.0 の融合グラフ(区間を NPU の 1 回の呼び出しで計算するグラフ)は速かったのですが、文脈の長さごと・ ブロックサイズごとのグラフがそれぞれ重みを丸ごと持つので、Granite 4.2 8B NF4 でキャッシュが約 42 GB(区間ごとの従来の経路は約 11 GB) になり、既定でオフにするしかありませんでした。OpenVINO には重みをグラフの外に出す仕組み(重みを持たないグラフ)がありますが、 OpenVINO 2026.5 のコンパイラーは NF4 の重みでこれに失敗し、コンパイルにも時間がかかりました。グラフを書き直すのではなく、 コンパイラーとプラグインの不具合とボトルネックを直す方針にしました。 ### 仕組み - プラグインは onw が入れる OpenVINO 2026.5.0b1(`2026.5.0-23160-6db412d0a27`)と同じコミットから作り、onw は `ov.Core(onw/npukit/plugins.xml)` で「NPU」をこのプラグインに向けます。インストール済みの OpenVINO のファイルは変えません。 プラグインはキットのコンパイラーを横に置いて読みます。 - 使うのは OpenVINO の版が合うとき、NPU 4000 以降(Lunar Lake・Panther Lake)だけです。NPU 3720(Meteor Lake・Arrow Lake)は 重みを持たないグラフに対応しておらず、OpenVINO 自身のコンパイラーのほうが約 7% 速いグラフを作るので使いません (`ONW_NPUKIT=1` で強制、`ONW_NPUKIT=0` でどこでも OpenVINO 自身のものに戻します)。 - Windows 用は DLL のまま、Linux 用(Ubuntu 24.04 で作った .so)は `onw/npukit/linux/npukit-linux.tar.xz`(32 MB)にまとめてあり、 最初の起動で同じフォルダーに展開します。インストーラーとアップデーターは、もう一方の OS 用のファイルを落としません。 Linux の OpenVINO にはプラグイン側のコンパイラーが入っていないので、キットのない Linux では重みを持たないグラフを作れません。 ### 重みを持たないグラフ - `CACHE_MODE=OPTIMIZE_SIZE` と `ENABLE_WEIGHTLESS` でコンパイルすると、コンパイラーはグラフを「重みを NPU 用の並びに組み立てる 初期化スケジュール」と「本体」に分け、重みを含めずに書き出します。ロード時に同じ ov.Model を `MODEL_PTR` で渡すと、モデルの ファイル(.bin の写像)から重みを読み、NPU 上で組み立てて本体の入力につなぎます。組み立て済みの重みを入力として渡しても、 重みを含めたグラフと同じ速さで、計算結果もビット単位で同じでした。 - onw は区間ごとの部品と融合グラフの両方をこの形で保存します(キーに "weightless" を足した別のファイル。失敗した部品は従来の 重みを含むグラフに戻ります)。キャッシュはモデル本体の 1/10 以下になりました(Granite 4.2 8B NF4:10.5 → 0.8 GB)。 - 2 ビット(Bonsai)と INT4 の重みは分離されません(グラフの大きさがほぼ変わらない。Granite 4.2 3B INT4 の 1 トークンの部品で 63.5 対 63.6 MB)。INT4 は 2 行以上のグラフで、NPU 4000 以降のコンパイラーが INT4 を表引き(4 ビットの値をそのまま表の番号に する)に変える処理と重なって失敗し、直してもコンパイルが 20〜35 倍遅くなるだけでした。このため 2 ビットと INT4 のモデル (NPUW を使わないもの)では使わず、融合グラフも既定でオフです(2.1.0 と同じ動作)。INT8 は分離されます (llm-jp-4.1-8B INT8 の部品で 2.5 対 216 MB)。Lunar Lake 以降では NF4 版を勧めます。2 ビットは 3.2 で分離できるようにしました(下の「onw 3.2」)。 ### コンパイラーの修正(npu_compiler 8.3 をもとに) - **並列コンパイルが Windows のヒープのロックで直列になっていた**:自前のサンプリングプロファイラーで見ると、マルチクラスタ戦略の 探索(MCSA)の時間の大半がヒープのロック待ちでした。分散情報のマップ(`TensorDistributionMap`)が、要素 1 つでも約 45 KB を ヒープに確保していたためです。小さいうちはヒープを使わない `SmallDenseMap` に替え、MCSA は 7〜10 秒 → 1.9 秒になりました。 - **定数の畳み込みと縦方向の融合の探索の結果を使い回す**:同じ定数に同じ変換を何度も適用していたのを覚えておくようにしました。 出力はビット単位で同じです(Granite 4.2 8B の 1 トークンの部品:84.9 → 3.3 秒、Qwen3.5-9B の 64 トークンの部品 52.6 → 34 秒)。 - **NF4 の重みを持たないグラフ**(初期化スケジュールの中だけ): - NF4(quantile 型と、それを格納型にした量子化型)の型の付け替えを、格納型 ui4 のまま読み替えるようにしました (OpenVINO 2026.5 のコンパイラーは「ui4 → quantile の変換は未対応」で失敗)。値の付け替えの連鎖は、元の値までさかのぼって 1 回の読み替えにします(そのままだと最適化で「quantile への Convert」に畳まれて失敗します)。 - quantile をまたぐ Convert の融合をしないようにしました。 - 4 ビット値の Convert をクラスタに分けるとき、1 バイトの途中で切らないようにしました(128 行を 3 クラスタで割ると 43 行 × 4 ビット)。 - 4 ビット値の並べ替え(DMA)は、f16 に広げてから行います。 - 初期化スケジュールの出力を重みごとに分けます(`ONW_WS_NO_CONCAT`。OpenVINO は既定で 1 本にまとめます)。区切り方の違う グラフどうしで重みを共有するために必要です。 - **Qwen3.5 系 NF4 の 4 トークンのグラフ(MTP 先読みの検証用)**が `'async.await' has non 'return' consumer` で失敗していたのを修正 (関数の結果の手前に形だけ変える操作が残る場合)。これがないと MTP が使えません(Qwen3.5-9B NF4 の生成 9.4〜12.6 → 14〜19 tok/s)。 - **2 ビットのゼロ点付き重み(Bonsai)を 2 行以上で計算すると 0 になる不具合**の修正。 ### プラグインの修正(OpenVINO 2026.5.0 の NPU プラグインをもとに) - **重みの共有**:重みを持たないグラフでも、グラフごとに NPU 上へ重みを組み立てるので、融合グラフの長さ違い(1K〜32K 位置)や ブロックサイズの数だけ重みが増えます(8B NF4 で 1 種類あたり約 4.8 GB)。キットのプラグインは、初期化スケジュールを走らせた あと、出力した重みのバッファを大きさ・標本ハッシュ・全バイト比較で既存のものと照合し、すべて一致したら本体をそちらにつないで 新しいバッファを捨てます(`ONW_WS_SHARE=0` で無効)。Panther Lake の llm-jp-4.1-8B NF4 では、融合グラフ 6 種類を同時に読み込んで +5.7 GB(共有なしなら約 29 GB)でした。 - 共有したまま、ある種類のグラフを捨てて別の種類を読み込むと、NPU ドライバーの中で解放済みの領域への書き込みが起きることが あります(自前の例外ハンドラーで、プラグインの非同期スレッドからドライバーの `executeCommandList` の中で起きるのを確認)。 そこで 3.0 は融合グラフの全種類と従来の経路のグラフを読み込んだままにします(`ONW_FUSED_KEEP`。省メモリモードでは従来どおり 使う長さのグラフだけ)。メモリの解放を促す指示(evict のヒント)も、入力の割り当てでアクセス違反を起こしたので使いません。 ### onw 側の変更 - 全種類を持つので、融合グラフは文脈の長さまで 8K・16K・32K 位置の種類も作ります(2.1.0 は 4K まで、その先は従来の経路)。 回答もプロンプトも、そのときの位置を覆う一番短い種類で計算し、位置が進むと次の種類に切り替えます(長い種類ほど毎回読む KV キャッシュの範囲が広く、遅くなるため。22K トークンのプロンプトを全部 32K の種類で計算すると 29 tok/s、切り替えると 74 tok/s)。 - どれか 1 つの部品でも重みを持つグラフになったモデルでは、融合グラフを既定で使いません(種類ごとに重みを持つことになり、 16 GB の機種でメモリが足りなくなるため)。 ### 裏でのコンパイル:使い始めるまでを短く 融合グラフは文脈の長さ(1K〜32K 位置)とブロックサイズ(回答用の 1〜2 トークン、プロンプト用の 64 トークン)の組み合わせだけ あり、8B では全部で約 70 のグラフになります。全部をコンパイルしてから使い始めると、初回のロードが 1 時間を超えます。 - 初回のロードでコンパイルするのは、使い始めるのに必要なもの(従来の経路の部品と、一番短い 1K 位置の融合グラフ)だけです。 Panther Lake の Granite 4.2 8B NF4 で 10.2 分(2.1.0 相当は 10.7 分)、Lunar Lake(Ubuntu)では 3.5 分で回答できます。 - 残りの種類は、裏の 1 本のスレッドで短い順にコンパイルし、onw のブロブ置き場に書き出します。このときは NPU 上に実行環境を 作りません(推論の横で作ると、Panther Lake で NPU を見失いました)。コンパイラーは CPU で動くので、回答は止まりません。 Panther Lake の 8B では、裏の 49 分の間に 536 回答え、生成は 12.3 tok/s のままでした(Lunar Lake では 14 分)。 - 会話が先に長くなり、まだ裏でできていない種類が要るときは、その種類をその場で作ります(その間だけ止まります)。 - 一度できたグラフはキャッシュに残るので、2 回目以降は全部そろった状態で数十秒で起動します(Lunar Lake の 8B で 42 秒)。 - 並列コンパイルは、メモリに余裕がある PC 向けの設定(`ONW_COMPILE_JOBS`)のままです(16 GB の PC には入らないため)。 ### メモリの数字の読み方 グラフの「専用」はプロセスの private bytes です。キットでは重みがグラフの外(モデルのファイルのメモリマップと、NPU 上で組み立てた 1 組)にあるので、専用メモリは大きく減ります(Granite 4.2 8B NF4:11.5 → 2.0 GB)。ただし Intel の NPU はメインメモリを使うので、 組み立てた重みの分は RAM を使います。実際に使う RAM はピークのワーキングセットで見るのが正確で、こちらは 12.5 → 10.1 GB(約 2 割減、 重みの写し 1 組分)です。Qwen3.5 系は 2.0 のころから NPUW の重みバンクで重みを 1 組にしていたので、メモリは変わりません (プロンプトが速くなったのも 2.0 の 64 トークン処理のときで、Panther Lake の Qwen3.5-9B INT4 で約 44 → 157 tok/s)。 ### onw 3.1:Qwen 系も融合グラフで Qwen3.5・Ornith は区間に分ける部品が少ないので、3.0 までは NPUW(同じ形の層を 1 つにまとめてコンパイルし、重みを 1 組にする OpenVINO の仕組み)で動かしていました。キットで重みを共有できるようになったので、3.1 では NPUW を外し、融合グラフと MTP 先読みで 動かします(設定「Qwen 系を高速化する」、`ONW_FAST_DENSE=0` でオフ)。 - 重みは 1 組のままです(初期化スケジュールの出力の照合で、94 個のスケジュールの出力が既存のものと一致し、計 24 GB 分を捨てました)。 - メモリが増えるのは、グラフの命令列と作業領域の分です。NPUW は同じ形の層を 1 つにまとめますが、融合グラフは区間の層をすべて持ちます (Panther Lake の Qwen3.5-9B NF4:NPUW 5.8 GB、従来の経路だけ 6.2 GB、融合グラフ 1K・2K・4K の 3 種類 8.1 GB、4K の 1 種類 6.7 GB)。 - そこで Qwen3.5 系では融合グラフを 4K 位置の種類から使います。アテンション層が 4 層に 1 つしかないので、長めの種類でもほとんど遅くなり ません(4K の 1 種類:約 3.5K トークンのプロンプト 297 tok/s、3 種類 265 tok/s)。32K の 1 種類にすると、毎回 32K 位置分を計算して 遅くなります(生成 14 tok/s)。 - NPUW のまま融合グラフを使う形も試しましたが、ロジットが NaN になり、メモリも 12.6 GB に増えました。 結果は上のグラフと CHANGELOG の v3.0.0・v3.1.0 を見てください。 ### onw 3.2:2 ビット(Bonsai)を 64 トークンずつ、重みは 1 組のまま - **重みを持たないグラフを 2 ビットでも**:Bonsai の 2 ビットの重みは 128 個ずつのグループに分けて、グループごとのスケールと ゼロ点で量子化されています。コンパイラーは隣り合うグループの切り出しを「仮の土台の上の Concat」にまとめてしまい、重みを 分離できませんでした。キットのコンパイラーは、同じ OV 定数の連続したグループの切り出しを「先頭+k グループの切り出し+残り」 に書き直し、[k, N, K] の切り出しはグループごとの Slice → Reshape → Concat で組み立てます(ui2 の Transpose は初期化スケジュール の中では使えないため)。`MergeFullyConnected` のまとめはそのまま使えます。 - **重みの部分一致の共有(プラグイン)**:1 トークン用と 64 トークン用のグラフは、重みの区切り方が違います(片方の 1 本が、もう 片方の数本をつないだもの)。キットのプラグインは、64 KB 以上の出力を登録しておき、32 KB 以上の新しい出力を既存のバッファの 中の同じ大きさの倍数の位置と照合して、一致すればその一部を指すようにします(`ONW_WS_SUBRANGE=0` で無効)。2 ビットの重みは NPU 上に 1 組だけです。 - **重みテーブルは共有できない**:NPU(5010 で確認)は、2 ビットのグループのスケールを、出力チャネルごと・グループごとに 16 バイト の重みテーブルの行から読みます。テーブルはグラフの中で並びが決まるので、グラフごとに別に要り、重み 1 個あたり 1 ビット (2 ビットの重みの半分)になります。新しい形式のテーブル(`useNewWeightTableFormat`)は 5010 の PPE では使えませんでした。 27B では 1 組 3.4 GB になり、1 トークン用+64 トークン用で NPU 13.8 GB を使ったので、27B には入れていません。 - **コンパイル時間**:戦略探索(MCSA・兄弟の操作の解析)が、同じ重なり方の分散情報や兄弟のリストを何度も作り直していたのを 覚えておくようにし、定数の畳み込みのメモを転置・並べ替えにも広げました。定数を左から掛ける行列積がバッチごとに展開される 所も、右から掛ける形に直します(`ONW_LEFTMM=0` で無効)。2 ビットの 64 トークンの部品 1 つが 335 → 約 20 秒(バイナリーは同じ)。 - **プロセスでの並列コンパイル**:OpenVINO は 1 プロセスの中の `compile_model` を同時に呼んでも順番に処理し、コンパイラーも グラフ 1 つにほぼ 1 コアしか使いません。初回のロードでは、コンパイルして `export_model` で保存するだけの子プロセスを、空き メモリ(1 つあたり 2.5 GB)とコア数(半分)に応じて最大 6 つ走らせ、親はできたファイルを読み込みます。子は NPU に重みを 載せないので、重みは二重になりません(Bonsai 2 4B:819 → 約 180 秒)。子は MoE のエキスパートの束も共有の重みも NPU から 見えるメモリに読み込みません(最初は読み込んでいて、Qwen3.6-35B-A3B の子 6 つが Panther Lake の NPU ドライバーの中で止まり、 再起動が要りました。直したあとは 84 部品を 185 秒、初回のロード 231 秒)。 - **従来のグラフを外す**:融合グラフが覆う長さでは、従来のグラフ(とその重みテーブル)を読み込んだあとで外します。画像の質問や、 融合グラフより長い文脈になったときだけ読み込み直します(`ONW_FUSED_DROP_PAGED=0` で外さない)。外したグラフの状態のテンソルは onw 側で持ちます。 - **融合グラフの 0 の行**:複数トークンのブロックでは、最後の RMSNorm の二乗和が fp16 であふれて行全体が 0 になることがあります (NPU 4000 と 3720 で見ていた現象)。従来のグラフは頭の部分だけ縮めた入力で計算し直していましたが、融合グラフにはそれが なく、Bonsai 2 9B の 64 トークンのブロックで Lunar Lake の 256 行中 20 行が一様分布になっていました(KL 0.86)。同じ処理を 入れて KL 0.02 です。 - **2 ビットのモデルは先読み検証(PLD)を使いません**:下書きの確認が融合 64 トークンのグラフになり、Lunar Lake で生成が 23 → 13〜17 tok/s に落ちていました(確認の重さは従来のグラフで測っていたので、オンと判断していました)。 ### onw 3.2.1:MoE の生成(Panther Lake、Qwen3.6-35B-A3B) 1 トークン 93 ms の内訳を区間ごとに測ると、NPU の 51 区間で約 52 ms(1 区間 約 1.2 ms)、出力層 17.6 ms、アテンションの呼び出し 10〜14 ms、ホスト 12 ms でした(読む量は約 1.7 GB、帯域なら 30 ms 前後)。 - **エキスパートの結び付け**:ホスト 12 ms のうち 9.6 ms が、層ごとの `set_tensor` 16 回と `get_tensor` 3 回でした。前のトークンと同じ エキスパートはそのスロットに残し、新しいものだけを空いたスロットへ 1 回の `set_tensors` で結び付けます(スロットの重みも並べ替え。 和の順序が変わるだけで、ロジットの差は KL 0.003)。1 トークンで結び付け直すのは 320 スロット中 約 150 で、残りの約 4.7 ms は プラグイン側の 1 テンソルあたり約 16 µs です。 - **アテンション**:2.0.4 は MoE で、結び付け済みのアテンション要求を使っていませんでした(REAP-18B でドライバーのホストメモリが 尽きたため)。上限を 48 個にして MoE でも使います(10.9 → 7.2 ms、6,000 トークンの文脈で生成 7.0 → 9.9 tok/s)。 - **出力層**:248320 × 2048 の INT8 の表を入力で渡す出力層は、単独で 14.4 ms でした。**同じグラフを PERF_COUNT=YES でコンパイル すると 5.3 ms** になります。DMA・DPU・SW のプロファイリングを全部切っても速いままなので、プロファイリングの処理ではなく、 コンパイラーの `enableProfiling` が変える何かです(アウトラインの設定、CMX の予約量の差 2 KB は違いました)。出力層のグラフ だけをそうしてコンパイル・実行します(`ONW_HEAD_PERF=0` で無効)。ほかの区間には効きません。 - **NF4 版のモデル**:エキスパート以外の重み(グループ 128 の INT4)は、行列積が 18 GB/s ほどしか出ていませんでした。元の bf16 から チャネル単位の NF4(フル アテンションの q/k/v は INT8)に変換し直すと、1 区間 1.3 → 1.0 ms、生成 15.6 tok/s、プロセスのメモリ 4.0 → 1.5 GB です(Lunar Lake 以降専用の別のモデル)。 - 効かなかったもの:DeltaNet の状態(ちょうど 1 MiB)の出力を 2 つに分ける(単独では 2 倍速いが、実際の区間では重みの読み込みと 重なっていて変わらない。`ONW_SSM_SPLIT=1`)、状態の更新を 1 回の行列積にまとめる、KV の最初のブロックを 2048 にする、MTP (16 トークンのグラフはスロットが 128 あり、検証のたびに大量のエキスパートを読む)。 ## NPU で初めて動かした 4 つ **私たちの知る限り、次の 4 つを NPU で動かして公開している実装は onw が初めてです**(2026 年 9 月、Fable 5.1 調べ)。 1. **画像エンコーダーまで含めた VLM 全体** Intel 公式の OpenVINO GenAI の VLM パイプラインは、言語モデルは NPU に載せても画像エンコーダーは CPU で動かします。 ほかの公開例はテキストのみです。onw は Gemma 4 E4B と Qwen3.5/Qwen3.6 の画像エンコーダーを NPU で動かします (画像 1 枚あたり Gemma 1.9 秒、Qwen 0.5 秒)。形が実行時に決まる部分を静的なグラフに作り直す必要がありました (下の「画像エンコーダーまで NPU に載せた経緯」)。 2. **Gated DeltaNet 混在のハイブリッドモデル**(Qwen3.5/Qwen3.6) OpenVINO 公式は Qwen3.5 系を CPU・GPU のみ対応としています。線形アテンションの再帰状態を NPU の FP16 で正しく 計算するには、値の小ささ・大きさの両方への対策が要りました(下の「Gated DeltaNet を NPU で動かす」)。 3. **MoE のエキスパート計算**(Qwen3.6-35B-A3B、LFM2-8B-A1B) OpenVINO 公式は LFM2-8B-A1B を CPU・GPU のみ対応としています。Intel は NPUW で MoE 対応を進めていますが、一般に 使える形では未公開です。NPU は形の決まったグラフしか扱えないため、トークンごとに違うエキスパートを選ぶ MoE とは 相性が悪く、onw はネットワークをルーターの直後で区切り、選んだエキスパートの重みを実行時の入力として渡します (下の「MoE を NPU で動かす仕組み」)。 4. **メモリに入りきらない MoE を、エキスパートを SSD から読みながら Intel NPU で動かすオープンソースの実装** プロンプト処理と生成の両方でエキスパートを NPU で計算し、エキスパートは LRU キャッシュ+OS のキャッシュを通さない 直接読み込みで SSD から読みます。16 GB の Lunar Lake で Qwen3.6-35B-A3B(17 GB)が 8.6 tok/s で動きました。 似た試みには、Qualcomm の NPU 向けの論文(PowerInfer-2、EStream、BigMoMo。コードは非公開)と、Apple の Neural Engine でプロンプト処理だけを行う ds4-ssd があります(下の「メモリが足りないとき」)。 ## MoE を NPU で動かす仕組み NPU は形の決まったグラフしか扱えませんが、MoE はトークンごとに違うエキスパートを選びます。NPU 3720 で Qwen3.6 の 寸法の 1 層・1 トークンを測ると、次のようになりました。 | 方式 | 1 層・1 トークン | |---|---| | 全エキスパートを計算する | 45 ms | | グラフ内の Gather で選ぶ(INT4 定数) | コンパイルできない | | グラフ内の Gather で選ぶ(INT8・詰め込み形式) | 18〜135 ms(NPU が表全体を転送する) | | **選んだエキスパートを実行時の入力として渡す** | **1.7 ms**(NPU のメモリ帯域いっぱい) | そこで onw は次のように組んでいます。 - ネットワークを **MoE のルーターの直後で区間に分け**、ルーターの上位 k 個をホストが受け取って、次の区間に選ばれた エキスパートの重みバッファを結び付けます。全エキスパートは NPU から見えるホストメモリ(`create_host_tensor`)に 置き、`set_tensor` で結び付けるだけなのでコピーはありません(1 万個・16 GB のテンソルの確保も 0.5 秒)。 - エキスパートは **チャネル単位 INT4 を 1 エキスパート 1 テンソルに詰めた形** にします(gate_up と down を 1 つの INT4 入力に詰めても速度は同じ)。実行時に渡す重みを NPU が全速で扱えるのは「INT4(または INT8)→ Convert → 2 次元の MatMul、スケールは出力側」の形だけで、グループ単位のスケールを付けると要素ごとの遅い処理になり、約 20 倍遅くなります。 - それ以外の重みは区間の中のグループ 128 の INT4 定数です(非エキスパート部分をチャネル単位 INT8 定数にすると、 LFM2 の速度は半分になりました)。出力層は共有の INT8 入力として独立した区間に置き、ブロックサイズが違っても 1 つ だけ、ロジットが不要なプロンプト処理では飛ばします。 - 埋め込みと Gemma の 2.7 GB の層ごとの埋め込みはホストで引きます。実行時入力からの Gather は、NPU が表全体を転送 するためです。 - 16 トークンのプロンプトブロックでは、ブロック内の全トークンが選んだエキスパートの和集合を、生成時はそのトークンの 上位 k 個を結び付けます。エキスパートが 32 個の LFM2 と、128 個に刈り込んだ Qwen3.6-REAP では、プロンプトブロックで 全エキスパートを最初に結び付けたままにします。 - 区間の状態(KV キャッシュの行、畳み込みの窓、再帰状態)は入出力の名前の規則でホストが管理し、先読み検証で 外れたトークンは正確に巻き戻します。 コンパイルは経路を自動で選びます。NPU 4000(Lunar Lake)の dense モデルは NPUW(重みを 1 コピーにしてブロック サイズ間で共有)、だめなら 1 タイル、通常のコンパイル、大きいブロックサイズをあきらめる、の順に試します。NPU コンパイラーはまれにプロセスごと落ちるので、各経路は子プロセスで試し、結果を `npu_cache/onw_compile.json` に 記録して次回からは試しません。 設定画面の実験的な設定(v0.10.1)で、MoE モデルへの NPUW(`ONW_NPUW_MOE=1`。エキスパートは元から実行時入力で 1 コピー なので、共有されるのはそれ以外の重み)、`NPUW_FUNCALL_ASYNC`(`ONW_NPUW_ASYNC=1`)、重みなしのコンパイルキャッシュ `CACHE_MODE=OPTIMIZE_SIZE`(`ONW_CACHE_SIZE=1`)を足せます。どれも llama.cpp の OpenVINO 版が NPU で使っている設定で、 有効にした組み合わせごとに経路の記録を分けます。 切り分けた部品は NPUW の既定のオンラインパーティショナー(REG/COMPUTE/SPATIAL も)が「宣言されていない Parameter」 で拒否するので、`NPUW_ONLINE_PIPELINE=REP`(繰り返しブロック。部品の中の Sliding 層を折り畳む)でコンパイルし、 だめなら NONE(1 サブグラフ)、通常コンパイルの順に落ちます。LM ヘッドは NONE。NPUW の重みバンクはコンパイル後も モデルの .bin を参照するので、.bin は mmap で持ってモデルと同じ寿命にしています(0.10.4)。 読み込みが終わると、その読み込みで取り込まなかった `onw_blobs/seg*` を消し、NPUW を使わない機種では OpenVINO 側の キャッシュ(`npu_cache/*.blob`。onw の blob は全部自前の置き場にあるので不要)も空にします(0.10.5、`ONW_CACHE_PRUNE=0` で無効)。アテンションのグラフと画像エンコーダーは必要になったときにコンパイルするので、掃除の対象外です。 ## Gated DeltaNet(ハイブリッドモデル)を NPU で動かす Qwen3.5/Qwen3.6 は、Gated DeltaNet(線形アテンションの一種で、層ごとに再帰状態を持つ)とゲート付きアテンションを 3:1 で組み合わせたハイブリッドモデルです。NPU は FP16 で計算するため、次の対策が要りました。 - **生成時と プロンプト処理で形を変える**:1 トークンの生成は状態の行列更新をそのまま書いた形、16 トークンの プロンプトブロックはチャンク並列形式です。チャンク並列形式で出てくる (I − N) の逆行列は、教科書どおりの級数展開 ((I − N)(I + N²)…)だと実際のキーで FP16 の桁落ちが起きるため、ブロックを倍々にしていく厳密な逆行列で求めます。 - **小さすぎる出力を 1024 倍する**:DeltaNet の出力はヘッドごとの RMS が 1e-3〜2e-2 と小さく、NPU 3720 では小さい ヘッドの精度が FP16 の非正規化数の範囲で失われ、直後のゲート付き RMSNorm がその誤差を O(1) に拡大していました (1 層あたり約 25%)。出力はスケール不変な RMSNorm にしか入らないので、q を 1024 倍し、RMSNorm の eps を 1024² 倍 すれば計算は元と同じまま、値を FP16 の安全な範囲に置けます。 - **大きすぎる値の前処理**:1024 倍した出力を複数トークンのブロックで RMSNorm に入れると、今度は二乗和が FP16 で 溢れます。x/c を正規化し eps を c² で割る形に前処理しています。 - 実際の状態の大きさは CPU(FP32)で確かめています(畳み込みの状態の最大値は 14 で、FP16 の限界からは遠い)。 - **量子化に敏感なモデル(onw 0.12、Ornith-1.5-35B-A3B)**:Qwen3.6-35B-A3B の追加学習版ですが、全重みグループ 128 の INT4 では最初のトークンを外しました(NPU でなく CPU の FP32 でも同じ)。bf16 のモデルと層ごとに比べると、層 0 の MoE 入力で すでに 22% ずれていて、その大半が DeltaNet の `out_proj` と `in_proj_qkv` でした(グループ 32 の INT4 でも 19%)。この 2 つ だけを INT8 にすると回答の終わりまで一致します。INT8 は INT4 と同じ **グループ 128 の形(q8g128)** にしています。同じ重みを チャネル単位 INT8 にすると 1 トークンの区間が 157 → 364 ms と倍以上遅くなりましたが、q8g128 なら 195 ms で済みます (`onw convert --deltanet-fmt q8g128`)。 35B-A3B は、これとは別に NPU だけで出るずれ(改行などのトークンで残差がとても小さく、RMSNorm の二乗が FP16 の 非正規化数に落ちる)を調査中のため、まだモデル一覧には載せていません。 ## 画像エンコーダーまで NPU に載せた経緯 最初は単独版([gemma-4-E4B-it-npu](https://huggingface.co/ryugyosoft/gemma-4-E4B-it-npu)、 [Qwen3.5-9B-npu](https://huggingface.co/ryugyosoft/Qwen3.5-9B-npu))でモデルごとに NPU に載せ、そこで分かった 直し方を onw の共通エンジンに取り込みました。 **Gemma 4 E4B** - 画像エンコーダーの元の IR には、データで形が決まる箇所が 3 つありました。 1. パッチの位置埋め込みが `OneHot(pos, 10240) @ 表`(約 79 GFLOP、NPU で 1.6 秒)→ 同じ結果になる行の参照に 書き換えて数 ms。 2. プーラーの one-hot の深さが実行時の値 → 常にソフトトークン数と同じなので定数に。 3. `NonZero → GatherND` でパディングのトークンを落としていた → 後ろの正規化と射影はトークンごとの計算なので 全トークンを計算し、有効なトークンの印を返してホストで切り出す。 元のモデルとの一致は CPU でコサイン類似度 0.998。280 トークンでは NPU 3720 が通常のコンパイル結果を最初の推論で 受け付けないため、一度試して NPUW の分割に切り替えます。 - デコーダーは、ヘッド次元 512 のアテンションの P·V(出力 512 幅)が NPU 3720 で正しく計算されませんでした。層ごとに CPU と比べると、誤りは全アテンションの 7 層の SDPA 出力だけ(約 100%)でした。アテンションは V について線形なので、 ヘッド数を倍にして V を 256 幅ずつに分けると誤差は 0.57% になります。この書き換えたグラフは NPUW の LLM モードが 受け付けないため、静的な形のデコーダー(プロンプト用 16 トークン、生成用 1 トークン)とホスト管理の KV キャッシュ、 ホストで作るマスクで組んでいます(グラフ内のマスク生成の `Select` もコンパイルできなかった)。 - 出力層の logit softcap(`matmul → ×scale → /30 → tanh → ×30`)は、16 トークンのブロックで誤って計算されました。 1/30 を入力側に移すと正しくなります。 - 2.7 GB の層ごとの埋め込み表は、単独版では NPU 3720 がプロセスあたり約 2.1 GB しか載せられず、半分を別プロセスに 置いていました。onw では表を引くだけの処理はホストで行い(INT4、ファイルのメモリマップのまま)、別プロセスも NPU メモリも使いません。 **Qwen3.5/Qwen3.6** - OpenVINO は画像エンコーダーを、パッチ埋め込み、学習済み位置表(4 隅の行からの双線形補間)、27 層の ViT+2×2 の マージャーの 3 つに分け、位置埋め込み・回転埋め込み・ブロック単位のアテンションマスクをホストで作って渡します。 512×512 の 1 枚に固定すればこれらはすべて定数なので、一度計算してグラフに焼き込み、1 つの静的なグラフにしました (`pixel_patches[1024, 1536] → image_tokens[256, …]`)。重みは INT8。 - 画像トークンの MRoPE の位置(時間・縦・横)はホストで計算します。 **onw での扱い** - 画像エンコーダーは最初の画像が来たときに読み込み、メモリ 24 GB 未満の PC では画像を処理したらすぐ解放します (Gemma の画像エンコーダーは NPUW 経由で、コンパイル済みで +1.0 GB、最初の推論で +1.4 GB。解放すれば全部戻る)。 - コンパイルに失敗した経路は印を付けて記録し、次回からは別の経路を使います。 ## gpt-oss-20b を NPU で動かす(onw 0.11) OpenAI の gpt-oss-20b は、24 層すべてが「アテンション → 32 個から 4 個を選ぶ MoE」で、アテンションには学習済みの **attention sink**(ヘッドごとに softmax に参加する値なしのロジット)、**YaRN** の RoPE(4K → 128K)、偶数層の **128 幅 sliding window**、エキスパートには **バイアス** と **clamp 付き SwiGLU** があります。出力は **harmony** 形式 (analysis/commentary/final のチャネル)で、思考(analysis)は常に生成されます。onw では sliding 層をグラフ内の リング(Gemma と同じ)、full 層をページ化 KV に載せ、sink はグラフ内では softmax の列として、ホスト側の合成では 分母への 1 項として足しています。harmony は chat 側で解釈し、analysis は思考モードのときだけ表示、commentary の `to=functions.NAME` は OpenAI 形式の tool_calls に変換します(`reasoning_effort` は思考モード off で low、on で medium)。 エキスパートは公開重みが **MXFP4**(FP4 に 32 個ごとの 2 のべき乗スケール)です。実行時入力にできる形はチャネル単位 なので再量子化が必要で、チャネル単位 INT4 は重み単体では約 20% ずれますが、モデル全体では bf16 と比べて貪欲生成 17 トークン中 16 が一致(ロジットは確信度の高いところで 5〜12 低め)。Intel 公式の INT4 版(INT4_ASYM、1 行 1 スケール) と同じトレードオフです。INT8(19 GB)ならロジット差 0.9。アテンションの重みは INT4(グループ 64/32)だとロジットが 2〜3 ずれるので INT8 です。 NPU の FP16 で動かすまでに 3 つの溢れがありました。(1) 残差に **巨大活性**(|h| ≈ 42,000、7 層目から)があり RMSNorm の 二乗が溢れる。行を最大値で割る LFM2 の方式だと今度は普通の要素の二乗が非正規化数になって NPU が 0 に潰すので、 行を **最大 64** にスケールしてから計算します。(2) **INT8 のコード(≦127)と活性の積和**:NPU は生のコードで積和して から行スケールを掛けるので、出力が数百でも fp16 で溢れる(1186 のはずが 155 に)。トークンごとに活性を最大値で割って 積和し、後で戻します(速度は同等以上)。(3) 同じことが **LM ヘッド**(INT8、実行時入力)でも起き、ロジットが 16〜18 に張り付いていました。区間を 1 つだけ NPU に差し替える混成連鎖で特定(CPU の入力で区間ごとに比べる方法では隠れる)。 弱点はプロンプト処理で、16 トークンのブロックで 32 エキスパート全部を計算する設計のため 84 トークンで約 4 秒 (既存の MoE モデルと同じ制約。先読み検証もこのモデルでは自動でオフ)。 ## Granite と llm-jp(Llama 型、onw 0.13) - 変換器 `onw/models/granite.py` は、Granite の係数(埋め込み・残差・アテンション・出力の倍率)を既定値 1 にすれば普通の Llama なので、`llama`(llm-jp)もこれで変換します。8 層ずつの区間と独立した出力層の区間は Qwen3.5 の dense と同じ作りです。 - **Granite は INT4 に弱い**:グループ 128 の INT4 では、bf16 の 400 トークンの回答との一致が 316/400(4.2 3B)。グラフ自体は正しく (FP16 の重みなら 392/400)、誤差は量子化によるものでした。全部 INT8 なら 391/400 ですが、NPU 3720 では 8.1 → 4.7 tok/s に落ち、 グループ 32 の INT4 も同じ遅さで、GPTQ・AWQ はほぼ効きませんでした。採用したのは **符号を考慮した INT4**(q4sg128:正側は最大値/7、 負側は最小値/8 の大きいほうを目盛りにする。並びと速度は普通の INT4 と同じ)と、アテンションの q/k/v だけグループ 128 の INT8 で、 345/400、速度 7.9 tok/s です。 - **出力層の非正規数**:Granite 4.2 8B の出力層は、INT8 の行の目盛りが FP16 の非正規数になる行が 558 行あり、NPU ではそれが 0 になって その語のロジットがちょうど 0 になっていました(一致が 362 → 309/400)。その行は目盛りを 2^-14 にして符号化し直しています。 - **llm-jp の harmony**:形式は gpt-oss と同じ系統ですが、トークナイザーが Metaspace 方式で、まとめて文字列に戻すと区切り記号の後ろに 空白が入ります(`<|channel|> final`)。onw は区切り記号ごとに分けて戻します。ツール呼び出しの宛先(`to=functions.x`)は役割の 見出し側に書かれるので、gpt-oss の並びに直してから読みます。 ## メモリが足りないとき:エキスパートを SSD から読む MoE は 1 トークンごとに一部のエキスパートしか使いません(Qwen3.6-35B-A3B なら 40 層 × 256 個のうち、各層 8 個)。 そこで、エキスパートは SSD に置いたままにして、ルーターが選んだものだけを読み込み、最近使ったものをメモリに残します (LRU キャッシュ。キャッシュの枠は NPU から見えるホストメモリなので、読んだらそのまま結び付けられる)。読み込みは OS のファイルキャッシュを通さない直接読み込み(Windows は `FILE_FLAG_NO_BUFFERING`、Linux は `O_DIRECT`)で、 8 並列です。OS のキャッシュに同じデータがもう一度溜まってメモリを圧迫することはありません。 - 設定画面の「エキスパートのメモリ」:auto(既定)は、モデル全体がメモリの 85% に収まらないときだけ SSD から読みます。 メモリは「約 4.8 GB+キャッシュ」になり、16 GB の PC では Qwen3.6-35B-A3B が約 8 GB のキャッシュ(当たり率 9 割強)に なります(Qwen3.6-REAP は SSD から読まずに収まります)。GB を指定することもできます。 コマンドでは `ONW_EXPERT_GB=5 onw serve …`。 - 実測(Qwen3.6-35B-A3B、NPU 3720、キャッシュ 5 GB=全エキスパートの 33%): | | すべてメモリ | SSD から(5 GB) | |---|---|---| | メモリ(ピーク、onw 0.7) | 19.9 GB | 9.9 GB(キャッシュ 3 GB なら 7.9 GB) | | 生成 | 6.8 tok/s | 5.4〜5.8 tok/s(内蔵 NVMe) | | キャッシュの当たり率 | — | 85〜90% | | 画像付きの質問(276 トークン)の読み込み | 15 秒 | 15 秒 | 回答はすべてメモリの場合と同じです。速さのための工夫は次のとおりで、どれもメモリを増やしません。 1. **プロンプトは層ごとに処理**:全部の 16 トークンの塊を 1 層目に通し、次に 2 層目…の順です。各層の状態はその層の前の 塊にしか依存しないので結果は同じで、各層のエキスパートはプロンプト全体で 1 回ずつ読むだけになります(塊ごとに全層を 通す順では 44 秒・33 GB 読み込み)。 2. **プロンプト処理の先読み**:ある塊が次の層で使うエキスパートは、その塊がこの層を終えた時点で分かるので、NPU が次の 塊を計算している間に読み始めます(24 秒 → 15 秒。別のバッファは使わず、キャッシュの枠に直接読み込み)。 3. **生成時の予測先読み(既定はオフ)**:区間を NPU に渡す前に、1 層前の状態にその層のルーターを当てて上位を予測し、 NPU の計算中に読み始めます。予測は本物の上位 8 個の 92% を当て(12 個予測時。8 個なら 82%、16 個なら 96%)、生成は 5.8 → 6.5 tok/s になりますが、外れた予測の分まで読むので CPU 時間が 1 トークンあたり 51 → 78〜85 ms に増えます。 8 コアの Lunar Lake では負担が大きいため既定では使いません(`ONW_PREFETCH_K=12` で有効。ルーターの重み 80 MB が増えます)。 試してやめたこと:質問の内容からエキスパートをまとめて選ぶ方式(Apple の論文のような、トークンごとではなくプロンプト 単位の選択)は、学習し直していない MoE では当たりません。プロンプトで各層の上位 64 個を選んでも、回答で実際に使われる エキスパートの 67% しか含みませんでした。 - LFM2-8B-A1B は 16 トークン処理で 32 個のエキスパートを一度に使う作りのため、この方式の対象外です(もともと約 5 GB で動きます)。 - 調べた道具:`trace_experts.py`(実際の会話で選ばれたエキスパートを記録)、`sim_expert_cache.py`(キャッシュの大きさごとの 当たり率を計算)、`sim_prompt_experts.py`(プロンプト単位の選択の当たり率)、`predict_experts.py`(予測の当たり率)、 `bench_ssd.py`(SSD の読み込み速度。内蔵 NVMe 3.3 GB/s、USB 接続 1.4 GB/s)、`test_stream.py`(SSD 読み込みでの速度とメモリ)。 ## 長い文脈:ページ化した INT8 KV キャッシュ(onw 0.10) NPU のグラフは形が固定なので、onw 0.9 までは KV キャッシュを「最大長ぶんの固定サイズ」でグラフの入力にしていました。 これには 3 つの問題がありました。 - 会話が短くても、毎トークン最大長ぶんのアテンションを計算する。 - 最大長ぶんのメモリ(f32)を最初から確保する。 - 長さを変えるたびに、重みを含む全区間をコンパイルし直す(Lunar Lake の 16 GB では Gemma の 32K でコンパイルが通らなかった)。 onw 0.10 では、読み込み時に各区間をアテンションの位置で切り分けます(`onw/paged.py`。変換済みのモデルはそのまま)。 - 重みのある区間はアテンションの手前で終わり、q と新しい K・V を出します。続きの区間はアテンションの結果を入力として 受け取ります。重みのある区間は文脈の長さと無関係になり、長さを変えても再コンパイルは要りません。 - K・V はホストに **INT8(トークンと KV ヘッドごとのスケール)** で持ちます。塊(1K, 1K, 2K, 4K, 8K, 以降 16K)は 会話が伸びたときに確保します。 - アテンションは、重みを持たない小さなグラフで塊ごとに NPU で計算します。塊ごとの「行の最大値・合計・重み付き和」を ホストで厳密に合成します(Flash-Decoding と同じオンライン softmax)。コストは実際に使った長さに比例します。 - NPU 3720 で速かった並べ方:塊ごとに K を [KV ヘッド, 長さ, 次元]、V を転置した [KV ヘッド, 次元, 長さ] の INT8 で 1 つずつ持ちます。スケールは K では Q·K の結果に、V では確率の側に掛けて行列積の外に出すので、各ヘッドの計算は 「重み」に対する 2 次元の行列積になります。Qwen3.5-9B の寸法、1 トークンで、16K の塊 1 つが 1.93 ms(KV を 4 次元の まま f16 で渡す素直な形だと 7.4 ms)。 - Gemma 4 は全体アテンションの 7 層だけをこの方式にし、29・35・41 層は 23 層の KV を読みます。スライディング窓の 35 層はグラフの中に残し、過去の KV を窓 512 +最大の塊 64 の輪にしました(先読み検証の巻き戻しでも、窓の中の位置は 上書きされない)。 - Gemma の公式グラフは RoPE の角度を位置番号から NPU の FP16 で計算していて、2048 を超える位置が丸められていました (8192 以上では 8 刻み)。そのため 1 万トークンを超えると回答が崩れました(CPU の FP32 では正しい)。角度の計算だけを グラフから切り出して CPU の FP32 で行い、cos・sin を入力として渡します(Qwen・LFM は元からホストで計算)。 確認:6 モデルとも、従来の固定長の KV との差は最初のトークンのロジットで 0.2〜4.6%(INT8 の丸めの範囲)で、同じ 回答が続きました。LFM2.5-2.6B に約 24,000 トークンの文章を入れ、冒頭近くに埋めた合言葉を最後に尋ねると、正しく 答えました。Gemma 4 E4B も、約 12,500 トークンの記録の列に埋めた合言葉を正しく答えました(RoPE を直す前は崩れた)。 | LFM2.5-2.6B、NPU 3720 | 短い会話 | 約 24,000 トークン | |---|---|---| | 生成 | 11.7 tok/s(従来 12.5) | 7.1 tok/s | | KV キャッシュ | ほぼ 0 | 264 MB | ## onw 0.7 でのメモリ削減 同じモデルのままエンジン側だけで、使用メモリを 2〜6 割減らしました(テスト機での実測、ピーク)。 | モデル | onw 0.6 | onw 0.7 | |---|---|---| | LFM2-8B-A1B | 約 6 GB | 4.8 GB | | Gemma 4 E4B | 約 15 GB | 6.0 GB(画像処理中 8.4 GB) | | Qwen3.5-9B | 約 20 GB | 10.9 GB | | Qwen3.6-REAP-18B-A3B | 約 15 GB | 12.3 GB | | Qwen3.6-35B-A3B | 約 23 GB | 19.9 GB(SSD から読んでキャッシュ 5 GB なら 9.9 GB) | - **コンパイル結果の二重持ちをやめた**:OpenVINO のキャッシュから読み込んだモデルは、あとで書き出せるように コンパイル結果(blob)を保持し続けます。NPU ドライバーは読み込み時に重みを自分のメモリへ写すので、同じものを 2 つ 持っていました。onw は blob を自分で保存・読み込み(`npu_cache/onw_blobs`)、ドライバーへ渡したら手放します (Qwen3.6-35B で 1 トークン版・16 トークン版それぞれ 1.1〜1.4 GB)。Windows はファイルのメモリマップから、 Linux はストリームから読み込みます。 - **画像エンコーダーは画像が来たときだけ読み込み**、メモリ 24 GB 未満の PC では画像を処理したらすぐ解放します (読み込み直しは 1〜5 秒。`ONW_KEEP_VISION=1` で残す)。 - **CPU で引くだけの表は NPU 用メモリに写さない**:埋め込み表と Gemma の層ごとの埋め込み表(1.4 GB)はファイルの メモリマップのまま使い、引いた行だけがメモリに載ります。 - **エキスパートの読み込みは OS のキャッシュを通さない**:起動時に一時的に同じデータが二重に載ることがなくなり、 起動も速くなりました(LFM2 で 30 秒 → 6 秒)。 - 残るもの:コンパイル済みのブロックサイズごとに、NPU 側の作業領域が約 0.6 GB(最初の推論で確保)。 - 調べた道具:`mem_breakdown.py`(段階ごとの内訳)、`mem_stages.py`(区間の読み込み段階ごと)、`mem_import.py` (blob の読み込み方の比較)、`mem_vision.py`、`mem_all.py`(全モデル)。 ## モデルを小さくする(onw convert --compact / --prune) - `--compact`:1 トークン用と 16/64 トークン用のグラフが同じ重みファイルを共有します(ダウンロード量・ディスク)。 ホスト側で引くだけの表(出力層と共有していない埋め込み、Gemma の層ごとの埋め込み)は 32 要素ごとのスケール付き INT4 に、Qwen の画像エンコーダーは INT8 にします。これらは使用メモリも減らしますが、重みファイルの共有だけは ディスクにしか効きません(NPU はブロックサイズごとにコンパイル済みの重みを持つため)。 - `--prune SAL.json --keep 0.5`(MoE):REAP によるエキスパートの刈り込み。`python -m onw.prune calibrate HF_DIR SAL.json` で各エキスパートの寄与度(日本語・英語・コードのチャットデータでの「ルーターの重み×出力の大きさ」の平均)を測り、 `python -m onw.prune eval` で残す割合ごとのパープレキシティを確認できます。Qwen3.6-35B-A3B は 256 個中 128 個を 残してパープレキシティ +8.9%(75% 残しなら +1.4%。Qwen3.6-REAP-18B-A3B-onw として公開)。エキスパートが 32 個しかない LFM2 は半分で +35% と落ち方が大きいため、刈り込んでいません。 ## MTP 先読みと 64 トークン処理(onw 1.4、Lunar Lake) Lunar Lake(NPU 4000、Ornith-1.5-9B NF4)で 1 トークンの生成を測ると、85% が NPU の計算そのもので、16 トークンのブロックは 1 トークンとほぼ同じ時間(区間 1 つあたり 7.2 ms と 6.3 ms)でした。NPU は重みを読む時間で決まっていて、計算は余っています。 そこで、余りを使う 2 つのことをしました。 - **64 トークンのプロンプトブロック**:DeltaNet の chunk 計算は 16 トークンのまま(`ir.deltanet_chunks`:64 トークンを 16 ずつ、 状態を引き継いで計算。FP16 での累積減衰と三角行列の逆行列が長いブロックで崩れないように)、射影は 64 行まとめて計算します。 プロンプト処理は約 2 倍。bf16 モデルとの一致率は 16 トークン版と同じでした(教師強制で 69.5% と 71.3%)。 - **MTP 先読み**:Qwen3.5 系には MTP 層(`mtp.*`:fc で [埋め込み, 最終正規化後の hidden] をまとめ、ゲート付きアテンションの デコーダー層 1 つ、mtp.norm、LM ヘッド)が付いています。位置 i の hidden と i+1 のトークンから i+2 を予想します(bf16 で 78% 一致。 連結の順を逆にすると 0%)。onw はこれを別のグラフ(INT8、約 0.25 GB、NPUW なし)にして、16 トークンのグラフ 1 つで KV 行の 書き込みと下書きを兼ね、2 トークン先まで下書きします。ヘッドは出現頻度の高い 3.2 万語だけ(Wikipedia 日英とコードで数えた 上位。ほかの文章の 96% を占める)で、全語彙の 1/8 の読み込み(11.5 → 2.3 ms)。温度ありでは下書き d を確率 p(d) で採り、 外れたら d を除いた p から選び直します(下書きが決定的なときの投機的サンプリング。1 トークンずつ生成したのと同じ分布)。 - **4 トークンの確認グラフと正確な巻き戻し**:確かめるブロックは「x+下書き 2 つ」なので、4 トークン用のグラフで十分です (16 トークン用より 1 ブロック約 15% 安い)。4 トークンでは DeltaNet を chunk 形式にできない(NPU 4000 のコンパイラーが [1, h, T, T] のテンソルで失敗する)ので、1 トークンの式を 4 回つなぎます。この形だと各行の後の状態もグラフの中にあるので、 それも出力し(`ssm_rows`)、外れた下書きの後ろだけを捨てます(これまでは DeltaNet の状態を途中まで戻せず、ブロックを丸ごと 戻して採ったトークンを次のブロックで計算し直していた)。NPU 4000 のコンパイラーは、最後の行以外の状態を転置したまま 出力する(CPU では正しい。グラフ側で先に転置しても打ち消される)ので、読み込み時に各層・各行の並びを、その行数だけのブロックの 最終状態とビット単位で照らし合わせて決めます(`_check_rows`。合わなければ従来の丸ごと戻す方式)。 8 種類の質問の平均で、MTP なし 10.2 → 16 トークン確認 13.9 → 4 トークン確認 15.1 tok/s(Ornith NF4、greedy)。 - NPU 3720 では 2 トークンのブロックでも 1 トークンの 1.7 倍(16 トークンは 2.4 倍)かかり、MTP は遅くなった(3.6〜4.7 対 4.2〜4.8 tok/s)ので使いません。小さい確認グラフを足すと、NPUW のない 3720 では重みのコピーも 1 つ増えます(約 4 GB)。 - MTP を使うときは 1・16 トークン用のグラフを読み込みません(回答はすべて確認ブロックで書くため)。NPU ドライバーの コマンドキューの枠(doorbell)を区間の数が超えないようにするためでもあります(下の「制約」)。 ## MoE のプロンプトをまとめて計算する(onw 2.0) 16 トークンのブロックでは、ブロックの全トークンが選んだエキスパート(Qwen3.6-35B-A3B で 1 ブロック 80〜110 個)の重みを ブロックごとに読みます。1024 トークンなら同じエキスパートを何十回も読み直すことになり、NPU 3720 で 1024 トークンに 47 秒 かかっていました。AMD 版 onwx で効いた方法を、OpenVINO のグラフで組み直しました(`onw/grouped.py`)。 - **層ごとに進める**:64 トークンのミキサー区間(`seg*_S64m.xml`:アテンションまたは DeltaNet、MoE 前の正規化、ルーター、 共有エキスパートまで。ルーティングされるエキスパートは入れない)で、プロンプトの全ブロックを 1 層ぶん進めます。 - **エキスパートごとにまとめる**:ホストがルーターの上位 8 個でトークンを並べ替え、エキスパートごとに 16/32/64/128 行の まとまりにして、エキスパートを 32 個(128 行は 16 個)並べた行列積のグラフ(重みは実行時の入力、ルーティングの重みも掛ける) に渡します。各エキスパートの重みは、プロンプト全体で 1 回しか読みません。2 つのリクエストを交互に使い、NPU が 1 つを 計算している間に、ホストが次の分を詰めて前の分の結果を書き戻します。 - **ホスト側**:並べ替えは層ごとに 1 回だけで、各ディスパッチは連続したスライスのコピーにしました。書き戻しは別スレッド、 8 個の結果の足し合わせは並べ戻しと 1 回で行います(Panther Lake でホスト側 4.3 → 2.9 秒)。 - **グラフの置き場**:行列積のグラフは onw の blob 置き場(`onw_blobs/grp_*`)に保存し、ロード時に読み込みます。 | Qwen3.6-35B-A3B、1024 トークン | 16 トークンのブロック | onw 2.0 | |---|---|---| | NPU 3720(Core Ultra 9 285HX) | 47.5 秒(21.5 tok/s) | 約 10 秒(約 100 tok/s) | | NPU 5010(Panther Lake) | 約 42 秒(24 tok/s) | 7.0 秒(約 147 tok/s) | 出力の差は 16 トークンのブロックに対して KL 0.0003〜0.0005(最初のトークンは一致)。メモリは、16 トークンのグラフを読まなく なるぶん相殺され、システム全体のピークで +0.3 GB 以内です。ただしミキサー区間の初回コンパイルは重く、16 GB の PC では メモリが足りなくなるので、使うのはメモリ 24 GB 以上の PC だけです(`ONW_GROUPED=1/0`)。 生成(1 トークン)では、区間どうしの入出力をロード時に一度だけ結び付けておき(`onw/wired.py`)、毎回の入力の受け渡しを なくしました(Panther Lake 8.9 → 10.3 tok/s、出力は同じ)。 ## dense モデルのプロンプトを 64 トークンずつ(onw 2.0) dense モデルのプロンプトは 16 トークンのブロックで処理していました。NPU の 1 回の実行には、ブロックの大きさにあまりよらない固定の手間(区間の切り替え、ホスト側の KV キャッシュの処理、行列の並べ替え)があり、16 トークンではそれが大半でした。 - **測ったこと**(Qwen3.5-9B の 1 層、乱数の重み):NPU 3720 では DeltaNet 層の 1 トークンあたりの時間が 16 → 64 トークンで 0.90 → 0.41 ms、128 では 0.41 ms のまま(INT4 のグループ量子化の行列積が 64 行で頭打ち)。Panther Lake では 0.46 → 0.25 ms。 - **やったこと**:64 トークンのグラフを加え、16 トークン用の代わりに読み込みます(1 トークン用+プロンプト用の 2 種類のまま)。NPU 3720 は重みを共有できないので、サイズを足すと重みの写しが増えますが、入れ替えなら増えません。Lunar Lake 以降も、サイズごとに NPU の作業領域(約 0.6 GB)を取るので入れ替えにしました。 - **DeltaNet の計算は 16 トークンのチャンクのまま**(`ir.deltanet_chunks`)。64 トークンを 1 チャンクにする式(減衰をチャンク内の累積とチャンクの合計の積で作り、fp16 の桁落ちを避ける)も試しました。NPU 3720 では状態の読み書きが減って DeltaNet が 1.5 倍速くなりましたが、Panther Lake のコンパイラーでは答えが大きくずれたので採用していません。 - **配布**:公開済みのモデルの重みファイル(`seg*.bin`)は変えていません。同じ変換器で作り直した 64 トークンのグラフの定数は、ほとんどが既存の `seg*.bin` の中に同じものがあり、そこを指します。足りない分(大きなブロックの形を表す整数、区間あたり数百バイト)だけを `seg*x.bin` に置き、読み込み時に入力として読んでから定数に置き換えます(`compact.graft_sizes`、`runtime._xbin_model`)。`engine.json` の `prompt_sizes` に書くので、2.0 より前の onw はこれらを読まず、今までどおり動きます。更新のダウンロードは数 MB です。 - **Panther Lake で計算がずれた件(2.0.1 で止め、2.0.2 で直した)**:Panther Lake(NPU 5010)では、2.0.0 の 64 トークンのグラフの計算結果が正しくありませんでした(Qwen3.5-9B INT4 のロジットの KL が 14)。原因は NPUW の `NPUW_DQ_FULL`(既定 YES)です。グループ量子化された INT4 の行列積を、重みのグループごとの行列積と足し算の連鎖に書き換える処理で、この NPU のコンパイラーが、ちょうど 64 行のときに、出力 4096 の行列積の後に足し算が続く形(各層の出力の射影と残差)を誤ってコンパイルしていました(16・32・48・80・96・128 行は正しい)。OpenVINO 自身の NPUW の LLM モードと llama.cpp は `NPUW_DQ_FULL=NO`(重みを転置するだけ)を使っています。2.0.2 では NPU 5000 番台でこれに合わせました。計算が正しくなったうえに、Qwen3.5-9B の 64 トークンは初回コンパイル 50 → 14 分、1024 トークン 105(誤り)→ 157 tok/s、16 トークンも 44 → 51 tok/s になり、メモリも少し減りました。NPU 4000(Lunar Lake)は YES のまま正しく計算でき、NO では約 2 割遅かったので変えていません。NPU 3720 は NPUW を使いません(使うと INT4 の重みを f16 に展開して持つので、9B でメモリが 9.8 → 34.7 GB になり、生成も半分になります)。 - **128 トークンは見送り**:Panther Lake では 139 tok/s とさらに速くなりますが、使用メモリが 16 トークンより約 1.1 GB 増え、初回のコンパイルが 9B で約 1 時間 40 分かかります(64 トークンで約 50 分。どちらも `NPUW_DQ_FULL` が YES のときの値)。NPU 3720 では 64 より遅くなります。 - **結果**(1024 トークン):Panther Lake の Qwen3.5-9B INT4 44 → 157 tok/s(2.0.2)、NPU 3720 の Qwen3.5-9B 31〜43 → 80〜87、llm-jp-4.1-8B 46 → 111、NeoHorse-1-4B 67 → 132、Granite 4.2 3B 69 → 139 tok/s。16 トークンのブロックとの差は KL 0.00005〜0.0015。 ## 融合グラフと NF4 版の dense モデル(onw 2.1) > onw 2.1.0 では、融合グラフは試験的な機能で既定ではオフです(`ONW_FUSED=1` で有効)。種類ごと・ブロックサイズごとのグラフがそれぞれ重みの写しを持つため、コンパイル済みのキャッシュがモデルの約 6〜7 倍になるからです(Granite 4.2 8B NF4 で約 42 GB、既定の経路では約 10.5 GB)。重みを共有するグラフにして既定に戻す予定です。wired・bound アテンション・2 行目の PLD は既定でオンです。 ページ化した KV キャッシュ(onw 0.10)は、区間をアテンションの位置で切ります。dense の 8B では 1 トークンに重みの部品の呼び出しが約 46 回、アテンションの呼び出しが約 40 回あり、その間の q・アテンションの出力・持ち越す値の受け渡しをホストが担っていました(Panther Lake の Granite 4.2 8B で、重みを読む時間以外が 1 トークン約 135 ms のうち約 35 ms)。 - **融合グラフ**(`onw/fused.py`):区間のグラフの中のアテンション(固定長の f16 キャッシュ)を、ページ化した INT8 キャッシュの最初の塊の読み出しと、このブロックで増えた行の自分自身へのアテンションに置き換え、区間全体を 1 つのグラフにします。 - scores = [q·K_b^T × ks_b(塊 b、INT8)| q·k_new^T] × scale + マスク、o = softmax(scores)·[V_b × vs_b | v_new]。q は行ごとの最大値で割ってから掛けます(INT8 の内積を fp16 に収める。`kvcache.attention_graph` と同じ)。 - 塊のテンソルは kvcache.py が書くのと同じ NPU 向けのホストテンソルを一度だけ入力に結びつけるので、コピーはありません。新しい行は呼び出しの後にホストが塊へ書き込みます。 - グラフの形は固定なので、塊の数ごとに 3 種類(1・2・3 塊 = 1K・2K・4K 位置)を作ります。4K を超えると従来の経路(区間を切ったグラフ)に戻ります。 - 回答用(1・2 トークン)と、NPU 4000 以降ではプロンプト用(64 トークン、アテンションは SDPA)にも作ります。NPU 3720 ではプロンプトの融合は速くならなかった(Granite 4.2 3B で 139 → 135 tok/s)ので、回答だけです。 - 同じサイズの従来のグラフは融合グラフを読み込む前に捨てるので、重みの写しは増えません(同じ大きさの 2 つ目の写しを持たない)。読み込み時に最初の 1 種類を用意し、残りは裏のスレッドでコンパイルだけして onw のキャッシュに置きます(`NPU_CREATE_EXECUTOR=0`)。 - 対象外:MoE、NPUW を使うモデル(Lunar Lake 以降の Qwen3.5 系:NPUW は融合グラフを層ごとの呼び出しに戻してしまい、速くならない)、スライディングウィンドウ。 - **wired(dense モデルの生成)**:2.0 の MoE と同じく、区間どうしの持ち越しの入出力を最初に一度だけ結びつけ、毎トークンの入力の受け渡しをなくしました(`onw/wired.py`)。 - **bound アテンション**:従来の経路のアテンションも、層と塊ごとに推論要求を用意して KV キャッシュのテンソルを結びつけたままにします(`ONW_KV_BOUND=0` で無効)。 - **2 トークンのグラフの空き行で PLD**:NPU 4000 以降で 2 トークンのグラフで回答するモデル(2.0.3)は、2 行目に会話から見つけた下書きを 1 つ載せて確かめます(今までは 64 トークンのグラフで 16 トークンを確かめ、数ステップ分かかっていました)。 - **結果**: | | 既定の経路(2.1.0) | 融合グラフ(`ONW_FUSED=1`) | |---|---|---| | Panther Lake、Granite 4.2 8B NF4、生成 | 9.2〜9.6 tok/s、1.29〜1.37 J/トークン | 10.7〜11.5 tok/s(2K 用)、1.04〜1.11 J/トークン(1K 用は 12.3〜13.1 tok/s) | | NPU 3720、Granite 4.2 3B INT4、1 トークン(短い文脈) | 145 ms | 109 ms(1K)/116 ms(2K)/140 ms(4K) | | NPU 3720、Granite 4.2 3B INT4、チャットの回答 | 6.3〜6.5 tok/s | 7.6〜8.3 tok/s(日本語の 1 問は 5.9 対 6.5) | 出力は従来の経路とほぼ同じです(NPU 3720 の Granite 4.2 3B で 1 トークンごとの KL 0.001〜0.015。僅差の候補(ロジットの差 0.03)で選ぶトークンが入れ替わることがあり、その先の言い回しが分かれます)。使用メモリは落ち着いた状態では同じで、読み込みや種類の切り替えのときだけ作業セットのピークが 0.2〜1.1 GB 高くなります(NPU 3720 の Granite 4.2 3B で +0.9 GB、Panther Lake の NF4 版で +0.2〜1.1 GB)。 - **かかるもの**: - コンパイル:3 種類 × サイズ数のグラフを追加でコンパイルします(Panther Lake の Granite 4.2 8B NF4 で、読み込み 22 分のあと裏で約 22 分)。キャッシュのディスク使用量も増えます(同じく約 42 GB。従来の経路だけなら約 11 GB)。 - 種類の切り替え:会話が 1K・2K 位置を超えるとき、4K を超えて従来の経路に戻るとき、長い会話の後に短い会話に戻るとき、グラフを読み直します(Panther Lake の Granite 4.2 8B で 1 サイズ 6〜10 秒、NPU 3720 の 3B で約 3 秒)。この時間はプロンプトの処理時間に入ります。 - **NF4 版(形式 C)**:Lunar Lake 以降向けの Granite 4.2 3B/8B、llm-jp-4.1-8B は、重みをチャネル単位の NF4 にし、q/k/v_proj と down_proj だけチャネル単位の INT8 にしました。いくつかの組み合わせ(全部 NF4、q/k/v だけ INT8、グループ 256 の NF4)を教師強制の精度で比べ、精度と速さの釣り合いでこれを選びました(512 トークンで 1 位の一致率/KL:Granite 4.2 8B 88.5%/0.097、INT4 版 85.5%/0.132)。グラフは 2 トークン用と 64 トークン用だけです(NPU 4000 以降は 2 トークン用で回答します)。 ## 1 ステップのホスト側の処理(onw 1.4) MTP の 1 ブロックのうち、NPU 以外(Python・numpy)が約 47 ms ありました。生成結果を 1 ビットも変えずに、次のことで約 32 ms に しました(Gemma、LFM2、Granite、llm-jp、Qwen3.5/3.6、gpt-oss、Ornith で、変更前と同じトークン列を確認)。 - 推論の入力をコピーしない(`share_inputs`)。入力の型が違うときだけ変換した配列を渡す。 - DeltaNet の状態を 2 つのバッファで交互に使う:区間は今の方を読み、新しい状態をもう一方へ直接書く(出力に `set_tensor`)。 ブロックを戻すときは入れ替えを戻すだけで、25 MB の状態のコピーが要らない。 - アテンションの出力(KV のブロックが 1 つのとき)を、割り算と並べ替えを 1 回で次の区間の形(f16)へ書く。マスクはブロックごとに 1 回だけ作る。greedy でペナルティなしのときは、確率分布を作らずに argmax だけで決める。 ## 複数トークンのブロックで、ロジットが行ごと 0 になる(onw 1.4 で修正) 先読み検証・MTP のブロックで、活性の大きい行(数式の `\times` や数字の後など)の最終 RMSNorm の二乗和が NPU の fp16 で溢れ、 その行のロジットがすべて 0 で返ることがありました(1,400 トークンに 16 回ほど)。温度ありではこれが全語彙の一様分布になり、他の言語の 単語などが混ざります。RMSNorm は入力の倍率によらないので、0 の行だけ行の最大値で割ってからヘッドをもう一度実行します (正常な行はそのまま)。 ## NPU で分かった制約(実測) onw を作る途中で、NPU 3720/4000(OpenVINO 2026.5.0b1)について分かったことです。 - 非対称 INT4 は使えない:対称 INT4(グループ 128)、予備に対称 INT8。 - FP16 の非正規化数は NPU では 0 になる:二乗平均などは 6e-5 以上に収まる大きさで計算する。実行時入力の INT8 コードは 生のまま積和されるので、活性をトークンごとに最大値で割ってから掛ける(gpt-oss の節)。 - INT4 定数からの Gather はコンパイルできない。INT8/FP16 定数や実行時入力からの Gather は表全体を転送するので遅い。 - 実行時に渡す重みが全速で動くのはチャネル単位 INT4(または INT8)だけ。グループ単位のスケールは約 20 倍遅い。 - NPU のホストテンソル(`create_host_tensor`)を `set_tensor` で差し替えるのはコピーなし。 - Gemma のヘッド次元 512 の P·V は誤り(V を 256 幅に分けて回避)。16 トークンの logit softcap も誤り(1/30 を入力側へ)。 - NPUW の畳み込み(NPUW_FOLD)は NPU 3720 では Gemma の出力が壊れる。NPU 4000 ではコンパイラー側の動的量子化 (NPUW_DQ+NPU_COMPILER_DYNAMIC_QUANTIZATION)と組み合わせれば正しく、しかも必須(Gemma を丸ごと通常コンパイルすると 終わらない)。 - NPU 4000 では、NPUW_SLICE_OUT は複数トークンの区間で形が合わなくなる。bool の定数は NPUW で推論リクエストの作成時に 失敗する(u8 の定数+Convert に置き換える)。「compute-layers-with-higher-precision」を指定するとコンパイラーが 展開できない変換が生まれる。NPU_TILES=1 はコンパイラーごと落ちる(だからコンパイルは子プロセスで試す)。 - Linux の NPU ドライバー 1.38 より前は、LFM2 の最初の畳み込み層を NPU 4000 で誤って計算した(値が約 2 のところ 1e4、 FP16 で溢れて NaN)。onw は NaN のロジットを検出したら止めます。 - OpenVINO のキャッシュから読み込んだモデルは blob を保持し続ける(書き出し用)。自分で書き出した blob を読み込めば手放せる。 - 位置番号を NPU で FP16 にすると 2048 を超える位置が丸められ、グラフ内で計算する RoPE が長い文脈で壊れる(cos・sin は ホストで作って渡す)。 - LFM2.5-2.6B は残差の値がとても小さい(RMS 約 0.02)トークンと値の大きい BOS が同じブロックに並び、RMSNorm の二乗平均と eps(1e-5)が FP16 の正規化数の範囲(6e-5 以上)を下回って、NPU の区間出力が 50〜77% ずれた。固定の倍率では両立しない ため、各行を自分の最大値で割ってから二乗平均を取る(必ず 1/次元数以上になる)。eps も (√eps/最大値)² として計算(差は 1〜5% に)。 - NPUW の重み共有(NPUW_WEIGHTS_BANK)は、同じプロセスでコンパイルしたグラフどうしでだけ効く。別々の回にコンパイルして キャッシュから読んだグラフは、それぞれ重みを持つ(Ornith 9B NF4:1・16 トークン版を一緒にコンパイル 4.6 GB、16 トークン版まで と 64 トークン版を別の回にコンパイル 8.7 GB)。onw はキャッシュのフォルダーにコンパイルした回を記録し、混ざっていたら まとめてコンパイルし直す(1.4.0)。ドライバーのメモリは `/proc/meminfo` の Shmem(Unevictable)に出て、プロセスの RSS には出ない。 - NPU 4000 の Linux ドライバーは、1 つのプロセスで推論リクエスト(コマンドキュー)を約 40 を超えて作ると doorbell の割り当てに 失敗し(`Failed to allocate doorbell ID: -16`)、以後は再起動するまでどのプロセスも NPU で推論できなくなった (`Failed to create command queue: -74`)。