onw 2.0.0: Qwen3.6 MoE prompts ~4-6x (grouped experts), dense prompts ~2-2.5x (64-token blocks), faster MoE decode
Browse files- CHANGELOG.md +16 -0
- CHANGELOG_en.md +16 -0
- MANUAL.md +1 -1
- MANUAL_en.md +1 -1
- README.md +6 -2
- README_en.md +7 -3
- TECHNICAL.md +41 -0
- TECHNICAL_en.md +57 -0
- onw/__init__.py +1 -1
- onw/app.py +18 -5
- onw/chat.py +50 -28
- onw/compact.py +51 -0
- onw/grouped.py +249 -0
- onw/i18n.json +2 -2
- onw/models/qwen3_5_moe.py +42 -5
- onw/runtime.py +120 -4
- onw/web/manage.html +1 -1
- onw/wired.py +142 -0
CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@
|
|
| 5 |
日付は日本時間です。各版は Hugging Face の `ryugyosoft/onw` にタグ(`v0.4.2` 以降)付きで残っています。
|
| 6 |
onw ウィンドウは、新しい版があるとこのファイルから変更点を表示します(`## v…` の見出しで区切っています)。
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
## v1.4.13 (2026-10-16)
|
| 9 |
- **Ternary Bonsai 2 9B / 4B(2 ビット、画像も読める、Lunar Lake 以降専用)をモデル一覧に追加**:Prism ML が Qwen3.5-9B / 4B を三値(−1・0・+1)の重みで学習し直したモデルを、重みを 2 ビットのまま NPU で動かします。Lunar Lake で 9B は生成約 13 tok/s・メモリ約 3.5 GB、4B は約 21 tok/s・約 2 GB です。27B と同じく、NPU コンパイラーの制限でプロンプトは 1 トークンずつ処理します。
|
| 10 |
- `onw convert` が Bonsai 2 9B(回転ブロック 4096)・4B(回転ブロック 512、埋め込みと共有の LM ヘッド)と、その画像エンコーダーを読めるようになりました。Bonsai 2 27B の変換結果は変わりません(コンパイル済みのキャッシュもそのまま使えます)。
|
|
|
|
| 5 |
日付は日本時間です。各版は Hugging Face の `ryugyosoft/onw` にタグ(`v0.4.2` 以降)付きで残っています。
|
| 6 |
onw ウィンドウは、新しい版があるとこのファイルから変更点を表示します(`## v…` の見出しで区切っています)。
|
| 7 |
|
| 8 |
+
## v2.0.0 (2026-10-17)
|
| 9 |
+
- **Qwen3.6-35B-A3B と Qwen3.6-REAP-18B-A3B のプロンプト処理が約 4〜6 倍に**(メモリ 24 GB 以上の PC):AMD 版 onwx と同じ工夫を Intel NPU に移しました。プロンプト全体を層ごとに処理し、同じエキスパートを選んだトークンをまとめて 1 回で計算します(今までは 16 トークンのブロックごとに、選ばれたエキスパートの重みを毎回読んでいました)。
|
| 10 |
+
- Panther Lake(Qwen3.6-35B-A3B、1024 トークン):約 24 → 約 147 tok/s(42 秒 → 7 秒)。
|
| 11 |
+
- Core Ultra 9 285HX(NPU 3720):Qwen3.6-35B-A3B 約 22 → 約 100 tok/s、REAP-18B 約 24 → 約 106 tok/s。
|
| 12 |
+
- 出力はほぼ同じです(16 トークンのブロックとの差は KL 0.0003〜0.0005、最初のトークンは一致)。
|
| 13 |
+
- 使用メモリはほぼ変わりません(システム全体のピーク:Panther Lake で +0.1〜0.3 GB、NPU 3720 では 16 トークンのグラフを読み込まなくなるぶん同じか少なめ)。
|
| 14 |
+
- **モデルの更新が必要です**(カードに「更新あり」と出ます。差し替えは区間ファイルの約 0.9 GB だけです)。更新すると初回のロードで新しいグラフをコンパイルし直します。古い onw でも更新後のモデルはそのまま動きます。
|
| 15 |
+
- メモリ 24 GB 未満の PC(16 GB の Lunar Lake など)では、新しいグラフのコンパイル中にメモリが足りなくなるため、今までどおりの処理のままです(`ONW_GROUPED=1` で強制、`0` で無効)。
|
| 16 |
+
- **Qwen3.6 系の生成が少し速く**:1 トークンの処理で、区間どうしの入出力を最初に一度だけつないでおき、毎回の受け渡しをなくしました。Panther Lake で 8.9 → 10.3 tok/s、NPU 3720 で 6.0 → 6.5 tok/s(出力は同じ)。
|
| 17 |
+
- **dense モデルのプロンプト処理が約 2〜2.5 倍に**:Qwen3.5-9B、Ornith-1.5-9B、NeoHorse-1-4B、Granite 4.2 3B / 8B、llm-jp-4.1-8B-thinking(INT4 / INT8)に 64 トークンのブロックを加え、プロンプトを 16 トークンずつではなく 64 トークンずつ処理します。Gemma 4 E4B と NF4 版は、今まで 64 トークンのブロックを使えなかった PC(NPU 3720 のメモリ 24 GB 未満、NPUW が使えない設定など)でも使うようになります。
|
| 18 |
+
- Panther Lake(Qwen3.5-9B INT4、1024 トークン):約 44 → 約 105 tok/s。
|
| 19 |
+
- Core Ultra 9 285HX(NPU 3720):Qwen3.5-9B 約 31〜43 → 約 80〜87 tok/s、llm-jp-4.1-8B 約 46 → 約 111、NeoHorse-1-4B 約 67 → 約 132、Granite 4.2 3B 約 69 → 約 139 tok/s。
|
| 20 |
+
- 出力はほぼ同じです(16 トークンのブロックとの差は KL 0.00005〜0.0015、最初のトークンは一致)。
|
| 21 |
+
- 使用メモリはほぼ変わりません(+0.35〜0.5 GB)。16 トークン用のグラフの代わりに 64 トークン用を読み込むので、重みの写しは増えません。そのぶん、短い入力や先読み検証は少し割高になります(NPU 3720 では先読み検証が自動でオフになります)。設定の「64 トークン処理」を off にすると今までどおりです。
|
| 22 |
+
- **モデルの更新が必要です**(カードに「更新あり」と出ます。追加のグラフファイル数 MB だけで、重みのファイルは変わりません)。更新後の初回のロードで新しいグラフをコンパイルします(Panther Lake の 9B で数十分かかります)。古い onw は追加分を読まず、更新後のモデルもそのまま動きます。
|
| 23 |
+
|
| 24 |
## v1.4.13 (2026-10-16)
|
| 25 |
- **Ternary Bonsai 2 9B / 4B(2 ビット、画像も読める、Lunar Lake 以降専用)をモデル一覧に追加**:Prism ML が Qwen3.5-9B / 4B を三値(−1・0・+1)の重みで学習し直したモデルを、重みを 2 ビットのまま NPU で動かします。Lunar Lake で 9B は生成約 13 tok/s・メモリ約 3.5 GB、4B は約 21 tok/s・約 2 GB です。27B と同じく、NPU コンパイラーの制限でプロンプトは 1 トークンずつ処理します。
|
| 26 |
- `onw convert` が Bonsai 2 9B(回転ブロック 4096)・4B(回転ブロック 512、埋め込みと共有の LM ヘッド)と、その画像エンコーダーを読めるようになりました。Bonsai 2 27B の変換結果は変わりません(コンパイル済みのキャッシュもそのまま使えます)。
|
CHANGELOG_en.md
CHANGED
|
@@ -4,6 +4,22 @@
|
|
| 4 |
|
| 5 |
Dates are JST. Every version is on Hugging Face `ryugyosoft/onw`, tagged from `v0.4.2` on.
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
## v1.4.13 (2026-10-16)
|
| 8 |
- **Ternary Bonsai 2 9B / 4B (2-bit, reads images, Lunar Lake and newer only) in the model list**: Prism ML's Qwen3.5-9B / 4B retrained with ternary weights (-1, 0, +1), run on the NPU with the weights kept 2-bit. On Lunar Lake the 9B generates ~13 tok/s in ~3.5 GB, the 4B ~21 tok/s in ~2 GB. As with the 27B, the NPU compilers' limit means prompts are processed token by token.
|
| 9 |
- `onw convert` reads Bonsai 2 9B (rotation blocks of 4096), 4B (blocks of 512, an LM head tied to the embedding) and their image encoder. Bonsai 2 27B converts exactly as before (compiled caches stay valid).
|
|
|
|
| 4 |
|
| 5 |
Dates are JST. Every version is on Hugging Face `ryugyosoft/onw`, tagged from `v0.4.2` on.
|
| 6 |
|
| 7 |
+
## v2.0.0 (2026-10-17)
|
| 8 |
+
- **Prompts of Qwen3.6-35B-A3B and Qwen3.6-REAP-18B-A3B about 4-6x faster** (PCs with 24 GB of RAM or more): the trick of onwx, the AMD sibling, on the Intel NPU. The whole prompt runs layer by layer, and the tokens that picked the same expert are computed together in one go (before, every 16-token block read its chosen experts' weights again).
|
| 9 |
+
- Panther Lake (Qwen3.6-35B-A3B, 1,024 tokens): ~24 -> ~147 tok/s (42 s -> 7 s).
|
| 10 |
+
- Core Ultra 9 285HX (NPU 3720): Qwen3.6-35B-A3B ~22 -> ~100 tok/s, REAP-18B ~24 -> ~106 tok/s.
|
| 11 |
+
- Practically the same output (KL 0.0003-0.0005 against the 16-token blocks, the first token equal).
|
| 12 |
+
- Practically no extra memory (system-wide peak: +0.1-0.3 GB on Panther Lake; on the NPU 3720 the same or less, as the 16-token graphs are no longer loaded).
|
| 13 |
+
- **The models need an update** (their card shows "update available"; only the segment files, ~0.9 GB, are replaced). The first load after it compiles the new graphs. Older onw versions keep working with the updated models.
|
| 14 |
+
- On PCs below 24 GB (a 16 GB Lunar Lake, for example) compiling the new graphs runs out of memory, so they keep the old prompt path (`ONW_GROUPED=1` forces it, `0` turns it off).
|
| 15 |
+
- **Qwen3.6 models generate a little faster**: for single tokens the segments' inputs and outputs are wired to each other once, with no hand-over at every token. Panther Lake 8.9 -> 10.3 tok/s, NPU 3720 6.0 -> 6.5 tok/s (same output).
|
| 16 |
+
- **Prompts of the dense models about 2-2.5x faster**: Qwen3.5-9B, Ornith-1.5-9B, NeoHorse-1-4B, Granite 4.2 3B / 8B and llm-jp-4.1-8B-thinking (INT4 / INT8) get 64-token blocks, so prompts run 64 tokens at a time instead of 16. Gemma 4 E4B and the NF4 models now use their 64-token blocks on PCs that could not before too (NPU 3720 with less than 24 GB, NPUW off, ...).
|
| 17 |
+
- Panther Lake (Qwen3.5-9B INT4, 1,024 tokens): ~44 -> ~105 tok/s.
|
| 18 |
+
- Core Ultra 9 285HX (NPU 3720): Qwen3.5-9B ~31-43 -> ~80-87 tok/s, llm-jp-4.1-8B ~46 -> ~111, NeoHorse-1-4B ~67 -> ~132, Granite 4.2 3B ~69 -> ~139 tok/s.
|
| 19 |
+
- Practically the same output (KL 0.00005-0.0015 against the 16-token blocks, the first token equal).
|
| 20 |
+
- Practically no extra memory (+0.35-0.5 GB): the 64-token graphs are loaded in place of the 16-token ones, so there is no extra copy of the weights. Short inputs and draft checks cost a little more on them (on the NPU 3720 prompt lookup decoding turns itself off). The setting "64-token blocks" = off keeps the old way.
|
| 21 |
+
- **The models need an update** (their card shows "update available"; just the added graph files, a few MB - the weights files stay the same). The first load after it compiles the new graphs (tens of minutes for a 9B on Panther Lake). Older onw versions skip the additions and keep working with the updated models.
|
| 22 |
+
|
| 23 |
## v1.4.13 (2026-10-16)
|
| 24 |
- **Ternary Bonsai 2 9B / 4B (2-bit, reads images, Lunar Lake and newer only) in the model list**: Prism ML's Qwen3.5-9B / 4B retrained with ternary weights (-1, 0, +1), run on the NPU with the weights kept 2-bit. On Lunar Lake the 9B generates ~13 tok/s in ~3.5 GB, the 4B ~21 tok/s in ~2 GB. As with the 27B, the NPU compilers' limit means prompts are processed token by token.
|
| 25 |
- `onw convert` reads Bonsai 2 9B (rotation blocks of 4096), 4B (blocks of 512, an LM head tied to the embedding) and their image encoder. Bonsai 2 27B converts exactly as before (compiled caches stay valid).
|
MANUAL.md
CHANGED
|
@@ -167,7 +167,7 @@
|
|
| 167 |
| メモリ節約モード | 1 トークン用と 16 トークン用のグラフを入れ替えて、片方だけを持ちます。待機中のメモリが約 4 割減る代わりに、回答の出だし数秒は遅めで、先読み検証は使いません。初回のコンパイルも並列にせず 1 つずつ行います(コンパイル中のメモリを抑えるため)。効くモデルがあるときだけ表示され、この PC で効くモデルの名前が出ます |
|
| 168 |
| 厳格 | メモリ節約モードの追加。2 種類のグラフを同時に持たないので、メモリは最小になりますが、回答のたびに書き始めるまで数秒待ちます |
|
| 169 |
| エキスパートのメモリ | Qwen3.6 系の MoE 用。上限を超えるエキスパートは SSD に置いたまま、必要なときだけ読みます(メモリが少なくても動きますが、遅くなります) |
|
| 170 |
-
| 64 トークン処理 | 長いプロンプトの処理が約 2 倍速くなります(
|
| 171 |
|
| 172 |
### 詳細
|
| 173 |
|
|
|
|
| 167 |
| メモリ節約モード | 1 トークン用と 16 トークン用のグラフを入れ替えて、片方だけを持ちます。待機中のメモリが約 4 割減る代わりに、回答の出だし数秒は遅めで、先読み検証は使いません。初回のコンパイルも並列にせず 1 つずつ行います(コンパイル中のメモリを抑えるため)。効くモデルがあるときだけ表示され、この PC で効くモデルの名前が出ます |
|
| 168 |
| 厳格 | メモリ節約モードの追加。2 種類のグラフを同時に持たないので、メモリは最小になりますが、回答のたびに書き始めるまで数秒待ちます |
|
| 169 |
| エキスパートのメモリ | Qwen3.6 系の MoE 用。上限を超えるエキスパートは SSD に置いたまま、必要なときだけ読みます(メモリが少なくても動きますが、遅くなります) |
|
| 170 |
+
| 64 トークン処理 | 長いプロンプトの処理が約 2〜2.5 倍速くなります(dense モデル。Qwen3.6 系は別の方法で速くしています)。16 トークン用のグラフの代わりに読み込むので、メモリはほぼ増えません(+0.3〜0.5 GB)。auto はオン。off にすると今までどおり 16 トークンずつ(短い入力の多い使い方や、NPU 3720 で先読み検証を使いたいとき) |
|
| 171 |
|
| 172 |
### 詳細
|
| 173 |
|
MANUAL_en.md
CHANGED
|
@@ -178,7 +178,7 @@ applies them ("Discard" throws them away). For settings that need a server resta
|
|
| 178 |
| Memory-saving mode | Swaps the 1-token and 16-token graphs so only one set is held: about 40% less memory when idle, but the first seconds of an answer are slower and prompt lookup is not used. The first compile also runs one graph at a time instead of in parallel (less memory while compiling). Shown only when it applies, with the models it works for on this PC |
|
| 179 |
| Strict | An addition to the memory-saving mode: never holds both sets of graphs, for the least memory, but every answer waits a few seconds before it starts |
|
| 180 |
| Expert memory | For the Qwen3.6 MoE models: experts beyond the limit stay on the SSD and are read when needed (works with less memory, but slower) |
|
| 181 |
-
| 64-token blocks | Long prompts are processed ~
|
| 182 |
|
| 183 |
### Advanced
|
| 184 |
|
|
|
|
| 178 |
| Memory-saving mode | Swaps the 1-token and 16-token graphs so only one set is held: about 40% less memory when idle, but the first seconds of an answer are slower and prompt lookup is not used. The first compile also runs one graph at a time instead of in parallel (less memory while compiling). Shown only when it applies, with the models it works for on this PC |
|
| 179 |
| Strict | An addition to the memory-saving mode: never holds both sets of graphs, for the least memory, but every answer waits a few seconds before it starts |
|
| 180 |
| Expert memory | For the Qwen3.6 MoE models: experts beyond the limit stay on the SSD and are read when needed (works with less memory, but slower) |
|
| 181 |
+
| 64-token blocks | Long prompts are processed ~2-2.5x faster (dense models; the Qwen3.6 models are sped up another way). Loaded in place of the 16-token graphs, so memory hardly grows (+0.3-0.5 GB). auto = on. off = 16 tokens at a time as before (for mostly short inputs, or to keep prompt lookup decoding on the NPU 3720) |
|
| 182 |
|
| 183 |
### Advanced
|
| 184 |
|
README.md
CHANGED
|
@@ -129,7 +129,11 @@ NPU は数トークンを 1 トークンとほぼ同じ時間で計算できる
|
|
| 129 |
(コンテキスト長、64 トークン処理、エキスパートのメモリ)を反映した目安です。メモリが足りない PC では、Qwen3.6 系の
|
| 130 |
エキスパートを SSD に置いたまま動かせます(設定の「エキスパートのメモリ」。仕組みは[技術解説](TECHNICAL.md))。
|
| 131 |
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
「思考」の列の「切替」のモデル(Qwen、LFM2.5、Gemma 4、Granite 4.2 など)は、既定では考えずに答えます(チャット画面の「思考モード」、API の
|
| 134 |
`chat_template_kwargs: {"enable_thinking": true}` または `reasoning_effort` で考えさせられます)。「常に」のモデル(gpt-oss、llm-jp)は思考モードがオフでも裏で考え、その分も最大トークンに数えます(`max_tokens` は大きめに)。
|
| 135 |
モデルが考えた思考は、回答とは別に、API では `reasoning_content`(トークン数は `usage.completion_tokens_details.reasoning_tokens`)、チャット画面では折りたためる
|
|
@@ -190,7 +194,7 @@ onw list # 対応アーキ
|
|
| 190 |
| temperature、top_p、top_k、繰り返し抑制(repetition / presence / frequency penalty)、max_tokens、stop、seed、思考モード | リクエストごと(API またはチャット画面) | 不要 |
|
| 191 |
| ポート、待ち受けアドレス、モデル、デバイス、先読み検証のオン/オフ | サーバーのオプション/設定画面、再起動(約 20 秒) | 不要 |
|
| 192 |
| **コンテキスト長**(`--context`、既定 32K、モデルの最大まで) | サーバーのオプション/設定画面 | 不要(KV キャッシュの上限が変わるだけ) |
|
| 193 |
-
| 64 トークン処理(
|
| 194 |
| MTP 先読み(Qwen3.5 系 NF4 版。auto は Lunar Lake 以降でオン) | `ONW_MTP=1/0` | 初回のみ |
|
| 195 |
| エキスパートのメモリ(Qwen3.6 系。上限を超える分は SSD から読む) | `ONW_EXPERT_GB=5`/設定画面 | 不要 |
|
| 196 |
| NPUW 重み共有(試験的。auto は Lunar Lake 以降でオン、MoE モデル(LFM2・Qwen3.6)と区間の多いモデル(Granite・llm-jp)は対象外:重みを 1 コピーにしてメモリとコンパイル時間を削減) | `ONW_NPUW=1/0`/設定画面 | 初回のみ |
|
|
|
|
| 129 |
(コンテキスト長、64 トークン処理、エキスパートのメモリ)を反映した目安です。メモリが足りない PC では、Qwen3.6 系の
|
| 130 |
エキスパートを SSD に置いたまま動かせます(設定の「エキスパートのメモリ」。仕組みは[技術解説](TECHNICAL.md))。
|
| 131 |
|
| 132 |
+
プロンプト(入力した文章)の読み込みは、dense モデルは 64 トークンずつ、Qwen3.6 系はプロンプト全体を層ごとにまとめて
|
| 133 |
+
処理します(onw 2.0)。NPU 3720 で Qwen3.5-9B 約 80〜87 tok/s、llm-jp-4.1-8B 約 110、Granite 4.2 3B 約 140、Qwen3.6-35B-A3B
|
| 134 |
+
約 100 tok/s(メモリ 24 GB 以上)、Panther Lake で Qwen3.5-9B 約 105、Qwen3.6-35B-A3B 約 147 tok/s です。
|
| 135 |
+
|
| 136 |
+
先読み検証は、検証ブロックが十分安く済むモデル(LFM 系、Lunar Lake 以降の Qwen3.5-9B・Gemma など)で自動的に使われます。
|
| 137 |
「思考」の列の「切替」のモデル(Qwen、LFM2.5、Gemma 4、Granite 4.2 など)は、既定では考えずに答えます(チャット画面の「思考モード」、API の
|
| 138 |
`chat_template_kwargs: {"enable_thinking": true}` または `reasoning_effort` で考えさせられます)。「常に」のモデル(gpt-oss、llm-jp)は思考モードがオフでも裏で考え、その分も最大トークンに数えます(`max_tokens` は大きめに)。
|
| 139 |
モデルが考えた思考は、回答とは別に、API では `reasoning_content`(トークン数は `usage.completion_tokens_details.reasoning_tokens`)、チャット画面では折りたためる
|
|
|
|
| 194 |
| temperature、top_p、top_k、繰り返し抑制(repetition / presence / frequency penalty)、max_tokens、stop、seed、思考モード | リクエストごと(API またはチャット画面) | 不要 |
|
| 195 |
| ポート、待ち受けアドレス、モデル、デバイス、先読み検証のオン/オフ | サーバーのオプション/設定画面、再起動(約 20 秒) | 不要 |
|
| 196 |
| **コンテキスト長**(`--context`、既定 32K、モデルの最大まで) | サーバーのオプション/設定画面 | 不要(KV キャッシュの上限が変わるだけ) |
|
| 197 |
+
| 64 トークン処理(dense モデルのプロンプトを 64 トークンずつ。auto はオンで、16 トークン用のグラフの代わりに読み込む。off で 16 トークンずつ、`ONW_S64=1` で両方) | `ONW_S64=1/0`/設定画面 | 初回のみ |
|
| 198 |
| MTP 先読み(Qwen3.5 系 NF4 版。auto は Lunar Lake 以降でオン) | `ONW_MTP=1/0` | 初回のみ |
|
| 199 |
| エキスパートのメモリ(Qwen3.6 系。上限を超える分は SSD から読む) | `ONW_EXPERT_GB=5`/設定画面 | 不要 |
|
| 200 |
| NPUW 重み共有(試験的。auto は Lunar Lake 以降でオン、MoE モデル(LFM2・Qwen3.6)と区間の多いモデル(Granite・llm-jp)は対象外:重みを 1 コピーにしてメモリとコンパイル時間を削減) | `ONW_NPUW=1/0`/設定画面 | 初回のみ |
|
README_en.md
CHANGED
|
@@ -118,8 +118,12 @@ vision encoder freed after each image (the default below 24 GB of RAM). The "Mem
|
|
| 118 |
window applies that PC's settings (context length, 64-token blocks, expert memory). On a PC short of memory the
|
| 119 |
Qwen3.6 models can keep their experts on the SSD (Settings → "Expert memory"; how it works: [Technical notes](TECHNICAL_en.md)).
|
| 120 |
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
re-processes only the new message: ~2 s). Models marked "switchable" in the thinking column (Qwen, LFM2.5, Gemma 4, Granite 4.2, ...) answer without thinking by
|
| 124 |
default (the chat page's thinking switch, `chat_template_kwargs: {"enable_thinking": true}` or `reasoning_effort` turns
|
| 125 |
it on); models marked "always" (gpt-oss, llm-jp) think even with thinking mode off, and that counts against max_tokens (give them room). Whatever a model thought comes apart from the answer (its token count as `usage.completion_tokens_details.reasoning_tokens`):
|
|
@@ -178,7 +182,7 @@ What needs what:
|
|
| 178 |
| temperature, top_p, top_k, repetition / presence / frequency penalty, max_tokens, stop, seed, thinking | per request (API or chat UI) | no |
|
| 179 |
| port, address, model, device, prompt lookup on/off | server option / settings, restart (~20 s) | no |
|
| 180 |
| **context length** (`--context`, 32K by default, up to the model's maximum) | server option / settings | no (it only bounds the KV cache) |
|
| 181 |
-
| 64-token prompt blocks (
|
| 182 |
| MTP draft tokens (the NF4 Qwen3.5-family builds; auto = on from Lunar Lake) | `ONW_MTP=1/0` | once, the first time |
|
| 183 |
| expert memory (Qwen3.6 models; the rest streams from the SSD) | `ONW_EXPERT_GB=5` / settings | no |
|
| 184 |
| NPUW weight sharing (experimental; auto = on from Lunar Lake, not for the MoE models LFM2 / Qwen3.6 or models with many parts (Granite, llm-jp): one copy of the weights, less memory, much faster compile) | `ONW_NPUW=1/0` / settings | once, the first time |
|
|
|
|
| 118 |
window applies that PC's settings (context length, 64-token blocks, expert memory). On a PC short of memory the
|
| 119 |
Qwen3.6 models can keep their experts on the SSD (Settings → "Expert memory"; how it works: [Technical notes](TECHNICAL_en.md)).
|
| 120 |
|
| 121 |
+
Prompts (the text you send) are read 64 tokens at a time on the dense models, and layer by layer over the whole prompt
|
| 122 |
+
on the Qwen3.6 models (onw 2.0): on the NPU 3720 Qwen3.5-9B ~80-87 tok/s, llm-jp-4.1-8B ~110, Granite 4.2 3B ~140,
|
| 123 |
+
Qwen3.6-35B-A3B ~100 tok/s (24 GB of RAM or more); on Panther Lake Qwen3.5-9B ~105, Qwen3.6-35B-A3B ~147 tok/s.
|
| 124 |
+
|
| 125 |
+
Prompt lookup decoding is used automatically where a verification block is cheap enough (the LFM models, Qwen3.5-9B
|
| 126 |
+
and Gemma from Lunar Lake on, ...), and every model keeps the conversation state between turns (a follow-up with an image
|
| 127 |
re-processes only the new message: ~2 s). Models marked "switchable" in the thinking column (Qwen, LFM2.5, Gemma 4, Granite 4.2, ...) answer without thinking by
|
| 128 |
default (the chat page's thinking switch, `chat_template_kwargs: {"enable_thinking": true}` or `reasoning_effort` turns
|
| 129 |
it on); models marked "always" (gpt-oss, llm-jp) think even with thinking mode off, and that counts against max_tokens (give them room). Whatever a model thought comes apart from the answer (its token count as `usage.completion_tokens_details.reasoning_tokens`):
|
|
|
|
| 182 |
| temperature, top_p, top_k, repetition / presence / frequency penalty, max_tokens, stop, seed, thinking | per request (API or chat UI) | no |
|
| 183 |
| port, address, model, device, prompt lookup on/off | server option / settings, restart (~20 s) | no |
|
| 184 |
| **context length** (`--context`, 32K by default, up to the model's maximum) | server option / settings | no (it only bounds the KV cache) |
|
| 185 |
+
| 64-token prompt blocks (dense models read prompts 64 tokens at a time; auto = on, loaded in place of the 16-token graphs; off = 16 at a time, `ONW_S64=1` = both) | `ONW_S64=1/0` / settings | once, the first time |
|
| 186 |
| MTP draft tokens (the NF4 Qwen3.5-family builds; auto = on from Lunar Lake) | `ONW_MTP=1/0` | once, the first time |
|
| 187 |
| expert memory (Qwen3.6 models; the rest streams from the SSD) | `ONW_EXPERT_GB=5` / settings | no |
|
| 188 |
| NPUW weight sharing (experimental; auto = on from Lunar Lake, not for the MoE models LFM2 / Qwen3.6 or models with many parts (Granite, llm-jp): one copy of the weights, less memory, much faster compile) | `ONW_NPUW=1/0` / settings | once, the first time |
|
TECHNICAL.md
CHANGED
|
@@ -14,6 +14,8 @@ onw をどうやって Intel NPU に載せたかの説明です。使い方は [
|
|
| 14 |
- onw 0.7 でのメモリ削減
|
| 15 |
- モデルを小さくする(onw convert --compact / --prune)
|
| 16 |
- MTP 先読みと 64 トークン処理(onw 1.4、Lunar Lake)
|
|
|
|
|
|
|
| 17 |
- 1 ステップのホスト側の処理(onw 1.4)
|
| 18 |
- 複数トークンのブロックで、ロジットが行ごと 0 になる(onw 1.4 で修正)
|
| 19 |
- NPU で分かった制約(実測)
|
|
@@ -339,6 +341,45 @@ Lunar Lake(NPU 4000、Ornith-1.5-9B NF4)で 1 トークンの生成を測る
|
|
| 339 |
- MTP を使うときは 1・16 トークン用のグラフを読み込みません(回答はすべて確認ブロックで書くため)。NPU ドライバーの
|
| 340 |
コマンドキューの枠(doorbell)を区間の数が超えないようにするためでもあります(下の「制約」)。
|
| 341 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
## 1 ステップのホスト側の処理(onw 1.4)
|
| 343 |
|
| 344 |
MTP の 1 ブロックのうち、NPU 以外(Python・numpy)が約 47 ms ありました。生成結果を 1 ビットも変えずに、次のことで約 32 ms に
|
|
|
|
| 14 |
- onw 0.7 でのメモリ削減
|
| 15 |
- モデルを小さくする(onw convert --compact / --prune)
|
| 16 |
- MTP 先読みと 64 トークン処理(onw 1.4、Lunar Lake)
|
| 17 |
+
- MoE のプロンプトをまとめて計算する(onw 2.0)
|
| 18 |
+
- dense モデルのプロンプトを 64 トークンずつ(onw 2.0)
|
| 19 |
- 1 ステップのホスト側の処理(onw 1.4)
|
| 20 |
- 複数トークンのブロックで、ロジットが行ごと 0 になる(onw 1.4 で修正)
|
| 21 |
- NPU で分かった制約(実測)
|
|
|
|
| 341 |
- MTP を使うときは 1・16 トークン用のグラフを読み込みません(回答はすべて確認ブロックで書くため)。NPU ドライバーの
|
| 342 |
コマンドキューの枠(doorbell)を区間の数が超えないようにするためでもあります(下の「制約」)。
|
| 343 |
|
| 344 |
+
## MoE のプロンプトをまとめて計算する(onw 2.0)
|
| 345 |
+
|
| 346 |
+
16 トークンのブロックでは、ブロックの全トークンが選んだエキスパート(Qwen3.6-35B-A3B で 1 ブロック 80〜110 個)の重みを
|
| 347 |
+
ブロックごとに読みます。1024 トークンなら同じエキスパートを何十回も読み直すことになり、NPU 3720 で 1024 トークンに 47 秒
|
| 348 |
+
かかっていました。AMD 版 onwx で効いた方法を、OpenVINO のグラフで組み直しました(`onw/grouped.py`)。
|
| 349 |
+
|
| 350 |
+
- **層ごとに進める**:64 トークンのミキサー区間(`seg*_S64m.xml`:アテンションまたは DeltaNet、MoE 前の正規化、ルーター、
|
| 351 |
+
共有エキスパートまで。ルーティングされるエキスパートは入れない)で、プロンプトの全ブロックを 1 層ぶん進めます。
|
| 352 |
+
- **エキスパートごとにまとめる**:ホストがルーターの上位 8 個でトークンを並べ替え、エキスパートごとに 16/32/64/128 行の
|
| 353 |
+
まとまりにして、エキスパートを 32 個(128 行は 16 個)並べた行列積のグラフ(重みは実行時の入力、ルーティングの重みも掛ける)
|
| 354 |
+
に渡します。各エキスパートの重みは、プロンプト全体で 1 回しか読みません。2 つのリクエストを交互に使い、NPU が 1 つを
|
| 355 |
+
計算している間に、ホストが次の分を詰めて前の分の結果を書き戻します。
|
| 356 |
+
- **ホスト側**:並べ替えは層ごとに 1 回だけで、各ディスパッチは連続したスライスのコピーにしました。書き戻しは別スレッド、
|
| 357 |
+
8 個の結果の足し合わせは並べ戻しと 1 回で行います(Panther Lake でホスト側 4.3 → 2.9 秒)。
|
| 358 |
+
- **グラフの置き場**:行列積のグラフは onw の blob 置き場(`onw_blobs/grp_*`)に保存し、ロード時に読み込みます。
|
| 359 |
+
|
| 360 |
+
| Qwen3.6-35B-A3B、1024 トークン | 16 トークンのブロック | onw 2.0 |
|
| 361 |
+
|---|---|---|
|
| 362 |
+
| NPU 3720(Core Ultra 9 285HX) | 47.5 秒(21.5 tok/s) | 約 10 秒(約 100 tok/s) |
|
| 363 |
+
| NPU 5010(Panther Lake) | 約 42 秒(24 tok/s) | 7.0 秒(約 147 tok/s) |
|
| 364 |
+
|
| 365 |
+
出力の差は 16 トークンのブロックに対して KL 0.0003〜0.0005(最初のトークンは一致)。メモリは、16 トークンのグラフを読まなく
|
| 366 |
+
なるぶん相殺され、システム全体のピークで +0.3 GB 以内です。ただしミキサー区間の初回コンパイルは重く、16 GB の PC では
|
| 367 |
+
メモリが足りなくなるので、使うのはメモリ 24 GB 以上の PC だけです(`ONW_GROUPED=1/0`)。
|
| 368 |
+
|
| 369 |
+
生成(1 トークン)では、区間どうしの入出力をロード時に一度だけ結び付けておき(`onw/wired.py`)、毎回の入力の受け渡しを
|
| 370 |
+
なくしました(Panther Lake 8.9 → 10.3 tok/s、出力は同じ)。
|
| 371 |
+
|
| 372 |
+
## dense モデルのプロンプトを 64 トークンずつ(onw 2.0)
|
| 373 |
+
|
| 374 |
+
dense モデルのプロンプトは 16 トークンのブロックで処理していました。NPU の 1 回の実行には、ブロックの大きさにあまりよらない固定の手間(区間の切り替え、ホスト側の KV キャッシュの処理、行列の並べ替え)があり、16 トークンではそれが大半でした。
|
| 375 |
+
|
| 376 |
+
- **測ったこと**(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。
|
| 377 |
+
- **やったこと**:64 トークンのグラフを加え、16 トークン用の代わりに読み込みます(1 トークン用+プロンプト用の 2 種類のまま)。NPU 3720 は重みを共有できないので、サイズを足すと重みの写しが増えますが、入れ替えなら増えません。Lunar Lake 以降も、サイズごとに NPU の作業領域(約 0.6 GB)を取るので入れ替えにしました。
|
| 378 |
+
- **DeltaNet の計算は 16 トークンのチャンクのまま**(`ir.deltanet_chunks`)。64 トークンを 1 チャンクにする式(減衰をチャンク内の累積とチャンクの合計の積で作り、fp16 の桁落ちを避ける)も試しました。NPU 3720 では状態の読み書きが減って DeltaNet が 1.5 倍速くなりましたが、Panther Lake のコンパイラーでは答えが大きくずれたので採用していません。
|
| 379 |
+
- **配布**:公開済みのモデルの重みファイル(`seg*.bin`)は変えていません。同じ変換器で作り直した 64 トークンのグラフの定数は、ほとんどが既存の `seg*.bin` の中に同じものがあり、そこを指します。足りない分(大きなブロックの形を表す整数、区間あたり数百バイト)だけを `seg*x.bin` に置き、読み込み時に入力として読んでから定数に置き換えます(`compact.graft_sizes`、`runtime._xbin_model`)。`engine.json` の `prompt_sizes` に書くので、2.0 より前の onw はこれらを読まず、今までどおり動きます。更新のダウンロードは数 MB です。
|
| 380 |
+
- **128 トークンは見送り**:Panther Lake では 139 tok/s とさらに速くなりますが、使用メモリが 16 トークンより約 1.1 GB 増え、初回のコンパイルが 9B で約 1 時間 40 分かかります(64 トークンで約 50 分)。NPU 3720 では 64 より遅くなります。
|
| 381 |
+
- **結果**(1024 トークン):Panther Lake の Qwen3.5-9B INT4 44 → 105 tok/s、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。
|
| 382 |
+
|
| 383 |
## 1 ステップのホスト側の処理(onw 1.4)
|
| 384 |
|
| 385 |
MTP の 1 ブロックのうち、NPU 以外(Python・numpy)が約 47 ms ありました。生成結果を 1 ビットも変えずに、次のことで約 32 ms に
|
TECHNICAL_en.md
CHANGED
|
@@ -14,6 +14,8 @@ test machine (Core Ultra 9 285HX, NPU 3720, Windows 11, OpenVINO 2026.5.0b1) unl
|
|
| 14 |
- Memory savings in onw 0.7
|
| 15 |
- Smaller models (onw convert --compact / --prune)
|
| 16 |
- MTP draft tokens and 64-token blocks (onw 1.4, Lunar Lake)
|
|
|
|
|
|
|
| 17 |
- Host-side work per step (onw 1.4)
|
| 18 |
- Logit rows of all zeros in multi-token blocks (fixed in onw 1.4)
|
| 19 |
- What we learned about the NPU (measured)
|
|
@@ -336,6 +338,61 @@ spare. Two things use it.
|
|
| 336 |
- With MTP the 1- and 16-token graphs are not loaded (every answer step is a checking block), which also keeps the number of
|
| 337 |
parts under the NPU driver's command-queue (doorbell) budget (see below).
|
| 338 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
## Host-side work per step (onw 1.4)
|
| 340 |
|
| 341 |
Of an MTP block, ~47 ms was Python / numpy. Without changing a single output bit, it is now ~32 ms (the same tokens as
|
|
|
|
| 14 |
- Memory savings in onw 0.7
|
| 15 |
- Smaller models (onw convert --compact / --prune)
|
| 16 |
- MTP draft tokens and 64-token blocks (onw 1.4, Lunar Lake)
|
| 17 |
+
- MoE prompts computed per expert (onw 2.0)
|
| 18 |
+
- Dense prompts in 64-token blocks (onw 2.0)
|
| 19 |
- Host-side work per step (onw 1.4)
|
| 20 |
- Logit rows of all zeros in multi-token blocks (fixed in onw 1.4)
|
| 21 |
- What we learned about the NPU (measured)
|
|
|
|
| 338 |
- With MTP the 1- and 16-token graphs are not loaded (every answer step is a checking block), which also keeps the number of
|
| 339 |
parts under the NPU driver's command-queue (doorbell) budget (see below).
|
| 340 |
|
| 341 |
+
## MoE prompts computed per expert (onw 2.0)
|
| 342 |
+
|
| 343 |
+
A 16-token block reads the weights of every expert its tokens picked (80-110 a block for Qwen3.6-35B-A3B), block after block:
|
| 344 |
+
a 1,024-token prompt reads the same experts dozens of times (47 s on the NPU 3720). What worked in onwx, the AMD sibling, is
|
| 345 |
+
rebuilt here with OpenVINO graphs (`onw/grouped.py`):
|
| 346 |
+
|
| 347 |
+
- **Layer by layer**: 64-token mixer segments (`seg*_S64m.xml`: attention or DeltaNet, the pre-MoE norm, the router and the
|
| 348 |
+
shared expert - no routed experts) take every block of the prompt through one layer.
|
| 349 |
+
- **Grouped by expert**: the host sorts the tokens by their router's top 8, packs them per expert into pieces of 16 / 32 / 64 /
|
| 350 |
+
128 rows, and runs GEMM graphs of 32 experts (16 for 128 rows; the weights are run-time inputs, the routing weights are
|
| 351 |
+
applied in-graph). Every expert's weights are read once for the whole prompt. Two requests alternate: while the NPU runs
|
| 352 |
+
one, the host fills the next and copies the previous one's results out.
|
| 353 |
+
- **Host side**: one sort per layer, contiguous slice copies per dispatch, the copy-out in a thread, the sum over the 8 experts
|
| 354 |
+
fused with the gather back to token order (Panther Lake: host time 4.3 -> 2.9 s).
|
| 355 |
+
- **Graph store**: the GEMM graphs are kept in onw's blob store (`onw_blobs/grp_*`) and imported at load time.
|
| 356 |
+
|
| 357 |
+
| Qwen3.6-35B-A3B, 1,024 tokens | 16-token blocks | onw 2.0 |
|
| 358 |
+
|---|---|---|
|
| 359 |
+
| NPU 3720 (Core Ultra 9 285HX) | 47.5 s (21.5 tok/s) | ~10 s (~100 tok/s) |
|
| 360 |
+
| NPU 5010 (Panther Lake) | ~42 s (24 tok/s) | 7.0 s (~147 tok/s) |
|
| 361 |
+
|
| 362 |
+
The output differs by KL 0.0003-0.0005 from the 16-token blocks (same first token). Memory: no 16-token graphs are loaded,
|
| 363 |
+
which offsets the mixers; the system-wide peak is within +0.3 GB. Compiling the mixers the first time is heavy, though, and
|
| 364 |
+
runs a 16 GB PC out of memory, so only PCs with 24 GB of RAM or more use it (`ONW_GROUPED=1 / 0`).
|
| 365 |
+
|
| 366 |
+
For single tokens the segments' inputs and outputs are bound to each other once at load time (`onw/wired.py`), with no input
|
| 367 |
+
hand-over per token (Panther Lake 8.9 -> 10.3 tok/s, same output).
|
| 368 |
+
|
| 369 |
+
## Dense prompts in 64-token blocks (onw 2.0)
|
| 370 |
+
|
| 371 |
+
Dense models read prompts in 16-token blocks. Every NPU run has a fixed cost that hardly depends on the block size
|
| 372 |
+
(switching segments, the host's KV-cache work, re-laying tensors), and at 16 tokens it was most of the time.
|
| 373 |
+
|
| 374 |
+
- **Measured** (one Qwen3.5-9B layer, random weights): on the NPU 3720 a DeltaNet layer costs 0.90 -> 0.41 ms per token
|
| 375 |
+
from 16- to 64-token blocks and still 0.41 at 128 (the group-quantized INT4 matmuls stop scaling at 64 rows); on
|
| 376 |
+
Panther Lake 0.46 -> 0.25 ms.
|
| 377 |
+
- **Done**: 64-token graphs, loaded in place of the 16-token ones (still two sizes: 1 token + the prompt size). The NPU
|
| 378 |
+
3720 cannot share weights between sizes, so an extra size would be an extra copy of the weights - a swap is not. From
|
| 379 |
+
Lunar Lake on each size still takes its own NPU scratch (~0.6 GB), so it is a swap there too.
|
| 380 |
+
- **DeltaNet stays in 16-token chunks** (`ir.deltanet_chunks`). A form with the whole 64-token block as one chunk (the
|
| 381 |
+
decays built as products of in-chunk cumulative gates and chunk totals, no fp16 cancellation) made DeltaNet 1.5x
|
| 382 |
+
faster on the NPU 3720 by reading and writing the state once a block, but the Panther Lake compiler computed it far
|
| 383 |
+
off, so it is not used.
|
| 384 |
+
- **Shipping**: the published weights files (`seg*.bin`) are unchanged. The 64-token graphs, converted again with
|
| 385 |
+
the same converter, find nearly all their constants as the same bytes in the existing `seg*.bin` and point there; the
|
| 386 |
+
few missing ones (integers for the larger blocks' shapes, a few hundred bytes a segment) are in `seg*x.bin`, read as
|
| 387 |
+
inputs and turned into constants at load (`compact.graft_sizes`, `runtime._xbin_model`). They are listed under
|
| 388 |
+
`prompt_sizes` in `engine.json`, which onw before 2.0 does not read, so it keeps working as before. An update
|
| 389 |
+
downloads a few MB.
|
| 390 |
+
- **No 128-token blocks**: on Panther Lake they reach 139 tok/s, but take ~1.1 GB more memory than the 16-token
|
| 391 |
+
blocks and ~1 h 40 min to compile for a 9B the first time (64 tokens: ~50 min); on the NPU 3720 they are slower than 64.
|
| 392 |
+
- **Result** (1,024 tokens): Panther Lake Qwen3.5-9B INT4 44 -> 105 tok/s; NPU 3720 Qwen3.5-9B 31-43 -> 80-87,
|
| 393 |
+
llm-jp-4.1-8B 46 -> 111, NeoHorse-1-4B 67 -> 132, Granite 4.2 3B 69 -> 139 tok/s. KL 0.00005-0.0015 against the
|
| 394 |
+
16-token blocks.
|
| 395 |
+
|
| 396 |
## Host-side work per step (onw 1.4)
|
| 397 |
|
| 398 |
Of an MTP block, ~47 ms was Python / numpy. Without changing a single output bit, it is now ~32 ms (the same tokens as
|
onw/__init__.py
CHANGED
|
@@ -2,4 +2,4 @@
|
|
| 2 |
|
| 3 |
A small engine that runs LLMs entirely on the Intel NPU: static segment graphs + host-managed state.
|
| 4 |
"""
|
| 5 |
-
__version__ = "
|
|
|
|
| 2 |
|
| 3 |
A small engine that runs LLMs entirely on the Intel NPU: static segment graphs + host-managed state.
|
| 4 |
"""
|
| 5 |
+
__version__ = "2.0.0"
|
onw/app.py
CHANGED
|
@@ -55,6 +55,14 @@ NF4_ONLY = {"ryugyosoft/Ornith-1.5-9B-nf4-onw", "ryugyosoft/Qwen3.5-9B-nf4-onw",
|
|
| 55 |
# (the NF4 models republished with the MTP layer: a copy without it is an old download - outdated(). Not Bonsai: it
|
| 56 |
# has no MTP layer, so a "no mtp" check made its card "update" forever and it could not be loaded, onw 1.4.9 - 1.4.10)
|
| 57 |
NF4_MTP = {"ryugyosoft/Ornith-1.5-9B-nf4-onw", "ryugyosoft/Qwen3.5-9B-nf4-onw"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
|
| 60 |
def catalog():
|
|
@@ -293,9 +301,9 @@ def raise_window():
|
|
| 293 |
|
| 294 |
|
| 295 |
def big_blocks_on(cfg, ram):
|
| 296 |
-
"""
|
| 297 |
-
|
| 298 |
-
return
|
| 299 |
|
| 300 |
|
| 301 |
def usable_ram(ram):
|
|
@@ -570,13 +578,18 @@ def outdated(repo, p):
|
|
| 570 |
fetches just the changed files): the gpt-oss-20b models published with an INT4 embedding table (v0.11.0 era) -
|
| 571 |
the INT8 one is noticeably more accurate; the NF4 Qwen3.5 models published before the MTP layer and the 64-token
|
| 572 |
blocks (onw 1.4.0: ~1.3x the answer and 2x the prompt speed on Lunar Lake)."""
|
| 573 |
-
gpt, nf4 = repo.startswith("ryugyosoft/gpt-oss-20b"), repo in NF4_MTP
|
| 574 |
-
|
|
|
|
| 575 |
return False
|
| 576 |
try:
|
| 577 |
meta = json.load(open(os.path.join(p, "engine.json")))
|
| 578 |
except (OSError, ValueError):
|
| 579 |
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
| 580 |
return "embed" in (meta.get("tables") or {}) if gpt else "mtp" not in meta
|
| 581 |
|
| 582 |
|
|
|
|
| 55 |
# (the NF4 models republished with the MTP layer: a copy without it is an old download - outdated(). Not Bonsai: it
|
| 56 |
# has no MTP layer, so a "no mtp" check made its card "update" forever and it could not be loaded, onw 1.4.9 - 1.4.10)
|
| 57 |
NF4_MTP = {"ryugyosoft/Ornith-1.5-9B-nf4-onw", "ryugyosoft/Qwen3.5-9B-nf4-onw"}
|
| 58 |
+
# [2.0] the Qwen3.6 MoE models republished with the mixer-only 64-token graphs (the grouped-MoE prompt path, ~4-5x the
|
| 59 |
+
# prompt speed): a copy without them (engine.json "mixer") is an old download
|
| 60 |
+
MOE_MIXER = {"ryugyosoft/Qwen3.6-35B-A3B-onw", "ryugyosoft/Qwen3.6-REAP-18B-A3B-onw"}
|
| 61 |
+
# [2.0] the dense models republished with larger prompt blocks (engine.json "prompt_sizes", compact.graft_sizes - the
|
| 62 |
+
# weights files unchanged, an update fetches a few MB): a copy without them is an old download
|
| 63 |
+
DENSE_PROMPT = {"ryugyosoft/Qwen3.5-9B-onw", "ryugyosoft/Ornith-1.5-9B-onw", "ryugyosoft/NeoHorse-1-4B-onw",
|
| 64 |
+
"ryugyosoft/granite-4.2-3b-onw", "ryugyosoft/granite-4.2-8b-onw",
|
| 65 |
+
"ryugyosoft/llm-jp-4.1-8b-thinking-onw", "ryugyosoft/llm-jp-4.1-8b-thinking-int8-onw"}
|
| 66 |
|
| 67 |
|
| 68 |
def catalog():
|
|
|
|
| 301 |
|
| 302 |
|
| 303 |
def big_blocks_on(cfg, ram):
|
| 304 |
+
"""A second copy of the weights for the 64-token blocks (memory estimate): only when forced on in the settings -
|
| 305 |
+
since 2.0 "auto" (chat.block_sizes) loads them in the 16-token graphs' place where the sizes cannot share weights."""
|
| 306 |
+
return cfg.get("big_blocks", "auto") == "on"
|
| 307 |
|
| 308 |
|
| 309 |
def usable_ram(ram):
|
|
|
|
| 578 |
fetches just the changed files): the gpt-oss-20b models published with an INT4 embedding table (v0.11.0 era) -
|
| 579 |
the INT8 one is noticeably more accurate; the NF4 Qwen3.5 models published before the MTP layer and the 64-token
|
| 580 |
blocks (onw 1.4.0: ~1.3x the answer and 2x the prompt speed on Lunar Lake)."""
|
| 581 |
+
gpt, nf4, mix = repo.startswith("ryugyosoft/gpt-oss-20b"), repo in NF4_MTP, repo in MOE_MIXER
|
| 582 |
+
dense = repo in DENSE_PROMPT
|
| 583 |
+
if not (gpt or nf4 or mix or dense) or not downloaded_by_onw(p):
|
| 584 |
return False
|
| 585 |
try:
|
| 586 |
meta = json.load(open(os.path.join(p, "engine.json")))
|
| 587 |
except (OSError, ValueError):
|
| 588 |
return False
|
| 589 |
+
if mix:
|
| 590 |
+
return "mixer" not in meta
|
| 591 |
+
if dense:
|
| 592 |
+
return "prompt_sizes" not in meta
|
| 593 |
return "embed" in (meta.get("tables") or {}) if gpt else "mtp" not in meta
|
| 594 |
|
| 595 |
|
onw/chat.py
CHANGED
|
@@ -7,7 +7,7 @@ import numpy as np
|
|
| 7 |
import openvino as ov
|
| 8 |
from transformers import AutoTokenizer
|
| 9 |
|
| 10 |
-
from .runtime import Cancelled, SegmentedModel
|
| 11 |
|
| 12 |
|
| 13 |
def npu_available(core):
|
|
@@ -50,33 +50,43 @@ def use_mtp(meta, shared):
|
|
| 50 |
return bool(meta.get("mtp")) and (env == "1" if env in ("0", "1") else shared)
|
| 51 |
|
| 52 |
|
| 53 |
-
def block_sizes(meta, shared=False, mtp=False):
|
| 54 |
-
"""Block sizes to load
|
| 55 |
-
weights shared between the sizes (NPUW)
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
| 58 |
only = os.environ.get("ONW_SIZES") # e.g. "16": decode with the 16-token graphs only
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
if only:
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
| 61 |
env = os.environ.get("ONW_S64")
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
if
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
return sizes
|
| 80 |
|
| 81 |
|
| 82 |
# Thinking levels ("low" / "medium" / "high", the API's reasoning_effort): harmony models (gpt-oss, llm-jp) were trained
|
|
@@ -103,12 +113,23 @@ class ChatEngine:
|
|
| 103 |
meta = json.load(open(os.path.join(engine_dir, "engine.json")))
|
| 104 |
shared = shares_weights(core, device, engine_dir, meta)
|
| 105 |
mtp = use_mtp(meta, shared)
|
| 106 |
-
|
|
|
|
|
|
|
| 107 |
from . import parts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
if shared and parts.estimate(meta, sizes) > parts.MANY: # (NPUW goes off for so many parts: no sharing)
|
| 109 |
shared = False
|
| 110 |
mtp = use_mtp(meta, shared)
|
| 111 |
-
sizes = block_sizes(meta, shared, mtp)
|
| 112 |
self.model = SegmentedModel(core, engine_dir, device, cfg, sizes=sizes, context=context, mtp=mtp)
|
| 113 |
self.tok = AutoTokenizer.from_pretrained(engine_dir)
|
| 114 |
# thinking: "switch" = the template takes enable_thinking (Qwen); "forced" = its generation prompt always opens
|
|
@@ -446,7 +467,8 @@ class ChatEngine:
|
|
| 446 |
"""Feed ids[start:stop] in blocks (with image rows / explicit positions when given); last logits."""
|
| 447 |
m, s, lg = self.model, start, None
|
| 448 |
stop = len(ids) if stop is None else stop
|
| 449 |
-
if m.streaming and stop - start > m.S:
|
|
|
|
| 450 |
# experts streamed from the SSD: go layer by layer so each layer's experts are read once per prompt
|
| 451 |
lg = m.prefill(ids[start:stop], None if embeds is None else embeds[start:stop],
|
| 452 |
None if pos is None else pos[:, start:stop], want_logits=need_logits, stop=self._cancel)
|
|
|
|
| 7 |
import openvino as ov
|
| 8 |
from transformers import AutoTokenizer
|
| 9 |
|
| 10 |
+
from .runtime import Cancelled, SegmentedModel, all_sizes, grouped_wanted
|
| 11 |
|
| 12 |
|
| 13 |
def npu_available(core):
|
|
|
|
| 50 |
return bool(meta.get("mtp")) and (env == "1" if env in ("0", "1") else shared)
|
| 51 |
|
| 52 |
|
| 53 |
+
def block_sizes(meta, shared=False, mtp=False, arch=None):
|
| 54 |
+
"""Block sizes to load: the 1-token graphs for answers and ONE prompt size, the largest the model has ([2.0]: in
|
| 55 |
+
the 16-token graphs' place - with the weights shared between the sizes (NPUW) every size still adds its NPU
|
| 56 |
+
scratch, without sharing a whole copy of the weights; Qwen3.5-9B INT4 64-token blocks: NPU 3720 31-43 -> 81-87
|
| 57 |
+
tok/s, Panther Lake 44 -> 104, +0.35-0.5 GB). With MTP drafts the smallest checking size (>= 4) instead of the
|
| 58 |
+
1-token graphs. ONW_S64=0: the 16-token graphs as before; 1: every size. arch: the NPU's (runtime.npu_arch) -
|
| 59 |
+
NPU 3720 takes blocks up to 64 tokens (128 are slower there: its INT4 matmuls stop scaling); ONW_PROMPT_MAX: a cap."""
|
| 60 |
+
sizes = all_sizes(meta)
|
| 61 |
only = os.environ.get("ONW_SIZES") # e.g. "16": decode with the 16-token graphs only
|
| 62 |
+
if not only and 1 in sizes and grouped_wanted(meta):
|
| 63 |
+
# [2.0] a MoE model with mixer graphs: every prompt goes the grouped-MoE path (onw/grouped.py), so only the
|
| 64 |
+
# 1-token graphs besides them (the NPU driver runs out of command lists with the 16-token ones as well)
|
| 65 |
+
return [1]
|
| 66 |
if only:
|
| 67 |
+
return [S for S in sizes if str(S) in only.split(",")] or sizes
|
| 68 |
+
cap = int(os.environ.get("ONW_PROMPT_MAX", "0")) or (64 if arch is not None and arch < 4000 else 0)
|
| 69 |
+
if cap:
|
| 70 |
+
sizes = [S for S in sizes if S <= cap]
|
| 71 |
env = os.environ.get("ONW_S64")
|
| 72 |
+
if env == "0":
|
| 73 |
+
sizes = [S for S in sizes if S <= 16]
|
| 74 |
+
small = [S for S in sizes if S <= 16]
|
| 75 |
+
big = [S for S in sizes if S > 16]
|
| 76 |
+
verify = [S for S in small if S >= 4]
|
| 77 |
+
if shared and mtp and verify:
|
| 78 |
+
# MTP drafts: every answer step is a verification block (x + 2 drafts, + rows to redo) on the smallest
|
| 79 |
+
# graph of >= 4 rows (Lunar Lake: the 4-token one ~15% cheaper than the 16-token one); no 1-token graphs
|
| 80 |
+
# (no single steps are left, and the NPU driver runs out of command queues past ~40 compiled parts:
|
| 81 |
+
# Ornith 9B has 13 per size + 3 for MTP)
|
| 82 |
+
keep = {min(verify)}
|
| 83 |
+
elif big and env != "1":
|
| 84 |
+
keep = {S for S in small if S == 1} # [2.0] the largest prompt size does the 16-token graphs' work
|
| 85 |
+
else:
|
| 86 |
+
# the 1- and 16-token graphs (4 / 8-token ones stay out: without NPUW each size holds its own copy of the
|
| 87 |
+
# weights)
|
| 88 |
+
keep = {S for S in small if S in (1, 16)} or set(small)
|
| 89 |
+
return [S for S in sizes if S in keep or (S > 16 and (env == "1" or S == max(big)))]
|
| 90 |
|
| 91 |
|
| 92 |
# Thinking levels ("low" / "medium" / "high", the API's reasoning_effort): harmony models (gpt-oss, llm-jp) were trained
|
|
|
|
| 113 |
meta = json.load(open(os.path.join(engine_dir, "engine.json")))
|
| 114 |
shared = shares_weights(core, device, engine_dir, meta)
|
| 115 |
mtp = use_mtp(meta, shared)
|
| 116 |
+
from .runtime import npu_arch
|
| 117 |
+
arch = npu_arch(core) if device == "NPU" else None
|
| 118 |
+
sizes = block_sizes(meta, shared, mtp, arch)
|
| 119 |
from . import parts
|
| 120 |
+
while shared and parts.estimate(meta, sizes) > parts.MANY and len([S for S in sizes if S > 1]) > 1:
|
| 121 |
+
# [2.0] rather fewer block sizes than no sharing (a second copy of the weights): the 16-token graphs go
|
| 122 |
+
# first when larger prompt blocks are there, then the smallest of those
|
| 123 |
+
big = [S for S in sizes if S > 16]
|
| 124 |
+
if 16 in sizes and big:
|
| 125 |
+
drop = 16
|
| 126 |
+
else:
|
| 127 |
+
drop = min(big or [S for S in sizes if S > 1])
|
| 128 |
+
sizes = [S for S in sizes if S != drop]
|
| 129 |
if shared and parts.estimate(meta, sizes) > parts.MANY: # (NPUW goes off for so many parts: no sharing)
|
| 130 |
shared = False
|
| 131 |
mtp = use_mtp(meta, shared)
|
| 132 |
+
sizes = block_sizes(meta, shared, mtp, arch)
|
| 133 |
self.model = SegmentedModel(core, engine_dir, device, cfg, sizes=sizes, context=context, mtp=mtp)
|
| 134 |
self.tok = AutoTokenizer.from_pretrained(engine_dir)
|
| 135 |
# thinking: "switch" = the template takes enable_thinking (Qwen); "forced" = its generation prompt always opens
|
|
|
|
| 467 |
"""Feed ids[start:stop] in blocks (with image rows / explicit positions when given); last logits."""
|
| 468 |
m, s, lg = self.model, start, None
|
| 469 |
stop = len(ids) if stop is None else stop
|
| 470 |
+
if (m.streaming and stop - start > m.S) or (getattr(m, "mix", None) is not None and stop - start >= 2):
|
| 471 |
+
# [2.0] a MoE model with mixer graphs: the grouped-MoE prompt path (onw/grouped.py) whenever it pays
|
| 472 |
# experts streamed from the SSD: go layer by layer so each layer's experts are read once per prompt
|
| 473 |
lg = m.prefill(ids[start:stop], None if embeds is None else embeds[start:stop],
|
| 474 |
None if pos is None else pos[:, start:stop], want_logits=need_logits, stop=self._cancel)
|
onw/compact.py
CHANGED
|
@@ -192,3 +192,54 @@ def compact(d):
|
|
| 192 |
int8_vision(d)
|
| 193 |
if mine:
|
| 194 |
common.finish(d)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
int8_vision(d)
|
| 193 |
if mine:
|
| 194 |
common.finish(d)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def graft_sizes(pub, new, sizes):
|
| 198 |
+
"""[2.0] Block sizes of the conversion `new` (same model, same converter) added to the published model folder `pub`
|
| 199 |
+
without touching its files: each new graph's constants point at the same bytes in pub's seg<i>.bin, the few it
|
| 200 |
+
lacks (shape constants of the larger blocks) go to seg<i>x.bin at offsets from len(seg<i>.bin) on (runtime.
|
| 201 |
+
_xbin_model); engine.json lists them under "prompt_sizes" (onw before 2.0 reads "sizes" only, so it never sees
|
| 202 |
+
graphs it could not load). An update then downloads a few MB instead of every weights file."""
|
| 203 |
+
meta = json.load(open(os.path.join(pub, "engine.json")))
|
| 204 |
+
nmeta = json.load(open(os.path.join(new, "engine.json")))
|
| 205 |
+
groups = {}
|
| 206 |
+
for name, m in nmeta["segments"].items():
|
| 207 |
+
if m["S"] in sizes:
|
| 208 |
+
groups.setdefault(name.split("_S")[0], []).append(name)
|
| 209 |
+
for seg, xmls in sorted(groups.items()):
|
| 210 |
+
binf = seg + ".bin"
|
| 211 |
+
old = open(os.path.join(pub, binf), "rb").read()
|
| 212 |
+
index = {} # the constants pub's own graphs of this segment store
|
| 213 |
+
for name, m in meta["segments"].items():
|
| 214 |
+
if name.split("_S")[0] == seg:
|
| 215 |
+
for mt in DATA_RE.finditer(open(os.path.join(pub, name), "rb").read()):
|
| 216 |
+
off, size = int(mt.group(1)), int(mt.group(2))
|
| 217 |
+
index.setdefault(hashlib.sha1(old[off:off + size]).digest() + size.to_bytes(8, "little"), off)
|
| 218 |
+
data = open(os.path.join(new, nmeta["segments"][xmls[0]].get("bin") or binf), "rb").read()
|
| 219 |
+
extra, xpos = [], {}
|
| 220 |
+
for x in xmls:
|
| 221 |
+
def remap(mt):
|
| 222 |
+
off, size = int(mt.group(1)), int(mt.group(2))
|
| 223 |
+
chunk = data[off:off + size]
|
| 224 |
+
h = hashlib.sha1(chunk).digest() + size.to_bytes(8, "little")
|
| 225 |
+
if h in index:
|
| 226 |
+
to = index[h]
|
| 227 |
+
else:
|
| 228 |
+
if h not in xpos:
|
| 229 |
+
xpos[h] = sum(len(c) for c in extra)
|
| 230 |
+
extra.append(chunk)
|
| 231 |
+
to = len(old) + xpos[h]
|
| 232 |
+
return mt.group(0).replace(b'offset="%d"' % off, b'offset="%d"' % to, 1)
|
| 233 |
+
text = DATA_RE.sub(remap, open(os.path.join(new, x), "rb").read())
|
| 234 |
+
with open(os.path.join(pub, x), "wb") as f:
|
| 235 |
+
f.write(text)
|
| 236 |
+
meta["segments"][x] = {**nmeta["segments"][x], "bin": binf}
|
| 237 |
+
if DATA_RE.search(text) and max(int(mt.group(1)) for mt in DATA_RE.finditer(text)) >= len(old):
|
| 238 |
+
meta["segments"][x].update(xbin=seg + "x.bin", xbase=len(old))
|
| 239 |
+
if extra:
|
| 240 |
+
with open(os.path.join(pub, seg + "x.bin"), "wb") as f:
|
| 241 |
+
for c in extra:
|
| 242 |
+
f.write(c)
|
| 243 |
+
print(f"{seg}: {len(xmls)} graphs, {sum(len(c) for c in extra)} bytes of new constants", flush=True)
|
| 244 |
+
meta["prompt_sizes"] = sorted(set(meta.get("prompt_sizes") or []) | set(sizes))
|
| 245 |
+
_save_meta(pub, meta)
|
onw/grouped.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""[2.0] Prompts of a mixture-of-experts model with a grouped expert GEMM (the idea of onwx, the AMD sibling, on the
|
| 2 |
+
Intel NPU): layer by layer over the whole prompt, the mixer-only graphs (mixer + post-norm + router + shared expert, no
|
| 3 |
+
routed experts: seg<i>_S<S>m.xml, models/qwen3_5_moe.add_mixer) run every block of the layer; then the host groups the
|
| 4 |
+
prompt's tokens by the expert the router picked, packs them into segments of R rows, and runs graphs of G expert slots
|
| 5 |
+
(each slot: its expert's INT4 weights bound as inputs, R gathered rows, the routing weights multiplied in) - each
|
| 6 |
+
expert's weights are read once for the whole prompt instead of once per block for every token of the block's union.
|
| 7 |
+
The outputs are added to the residual stream on the host before the next layer's mixer.
|
| 8 |
+
|
| 9 |
+
Measured (Qwen3.6-35B-A3B, NPU 3720): 1,024 tokens 47.5 s -> 10.2 s (S=64 mixers), KL 0.0003-0.0005 against the
|
| 10 |
+
16-token blocks."""
|
| 11 |
+
import os, time
|
| 12 |
+
import numpy as np
|
| 13 |
+
import openvino as ov
|
| 14 |
+
import openvino.opset13 as ops
|
| 15 |
+
from . import ir
|
| 16 |
+
|
| 17 |
+
SHAPES = [(int(g), int(r)) for g, r in (s.split("x") for s in os.environ.get("ONW_GROUPED_SHAPES",
|
| 18 |
+
"32x16,32x32,32x64,16x128").split(","))]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _i64(v):
|
| 22 |
+
return ops.constant(np.asarray(v, np.int64))
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def grouped_graph(G, R, H, I):
|
| 26 |
+
"""G expert slots (w_j / s_j: an expert's packed INT4 weights and scales) x R rows each: x [G, R, H] f16, rw [G, R, 1]
|
| 27 |
+
(the rows' routing weights) -> y [G, R, H] f16 = rw * expert_j(x_j)"""
|
| 28 |
+
X = ops.parameter([G, R, H], ov.Type.f16, name="x")
|
| 29 |
+
xf = ops.convert(X, ov.Type.f32)
|
| 30 |
+
params, ys = [X], []
|
| 31 |
+
for j in range(G):
|
| 32 |
+
slot = ir.expert_slot_params(j, H, I)
|
| 33 |
+
params += slot
|
| 34 |
+
xj = ops.reshape(ops.slice(xf, np.array([j]), np.array([j + 1]), np.array([1]), np.array([0])), _i64([R, H]), False)
|
| 35 |
+
ys.append(ops.unsqueeze(ir.expert_ffn(xj, slot, I, H), _i64([0])))
|
| 36 |
+
y = ops.concat(ys, 0) if G > 1 else ys[0]
|
| 37 |
+
rw = ops.parameter([G, R, 1], ov.Type.f16, name="rw")
|
| 38 |
+
params.append(rw)
|
| 39 |
+
y = ops.convert(ops.multiply(y, ops.convert(rw, ov.Type.f32)), ov.Type.f16)
|
| 40 |
+
r = ops.result(y)
|
| 41 |
+
r.output(0).get_tensor().set_names({"y"})
|
| 42 |
+
return ov.Model([r], params, f"grp_{G}x{R}")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _compiled(core, device, config, G, R, H, I, key):
|
| 46 |
+
"""The grouped graph compiled once into onw's own blob store (npu_cache/onw_blobs/grp_<G>x<R>-*.blob: OpenVINO's
|
| 47 |
+
CACHE_DIR files are pruned after every load on most NPUs, and a CACHE_DIR hit keeps a second copy in memory),
|
| 48 |
+
imported from there later (~0.1 s instead of a compile at every load)."""
|
| 49 |
+
import io, json
|
| 50 |
+
from . import runtime as rt
|
| 51 |
+
cache = (config or {}).get("CACHE_DIR")
|
| 52 |
+
nocache = {k: v for k, v in (config or {}).items() if k != "CACHE_DIR"}
|
| 53 |
+
if device != "NPU" or not cache or os.environ.get("ONW_OWN_BLOBS") == "0":
|
| 54 |
+
return core.compile_model(grouped_graph(G, R, H, I), device, config)
|
| 55 |
+
bp = rt.blob_path(cache, f"grp_{G}x{R}", ["grouped v1", G, R, H, I, key,
|
| 56 |
+
json.dumps(nocache, sort_keys=True, default=str)])
|
| 57 |
+
if os.path.exists(bp):
|
| 58 |
+
try:
|
| 59 |
+
c = rt.import_blob(core, bp, device, nocache)
|
| 60 |
+
rt.USED_BLOBS.add(bp)
|
| 61 |
+
return c
|
| 62 |
+
except Exception as e: # (a broken file: compiled again below)
|
| 63 |
+
print(f"[grouped] {os.path.basename(bp)}: {e}; compiling it again", flush=True)
|
| 64 |
+
cm = core.compile_model(grouped_graph(G, R, H, I), device, nocache)
|
| 65 |
+
os.makedirs(os.path.dirname(bp), exist_ok=True)
|
| 66 |
+
buf = io.BytesIO()
|
| 67 |
+
cm.export_model(buf)
|
| 68 |
+
with open(f"{bp}.{os.getpid()}.tmp", "wb") as f:
|
| 69 |
+
f.write(buf.getbuffer())
|
| 70 |
+
del buf, cm
|
| 71 |
+
os.replace(f"{bp}.{os.getpid()}.tmp", bp)
|
| 72 |
+
rt.USED_BLOBS.add(bp)
|
| 73 |
+
return rt.import_blob(core, bp, device, nocache)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class GroupedMoE:
|
| 77 |
+
"""The grouped expert GEMMs: per shape (G slots, R rows) two requests used in turn (the host gathers and binds the
|
| 78 |
+
next dispatch and scatters the previous one while the NPU runs)."""
|
| 79 |
+
def __init__(self, core, device, config, bank, H, I, streaming, key=""):
|
| 80 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 81 |
+
self.bank, self.H, self.streaming = bank, H, streaming
|
| 82 |
+
self.reqs = {}
|
| 83 |
+
for G, R in SHAPES:
|
| 84 |
+
c = _compiled(core, device, config, G, R, H, I, key)
|
| 85 |
+
self.reqs[R] = (G, [(c.create_infer_request(), np.zeros((G * R, H), np.float16), np.zeros((G * R, 1), np.float16))
|
| 86 |
+
for _ in range(2)])
|
| 87 |
+
self.Rs = sorted(self.reqs)
|
| 88 |
+
self.nt = min(8, max(2, (os.cpu_count() or 4) // 2))
|
| 89 |
+
self.pool = ThreadPoolExecutor(self.nt)
|
| 90 |
+
self.copier = ThreadPoolExecutor(1) # (a finished dispatch's outputs, copied out off the main thread)
|
| 91 |
+
self.stats = {"dispatch": 0, "npu": 0.0, "host": 0.0, "prep": 0.0, "gather": 0.0, "bind": 0.0, "scatter": 0.0,
|
| 92 |
+
"sum": 0.0}
|
| 93 |
+
|
| 94 |
+
def _take(self, src, si):
|
| 95 |
+
"""src[si] in threads (numpy's fancy indexing runs without the GIL: ~2x on the row gathers)"""
|
| 96 |
+
out = np.empty((len(si),) + src.shape[1:], src.dtype)
|
| 97 |
+
n = self.nt
|
| 98 |
+
q = (len(si) + n - 1) // n
|
| 99 |
+
list(self.pool.map(lambda c: np.take(src, si[c * q:(c + 1) * q], 0, out=out[c * q:(c + 1) * q]), range(n)))
|
| 100 |
+
return out
|
| 101 |
+
|
| 102 |
+
def _ksum(self, Zs, at, N):
|
| 103 |
+
"""y [N, H] f32: row i = sum over k of Zs[at[i, k]] (the gather back to token order and the sum in one pass)"""
|
| 104 |
+
y = np.empty((N, Zs.shape[1]), np.float32)
|
| 105 |
+
q = (N + self.nt - 1) // self.nt
|
| 106 |
+
|
| 107 |
+
def part(c):
|
| 108 |
+
s = slice(c * q, (c + 1) * q)
|
| 109 |
+
a = y[s]
|
| 110 |
+
a[:] = Zs[at[s, 0]]
|
| 111 |
+
for k in range(1, at.shape[1]):
|
| 112 |
+
a += Zs[at[s, k]]
|
| 113 |
+
list(self.pool.map(part, range(self.nt)))
|
| 114 |
+
return y
|
| 115 |
+
|
| 116 |
+
def run(self, layer, xn, idx, w, prefetch=None):
|
| 117 |
+
"""xn [N, H] (the post-norm rows), idx / w [N, K] (the router's experts / weights) -> the routed MoE [N, H] f32.
|
| 118 |
+
prefetch(): called once this layer's experts are bound (streaming: start reading the next layer's)."""
|
| 119 |
+
t0 = time.perf_counter()
|
| 120 |
+
H = self.H
|
| 121 |
+
N, K = idx.shape
|
| 122 |
+
flat = idx.reshape(-1).astype(np.int64)
|
| 123 |
+
order = np.argsort(flat, kind="stable") # (token, k) pairs sorted by expert
|
| 124 |
+
tok = order // K
|
| 125 |
+
wt = w.reshape(-1)[order].astype(np.float16)[:, None]
|
| 126 |
+
ex = flat[order]
|
| 127 |
+
bounds = np.flatnonzero(np.diff(ex)) + 1
|
| 128 |
+
starts, ends = np.concatenate([[0], bounds]), np.concatenate([bounds, [len(ex)]])
|
| 129 |
+
Rmax = self.Rs[-1]
|
| 130 |
+
pieces = {R: [] for R in self.Rs} # R -> [(expert, start, end)]
|
| 131 |
+
for s, e in zip(starts, ends):
|
| 132 |
+
for a in range(s, e, Rmax):
|
| 133 |
+
b = min(a + Rmax, e)
|
| 134 |
+
pieces[next(r for r in self.Rs if r >= b - a)].append((int(ex[s]), a, b))
|
| 135 |
+
uniq = sorted({int(x) for x in ex[starts]})
|
| 136 |
+
keys = [(layer, e) for e in uniq]
|
| 137 |
+
pairs = dict(zip(uniq, self.bank.get_many(keys) if self.streaming else [self.bank[k] for k in keys]))
|
| 138 |
+
if prefetch is not None:
|
| 139 |
+
prefetch()
|
| 140 |
+
# the rows in expert order once (one big gather): a dispatch then copies contiguous slices in and out
|
| 141 |
+
xsort = self._take(np.ascontiguousarray(xn, np.float16), tok)
|
| 142 |
+
Zs = np.empty((N * K, H), np.float16) # the weighted expert outputs, in expert order
|
| 143 |
+
jobs = [(R, self.reqs[R][0], lst[d0:d0 + self.reqs[R][0]]) for R, lst in pieces.items()
|
| 144 |
+
for d0 in range(0, len(lst), self.reqs[R][0])]
|
| 145 |
+
pend, k, tn = None, 0, 0.0
|
| 146 |
+
st, pc = self.stats, time.perf_counter
|
| 147 |
+
st["prep"] += pc() - t0
|
| 148 |
+
|
| 149 |
+
copies = {} # request -> its outputs' copy-out (done before it runs again)
|
| 150 |
+
|
| 151 |
+
def out(Y, R, grp):
|
| 152 |
+
for j, (_, a, b) in enumerate(grp):
|
| 153 |
+
Zs[a:b] = Y[j * R:j * R + b - a]
|
| 154 |
+
|
| 155 |
+
def finish(p):
|
| 156 |
+
nonlocal tn
|
| 157 |
+
req, R, grp = p
|
| 158 |
+
t = pc()
|
| 159 |
+
req.wait()
|
| 160 |
+
t1 = pc()
|
| 161 |
+
tn += t1 - t
|
| 162 |
+
copies[id(req)] = self.copier.submit(out, req.get_output_tensor(0).data.reshape(-1, H), R, grp)
|
| 163 |
+
st["scatter"] += pc() - t1
|
| 164 |
+
for R, G, grp in jobs:
|
| 165 |
+
t = pc()
|
| 166 |
+
two = self.reqs[R][1]
|
| 167 |
+
req, X, RW = two[k % 2] if pend is None or pend[0] is not two[k % 2][0] else two[(k + 1) % 2]
|
| 168 |
+
k += 1
|
| 169 |
+
if id(req) in copies:
|
| 170 |
+
copies.pop(id(req)).result()
|
| 171 |
+
for j, (_, a, b) in enumerate(grp): # (padding rows keep old data: their outputs are not read)
|
| 172 |
+
X[j * R:j * R + b - a] = xsort[a:b]
|
| 173 |
+
RW[j * R:j * R + b - a] = wt[a:b]
|
| 174 |
+
t1 = pc()
|
| 175 |
+
st["gather"] += t1 - t
|
| 176 |
+
for j in range(G):
|
| 177 |
+
w_t, s_t = pairs[grp[j][0] if j < len(grp) else grp[0][0]]
|
| 178 |
+
req.set_tensor(f"w_{j}", w_t)
|
| 179 |
+
req.set_tensor(f"s_{j}", s_t)
|
| 180 |
+
req.start_async({"x": X.reshape(G, R, H), "rw": RW.reshape(G, R, 1)}, share_inputs=True)
|
| 181 |
+
st["bind"] += pc() - t1
|
| 182 |
+
if pend is not None:
|
| 183 |
+
finish(pend)
|
| 184 |
+
pend = (req, R, grp)
|
| 185 |
+
self.stats["dispatch"] += 1
|
| 186 |
+
if pend is not None:
|
| 187 |
+
finish(pend)
|
| 188 |
+
for f in copies.values():
|
| 189 |
+
f.result()
|
| 190 |
+
t = pc()
|
| 191 |
+
y = self._ksum(Zs, np.argsort(order).reshape(N, K), N) # (pair (token i, k) sits at Zs row inv[i * K + k])
|
| 192 |
+
st["sum"] += pc() - t
|
| 193 |
+
self.stats["npu"] += tn
|
| 194 |
+
self.stats["host"] += time.perf_counter() - t0 - tn
|
| 195 |
+
return y
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def make(m):
|
| 199 |
+
"""runtime.SegmentedModel m's GroupedMoE (built at load time, after the mixer graphs)"""
|
| 200 |
+
tried = m._ctx.get("tried")
|
| 201 |
+
return GroupedMoE(m._ctx["core"], m._ctx["device"], m._ctx["config"], m.bank, m.cfg["hidden_size"],
|
| 202 |
+
m.cfg["moe_intermediate_size"], m.streaming, getattr(tried, "key", ""))
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def prefill(m, ids, embeds=None, pos=None, want_logits=True, stop=None):
|
| 206 |
+
"""runtime.SegmentedModel m: the prompt ids through the mixer graphs (m.mix) layer by layer with the grouped MoE;
|
| 207 |
+
the last block's logits (want_logits) or None. stop(): checked between layers (Cancelled)."""
|
| 208 |
+
from .runtime import Cancelled
|
| 209 |
+
S, segs = m.mix
|
| 210 |
+
if m.grouped is None:
|
| 211 |
+
m.grouped = make(m)
|
| 212 |
+
blocks, s = [], 0
|
| 213 |
+
while s < len(ids):
|
| 214 |
+
k = min(S, len(ids) - s)
|
| 215 |
+
start = m.cur
|
| 216 |
+
_, n, x, common = m._block_inputs(ids[s:s + k], None if embeds is None else embeds[s:s + k],
|
| 217 |
+
None if pos is None else pos[:, s:s + k], S=S)
|
| 218 |
+
blocks.append({"n": n, "cur": start, "common": common, "carry": {"inputs_embeds": x}})
|
| 219 |
+
m.cur += n
|
| 220 |
+
s += k
|
| 221 |
+
m._last = None # (truncate() only follows step())
|
| 222 |
+
logits = None
|
| 223 |
+
for i, (req, meta, names) in enumerate(segs):
|
| 224 |
+
if stop is not None and stop():
|
| 225 |
+
raise Cancelled()
|
| 226 |
+
xns = []
|
| 227 |
+
for bi, b in enumerate(blocks):
|
| 228 |
+
if meta.get("head") and not (want_logits and bi == len(blocks) - 1):
|
| 229 |
+
continue
|
| 230 |
+
new, route, lg = m._run_segment(req, meta, names, b["carry"], b["common"], None, S, b["n"], b["cur"], keep=True)
|
| 231 |
+
logits = lg if lg is not None else logits
|
| 232 |
+
b["carry"] = {k: v for k, v in {**b["carry"], **new}.items() if k in m._mix_needed[i] or k == "carry.h"}
|
| 233 |
+
if "carry.xn" in new and route is not None:
|
| 234 |
+
xns.append((new["carry.xn"][0, :b["n"]], route))
|
| 235 |
+
if xns: # this part ended with layer L's router: its routed MoE now
|
| 236 |
+
L = meta["router_layer"]
|
| 237 |
+
xn = np.concatenate([x for x, _ in xns])
|
| 238 |
+
idx = np.concatenate([r[0][:len(x)] for x, r in xns])
|
| 239 |
+
w = np.concatenate([r[1][:len(x)] for x, r in xns])
|
| 240 |
+
pf = None
|
| 241 |
+
if m.streaming and L + 1 < m.cfg["num_hidden_layers"]: # (every expert of the next layer: a long prompt
|
| 242 |
+
pf = lambda: m.bank.prefetch([(L + 1, e) for e in range(m.E)], protect_layer=L) # uses nearly all)
|
| 243 |
+
y = m.grouped.run(L, xn, idx, w, pf)
|
| 244 |
+
o = 0
|
| 245 |
+
for b in blocks:
|
| 246 |
+
h = b["carry"]["carry.h"]
|
| 247 |
+
h[0, :b["n"]] = (h[0, :b["n"]].astype(np.float32) + y[o:o + b["n"]]).astype(h.dtype)
|
| 248 |
+
o += b["n"]
|
| 249 |
+
return logits
|
onw/i18n.json
CHANGED
|
@@ -39,7 +39,7 @@
|
|
| 39 |
"URL をコピーしました": "URL copied",
|
| 40 |
"auto(Lunar Lake 以降でオン)": "auto (on for Lunar Lake+)",
|
| 41 |
"auto(OS に合わせる)": "auto (follow the OS)",
|
| 42 |
-
"auto(
|
| 43 |
"auto(足りないときだけ SSD から)": "auto (SSD when RAM is short)",
|
| 44 |
"gpt-oss-20b の高精度版": "The higher-precision gpt-oss-20b",
|
| 45 |
"gpt-oss-20b の高精度版(エキスパート INT8。メモリ 24 GB 以上向け)": "The higher-precision gpt-oss-20b (INT8 experts; for 24 GB+ of RAM)",
|
|
@@ -397,7 +397,7 @@
|
|
| 397 |
"URL をコピーしました": "已复制 URL",
|
| 398 |
"auto(Lunar Lake 以降でオン)": "auto(Lunar Lake 起开启)",
|
| 399 |
"auto(OS に合わせる)": "auto(跟随系统)",
|
| 400 |
-
"auto(
|
| 401 |
"auto(足りないときだけ SSD から)": "auto(内存不足时用 SSD)",
|
| 402 |
"gpt-oss-20b の高精度版": "gpt-oss-20b 的高精度版",
|
| 403 |
"gpt-oss-20b の高精度版(エキスパート INT8。メモリ 24 GB 以上向け)": "gpt-oss-20b 的高精度版(INT8 专家;面向 24 GB 以上内存)",
|
|
|
|
| 39 |
"URL をコピーしました": "URL copied",
|
| 40 |
"auto(Lunar Lake 以降でオン)": "auto (on for Lunar Lake+)",
|
| 41 |
"auto(OS に合わせる)": "auto (follow the OS)",
|
| 42 |
+
"auto(オン。NPU 3720 などでは 16 トークン処理と入れ替え)": "auto (on; on NPU 3720 and the like in place of the 16-token blocks)",
|
| 43 |
"auto(足りないときだけ SSD から)": "auto (SSD when RAM is short)",
|
| 44 |
"gpt-oss-20b の高精度版": "The higher-precision gpt-oss-20b",
|
| 45 |
"gpt-oss-20b の高精度版(エキスパート INT8。メモリ 24 GB 以上向け)": "The higher-precision gpt-oss-20b (INT8 experts; for 24 GB+ of RAM)",
|
|
|
|
| 397 |
"URL をコピーしました": "已复制 URL",
|
| 398 |
"auto(Lunar Lake 以降でオン)": "auto(Lunar Lake 起开启)",
|
| 399 |
"auto(OS に合わせる)": "auto(跟随系统)",
|
| 400 |
+
"auto(オン。NPU 3720 などでは 16 トークン処理と入れ替え)": "auto(开启;在 NPU 3720 等机型上替换 16 token 分块)",
|
| 401 |
"auto(足りないときだけ SSD から)": "auto(内存不足时用 SSD)",
|
| 402 |
"gpt-oss-20b の高精度版": "gpt-oss-20b 的高精度版",
|
| 403 |
"gpt-oss-20b の高精度版(エキスパート INT8。メモリ 24 GB 以上向け)": "gpt-oss-20b 的高精度版(INT8 专家;面向 24 GB 以上内存)",
|
onw/models/qwen3_5_moe.py
CHANGED
|
@@ -48,7 +48,7 @@ class _MTPWeights:
|
|
| 48 |
|
| 49 |
|
| 50 |
def build_segments(cfg, W, S, T, fmt="q4g128", dn_fmt=None, norm_maxscale=False, P=P, attn_fmt=None, mtp=False,
|
| 51 |
-
draft_vocab=None):
|
| 52 |
"""dn_fmt: format of the DeltaNet in_proj_qkv / out_proj weights (default fmt); attn_fmt: the full-attention
|
| 53 |
q/k/v_proj (default fmt; NF4 models keep them INT8); norm_maxscale: residual-stream
|
| 54 |
RMSNorms with ir.rmsnorm(maxscale=True); P: checkpoint key prefix (text-only "qwen3_5_text" repos use "model.").
|
|
@@ -223,12 +223,15 @@ def build_segments(cfg, W, S, T, fmt="q4g128", dn_fmt=None, norm_maxscale=False,
|
|
| 223 |
result.append((ir.finish(outs, params, f"q35_seg{si}_S{S}"), meta))
|
| 224 |
result.append(head_segment(len(result)))
|
| 225 |
return result
|
| 226 |
-
for si in range(L + 1):
|
|
|
|
|
|
|
| 227 |
params, outs, shared = [], {}, {}
|
| 228 |
-
consume = si - 1 if si > 0 else None
|
| 229 |
-
meta = {"index": si, "consume_moe": consume, "router_layer": si if si < L else None, "S": S,
|
|
|
|
| 230 |
if consume is None:
|
| 231 |
-
h = ops.parameter([1, S, H], ov.Type.f32, name="inputs_embeds")
|
| 232 |
params.append(h)
|
| 233 |
else:
|
| 234 |
hin = ops.parameter([1, S, H], ov.Type.f32, name="carry.h")
|
|
@@ -325,6 +328,40 @@ def export(src, out, sizes=(1, 16), T=1024, fmt="q4g128", graphs_only=False, dn_
|
|
| 325 |
common.write_engine(out, meta, graphs_only) # (with graphs_only the vision entry is kept)
|
| 326 |
|
| 327 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
def export_mtp(cfg, W, out, T, fmt, norm_maxscale, pre, S=16):
|
| 329 |
"""The MTP layer for draft tokens (dense models): mtp_S16.xml (one 16-token graph does every MTP call - its cost
|
| 330 |
hardly depends on the rows) and mtp_head_S1.xml (an INT8 head over the 32K most frequent tokens, mtp_vocab.npy,
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
def build_segments(cfg, W, S, T, fmt="q4g128", dn_fmt=None, norm_maxscale=False, P=P, attn_fmt=None, mtp=False,
|
| 51 |
+
draft_vocab=None, mixer_only=False):
|
| 52 |
"""dn_fmt: format of the DeltaNet in_proj_qkv / out_proj weights (default fmt); attn_fmt: the full-attention
|
| 53 |
q/k/v_proj (default fmt; NF4 models keep them INT8); norm_maxscale: residual-stream
|
| 54 |
RMSNorms with ir.rmsnorm(maxscale=True); P: checkpoint key prefix (text-only "qwen3_5_text" repos use "model.").
|
|
|
|
| 223 |
result.append((ir.finish(outs, params, f"q35_seg{si}_S{S}"), meta))
|
| 224 |
result.append(head_segment(len(result)))
|
| 225 |
return result
|
| 226 |
+
for si in range(L if mixer_only else L + 1):
|
| 227 |
+
# mixer_only (grouped-MoE prompt path): no routed experts in the graphs - segment si takes h + the routed MoE
|
| 228 |
+
# of layer si-1 as carry.h (added on the host from the grouped expert GEMM)
|
| 229 |
params, outs, shared = [], {}, {}
|
| 230 |
+
consume = si - 1 if si > 0 and not mixer_only else None
|
| 231 |
+
meta = {"index": si, "consume_moe": consume, "router_layer": si if si < L else None, "S": S,
|
| 232 |
+
"slots": 0 if mixer_only else slots}
|
| 233 |
if consume is None:
|
| 234 |
+
h = ops.parameter([1, S, H], ov.Type.f32, name="inputs_embeds" if si == 0 else "carry.h")
|
| 235 |
params.append(h)
|
| 236 |
else:
|
| 237 |
hin = ops.parameter([1, S, H], ov.Type.f32, name="carry.h")
|
|
|
|
| 328 |
common.write_engine(out, meta, graphs_only) # (with graphs_only the vision entry is kept)
|
| 329 |
|
| 330 |
|
| 331 |
+
def add_mixer(src, out, S=64, keep=None):
|
| 332 |
+
"""[2.0] The grouped-MoE prompt path's graphs (onw/grouped.py) added to a converted MoE model folder: mixer-only
|
| 333 |
+
S-token segments seg<i>_S<S>m.xml (the mixer, the post-attention norm, the router and the shared expert - no routed
|
| 334 |
+
experts: those run on the host-gathered rows), engine.json "mixer": {"S": S}; compact's dedup then stores their
|
| 335 |
+
constants in seg<i>.bin with the other block sizes' (the same weights). src: the checkpoint (the experts' tensors
|
| 336 |
+
are not read)."""
|
| 337 |
+
from ..prune import maybe_prune
|
| 338 |
+
from .. import compact
|
| 339 |
+
meta = json.load(open(os.path.join(out, "engine.json")))
|
| 340 |
+
if meta.get("needs") == "nf4":
|
| 341 |
+
raise ValueError("add_mixer: NF4 models are not supported yet")
|
| 342 |
+
if keep is not None: # (a pruned model's kept experts: {layer: [ids]} - its router rows)
|
| 343 |
+
from ..prune import PrunedCheckpoint
|
| 344 |
+
keep = {int(k): v for k, v in keep.items()}
|
| 345 |
+
W, cfg = PrunedCheckpoint(Checkpoint(src), keep), {**text_config(src), "num_experts": len(next(iter(keep.values())))}
|
| 346 |
+
else:
|
| 347 |
+
common.restore_prune(out, True)
|
| 348 |
+
W, cfg = maybe_prune(Checkpoint(src), text_config(src))
|
| 349 |
+
pre = prefix(W)
|
| 350 |
+
segs = {}
|
| 351 |
+
for m, sm in build_segments(cfg, W, S, meta["T"], "q4g128", meta.get("deltanet_fmt"), meta.get("norm_maxscale", False),
|
| 352 |
+
pre, meta.get("attn_qkv_fmt"), mixer_only=True):
|
| 353 |
+
name = f"seg{sm['index']:02d}_S{S}m.xml"
|
| 354 |
+
ov.save_model(m, os.path.join(out, name))
|
| 355 |
+
segs[name] = {**sm, "mixer_only": True}
|
| 356 |
+
print(name, flush=True)
|
| 357 |
+
meta["segments"].update(segs)
|
| 358 |
+
meta["mixer"] = {"S": S}
|
| 359 |
+
with open(os.path.join(out, "engine.json.tmp"), "w") as f:
|
| 360 |
+
json.dump(meta, f, indent=1)
|
| 361 |
+
os.replace(os.path.join(out, "engine.json.tmp"), os.path.join(out, "engine.json"))
|
| 362 |
+
compact.dedup_segments(out)
|
| 363 |
+
|
| 364 |
+
|
| 365 |
def export_mtp(cfg, W, out, T, fmt, norm_maxscale, pre, S=16):
|
| 366 |
"""The MTP layer for draft tokens (dense models): mtp_S16.xml (one 16-token graph does every MTP call - its cost
|
| 367 |
hardly depends on the rows) and mtp_head_S1.xml (an INT8 head over the 32K most frequent tokens, mtp_vocab.npy,
|
onw/runtime.py
CHANGED
|
@@ -15,7 +15,7 @@ State handled here, by input/output naming convention:
|
|
| 15 |
runtime input makes the NPU move the whole table every call)
|
| 16 |
Every state tensor lives in the input buffers of one request and is shared by all block-size variants.
|
| 17 |
"""
|
| 18 |
-
import json, os, time
|
| 19 |
import numpy as np
|
| 20 |
import openvino as ov
|
| 21 |
|
|
@@ -275,10 +275,58 @@ def codes_const_scale(model):
|
|
| 275 |
return n
|
| 276 |
|
| 277 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
def read_segment(core, d, name, m, npuw, bins, T, built_T):
|
| 279 |
"""The segment's ov.Model, reshaped to context T. With NPUW every segment of a .bin reads against the same
|
| 280 |
in-memory copy (bins cache), so the weights bank sees one set of constants."""
|
| 281 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
b = m.get("bin") or name[:-4] + ".bin"
|
| 283 |
if b not in bins:
|
| 284 |
# a file-backed mapping (OpenVINO shares it, see mapped_bin): NPUW's weights bank keeps reading the
|
|
@@ -295,6 +343,23 @@ def read_segment(core, d, name, m, npuw, bins, T, built_T):
|
|
| 295 |
return model
|
| 296 |
|
| 297 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
CRASHED = "crashed"
|
| 299 |
TRANSIENT = "transient: " # a failure worth trying again next time (out of memory, disk full, interrupted)
|
| 300 |
|
|
@@ -916,7 +981,7 @@ class SegmentedModel:
|
|
| 916 |
self.sinks = {l: np.asarray(v, np.float32) for l, v in self.meta.get("attn_sinks", {}).items()}
|
| 917 |
self.mrope = rope.get("mrope_section") if rope.get("mrope_interleaved") else None
|
| 918 |
self.delta = 0 # MRoPE: position = slot + delta after an image (set by the caller)
|
| 919 |
-
sizes = sizes or self.meta
|
| 920 |
config = enable_turbo(core, device, config) # (see turbo(): on unless ONW_TURBO=0)
|
| 921 |
t0 = time.time()
|
| 922 |
if os.path.exists(os.path.join(d, "experts.bin")):
|
|
@@ -1002,7 +1067,7 @@ class SegmentedModel:
|
|
| 1002 |
# The largest block the model HAS, not the ones loaded: the ring is part of every graph's shape, so with the
|
| 1003 |
# 64-token blocks switched off (Gemma below 24 GB) the 1- and 16-token graphs would otherwise be other graphs -
|
| 1004 |
# compiled again at every switch, the old blobs pruned (48 ring slots more cost next to nothing)
|
| 1005 |
-
self.ring = self.window + max(self.meta.get("sizes")
|
| 1006 |
kv_dims, dropped = {}, set()
|
| 1007 |
self._rope = {} # S -> (CPU request: position_ids -> angles, Cos / Sin per angle)
|
| 1008 |
self._ctx = dict(core=core, d=d, device=device, config=config or {}, npuw=npuw, tried=tried, bins=bins,
|
|
@@ -1081,6 +1146,30 @@ class SegmentedModel:
|
|
| 1081 |
for p_ in NPUW_SEEN:
|
| 1082 |
rmtree(p_)
|
| 1083 |
NPUW_SEEN.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1084 |
if only:
|
| 1085 |
tried.save()
|
| 1086 |
raise Precompiled()
|
|
@@ -1153,6 +1242,18 @@ class SegmentedModel:
|
|
| 1153 |
self._needed = {}
|
| 1154 |
for S, lst in self.segs.items():
|
| 1155 |
self._bind(S, lst)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1156 |
self.exact_truncate = not any(k.startswith("ssm.") for k in self.state)
|
| 1157 |
# DeltaNet states in two buffers each: a segment reads the current one and writes its new state straight into
|
| 1158 |
# the other (set_tensor, no copy), then the two swap roles; undoing a verification block swaps back instead of
|
|
@@ -1169,6 +1270,14 @@ class SegmentedModel:
|
|
| 1169 |
self.S = max(self.segs)
|
| 1170 |
import threading
|
| 1171 |
self._mutex = threading.RLock() # a block size is swapped in only between two blocks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1172 |
self._lazy, self._loading = {S: True for S in deferred}, {}
|
| 1173 |
# attention sinks per paged layer, in the [kv, G] layout of the paged cache
|
| 1174 |
self._sink = {}
|
|
@@ -2030,6 +2139,9 @@ class SegmentedModel:
|
|
| 2030 |
|
| 2031 |
def prefill(self, ids, embeds=None, pos=None, want_logits=True, stop=None):
|
| 2032 |
with self._mutex:
|
|
|
|
|
|
|
|
|
|
| 2033 |
return self._prefill_layers(ids, embeds, pos, want_logits, stop)
|
| 2034 |
|
| 2035 |
def _step(self, ids, embeds=None, pos=None, want_logits=True, size=None):
|
|
@@ -2038,7 +2150,11 @@ class SegmentedModel:
|
|
| 2038 |
MRoPE positions (default: slot + delta on all rows). want_logits=False skips the LM head segment (prompt
|
| 2039 |
blocks whose logits are not needed) and returns None. size: this block size (default: the smallest that
|
| 2040 |
holds the block)."""
|
|
|
|
|
|
|
| 2041 |
S, n, x, common = self._block_inputs(ids, embeds, pos, S=size)
|
|
|
|
|
|
|
| 2042 |
self._tok += 1
|
| 2043 |
# what truncate() needs to undo part of a multi-token (verification) block
|
| 2044 |
self._last = {"start": self.cur, "cx": {}, "rows": {},
|
|
|
|
| 15 |
runtime input makes the NPU move the whole table every call)
|
| 16 |
Every state tensor lives in the input buffers of one request and is shared by all block-size variants.
|
| 17 |
"""
|
| 18 |
+
import json, os, re, time
|
| 19 |
import numpy as np
|
| 20 |
import openvino as ov
|
| 21 |
|
|
|
|
| 275 |
return n
|
| 276 |
|
| 277 |
|
| 278 |
+
def all_sizes(meta):
|
| 279 |
+
"""[2.0] Every block size the model folder has graphs for: "sizes" (what onw before 2.0 reads) + "prompt_sizes"
|
| 280 |
+
(larger prompt blocks added to a published model later, see compact.graft_sizes - out of older onw's sight, whose
|
| 281 |
+
loader cannot read their seg<i>x.bin)."""
|
| 282 |
+
return sorted(set(meta["sizes"]) | set(meta.get("prompt_sizes") or []))
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
_CONST_RE = re.compile(rb'<layer id="\d+" name="([^"]+)" type="Const"([^>]*)>(\s*)<data ([^>]*?)offset="(\d+)" size="(\d+)"([^>]*)/>')
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def _xbin_model(core, d, name, m, weights):
|
| 289 |
+
"""A graph added by compact.graft_sizes: its constants at offsets >= m["xbase"] are in m["xbin"] (the few new ones -
|
| 290 |
+
shape constants of the larger block - the rest point into the published seg<i>.bin, unchanged). Those are read as
|
| 291 |
+
Parameters (values unknown: shapes left open), then replaced by their constants and the shapes inferred again."""
|
| 292 |
+
base, extra = m["xbase"], np.fromfile(os.path.join(d, m["xbin"]), np.uint8)
|
| 293 |
+
fix = {}
|
| 294 |
+
|
| 295 |
+
def sub(mt):
|
| 296 |
+
off = int(mt.group(5))
|
| 297 |
+
if off < base:
|
| 298 |
+
return mt.group(0)
|
| 299 |
+
fix[mt.group(1).decode()] = (off - base, int(mt.group(6)))
|
| 300 |
+
return (b'<layer id="' + mt.group(0).split(b'"')[1] + b'" name="' + mt.group(1) + b'" type="Parameter"'
|
| 301 |
+
+ mt.group(2) + b'>' + mt.group(3) + b'<data ' + mt.group(4) + mt.group(7).lstrip() + b'/>')
|
| 302 |
+
xml = _CONST_RE.sub(sub, open(os.path.join(d, name), "rb").read())
|
| 303 |
+
model = core.read_model(xml.decode("utf8"), weights)
|
| 304 |
+
import openvino.opset13 as ops
|
| 305 |
+
for p in list(model.get_parameters()):
|
| 306 |
+
if p.get_friendly_name() in fix:
|
| 307 |
+
o, n = fix.pop(p.get_friendly_name())
|
| 308 |
+
et = p.get_element_type()
|
| 309 |
+
c = ops.constant(extra[o:o + n].view(et.to_dtype()).reshape(list(p.get_partial_shape().to_shape())), et)
|
| 310 |
+
c.set_friendly_name(p.get_friendly_name())
|
| 311 |
+
p.output(0).replace(c.output(0))
|
| 312 |
+
model.remove_parameter(p)
|
| 313 |
+
if fix:
|
| 314 |
+
raise ValueError(f"{name}: constants of {m['xbin']} not found in the graph: {sorted(fix)[:3]}")
|
| 315 |
+
model.validate_nodes_and_infer_types()
|
| 316 |
+
return model
|
| 317 |
+
|
| 318 |
+
|
| 319 |
def read_segment(core, d, name, m, npuw, bins, T, built_T):
|
| 320 |
"""The segment's ov.Model, reshaped to context T. With NPUW every segment of a .bin reads against the same
|
| 321 |
in-memory copy (bins cache), so the weights bank sees one set of constants."""
|
| 322 |
+
if m.get("xbin"):
|
| 323 |
+
b = m.get("bin") or name[:-4] + ".bin"
|
| 324 |
+
if b not in bins:
|
| 325 |
+
bins[b] = ov.Tensor(mapped_bin(os.path.join(d, b)), shared_memory=True)
|
| 326 |
+
model = _xbin_model(core, d, name, m, bins[b])
|
| 327 |
+
if npuw:
|
| 328 |
+
model = _no_bool_constants(model)
|
| 329 |
+
elif npuw:
|
| 330 |
b = m.get("bin") or name[:-4] + ".bin"
|
| 331 |
if b not in bins:
|
| 332 |
# a file-backed mapping (OpenVINO shares it, see mapped_bin): NPUW's weights bank keeps reading the
|
|
|
|
| 343 |
return model
|
| 344 |
|
| 345 |
|
| 346 |
+
GROUPED_MIN = int(os.environ.get("ONW_GROUPED_MIN", "2")) # [2.0] prompts from this long go the grouped-MoE path
|
| 347 |
+
GROUPED_RAM_GB = 24 # [2.0] RAM the grouped-MoE prompt path needs
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def grouped_wanted(meta):
|
| 351 |
+
"""[2.0] Whether a model with mixer graphs (engine.json "mixer") takes the grouped-MoE prompt path: with >= 24 GB
|
| 352 |
+
RAM (compiling the 64-token mixers next to the streamed experts' cache froze a 16 GB Lunar Lake); ONW_GROUPED=1 /
|
| 353 |
+
0 forces it on / off. (chat.block_sizes loads only the 1-token graphs then.)"""
|
| 354 |
+
env = os.environ.get("ONW_GROUPED")
|
| 355 |
+
if not meta.get("mixer") or env == "0":
|
| 356 |
+
return False
|
| 357 |
+
if env == "1":
|
| 358 |
+
return True
|
| 359 |
+
from .chat import total_ram_gb
|
| 360 |
+
ram = total_ram_gb()
|
| 361 |
+
return ram is not None and ram >= GROUPED_RAM_GB
|
| 362 |
+
|
| 363 |
CRASHED = "crashed"
|
| 364 |
TRANSIENT = "transient: " # a failure worth trying again next time (out of memory, disk full, interrupted)
|
| 365 |
|
|
|
|
| 981 |
self.sinks = {l: np.asarray(v, np.float32) for l, v in self.meta.get("attn_sinks", {}).items()}
|
| 982 |
self.mrope = rope.get("mrope_section") if rope.get("mrope_interleaved") else None
|
| 983 |
self.delta = 0 # MRoPE: position = slot + delta after an image (set by the caller)
|
| 984 |
+
sizes = sizes or all_sizes(self.meta)
|
| 985 |
config = enable_turbo(core, device, config) # (see turbo(): on unless ONW_TURBO=0)
|
| 986 |
t0 = time.time()
|
| 987 |
if os.path.exists(os.path.join(d, "experts.bin")):
|
|
|
|
| 1067 |
# The largest block the model HAS, not the ones loaded: the ring is part of every graph's shape, so with the
|
| 1068 |
# 64-token blocks switched off (Gemma below 24 GB) the 1- and 16-token graphs would otherwise be other graphs -
|
| 1069 |
# compiled again at every switch, the old blobs pruned (48 ring slots more cost next to nothing)
|
| 1070 |
+
self.ring = self.window + max(all_sizes(self.meta) if self.meta.get("sizes") else sizes) if self.paged and self.window else None
|
| 1071 |
kv_dims, dropped = {}, set()
|
| 1072 |
self._rope = {} # S -> (CPU request: position_ids -> angles, Cos / Sin per angle)
|
| 1073 |
self._ctx = dict(core=core, d=d, device=device, config=config or {}, npuw=npuw, tried=tried, bins=bins,
|
|
|
|
| 1146 |
for p_ in NPUW_SEEN:
|
| 1147 |
rmtree(p_)
|
| 1148 |
NPUW_SEEN.clear()
|
| 1149 |
+
# [2.0] the grouped-MoE prompt path (onw/grouped.py): the mixer-only graphs of the model's "mixer" block size,
|
| 1150 |
+
# compiled here (before the cache prune below, which drops what a load did not touch); ONW_GROUPED=0: off
|
| 1151 |
+
self.mix, self.grouped = None, None
|
| 1152 |
+
mx = self.meta.get("mixer")
|
| 1153 |
+
if mx and not only and not grouped_wanted(self.meta):
|
| 1154 |
+
mx = None
|
| 1155 |
+
if mx and self.E and not only:
|
| 1156 |
+
lst = []
|
| 1157 |
+
for name, m in sorted(self.meta["segments"].items()):
|
| 1158 |
+
if not m.get("mixer_only"):
|
| 1159 |
+
continue
|
| 1160 |
+
reqs = self._build(name, m)
|
| 1161 |
+
if reqs is None:
|
| 1162 |
+
print(f"[grouped] no {mx['S']}-token mixer graphs on this NPU / driver: prompts as before", flush=True)
|
| 1163 |
+
lst = None
|
| 1164 |
+
break
|
| 1165 |
+
for req, pm_ in reqs:
|
| 1166 |
+
lst.append((req, pm_, {i.get_any_name() for i in req.model_inputs}))
|
| 1167 |
+
if lst:
|
| 1168 |
+
self.mix = (mx["S"], lst)
|
| 1169 |
+
from . import grouped
|
| 1170 |
+
self.grouped = grouped.make(self) # (here: the first prompt does not wait for its graphs)
|
| 1171 |
+
print(f"[grouped] prompts: {mx['S']}-token mixer graphs + grouped expert GEMMs ({len(lst)} parts)",
|
| 1172 |
+
flush=True)
|
| 1173 |
if only:
|
| 1174 |
tried.save()
|
| 1175 |
raise Precompiled()
|
|
|
|
| 1242 |
self._needed = {}
|
| 1243 |
for S, lst in self.segs.items():
|
| 1244 |
self._bind(S, lst)
|
| 1245 |
+
if self.mix is not None: # [2.0] the mixer graphs read / write the same state
|
| 1246 |
+
for req, m, names in self.mix[1]:
|
| 1247 |
+
for n in names:
|
| 1248 |
+
if n in self._owner:
|
| 1249 |
+
req.set_tensor(n, self._owner[n])
|
| 1250 |
+
elif n.startswith("shared.") and n[7:] in self.shared:
|
| 1251 |
+
req.set_tensor(n, self.shared[n[7:]])
|
| 1252 |
+
later, need = set(), []
|
| 1253 |
+
for _, _, names in reversed(self.mix[1]):
|
| 1254 |
+
need.append(set(later))
|
| 1255 |
+
later |= names
|
| 1256 |
+
self._mix_needed = need[::-1]
|
| 1257 |
self.exact_truncate = not any(k.startswith("ssm.") for k in self.state)
|
| 1258 |
# DeltaNet states in two buffers each: a segment reads the current one and writes its new state straight into
|
| 1259 |
# the other (set_tensor, no copy), then the two swap roles; undoing a verification block swaps back instead of
|
|
|
|
| 1270 |
self.S = max(self.segs)
|
| 1271 |
import threading
|
| 1272 |
self._mutex = threading.RLock() # a block size is swapped in only between two blocks
|
| 1273 |
+
# [2.0] MoE decoding with the 1-token requests wired together once (onw/wired.py); ONW_WIRED=0: off
|
| 1274 |
+
self._wired = None
|
| 1275 |
+
if os.environ.get("ONW_WIRED", "1") != "0":
|
| 1276 |
+
from .wired import Wired
|
| 1277 |
+
if Wired.usable(self):
|
| 1278 |
+
self._wired = Wired(self)
|
| 1279 |
+
print(f"[wired] 1-token steps: {len(self._wired.plan)} parts wired once "
|
| 1280 |
+
f"({self._wired.copies} carried inputs copied)", flush=True)
|
| 1281 |
self._lazy, self._loading = {S: True for S in deferred}, {}
|
| 1282 |
# attention sinks per paged layer, in the [kv, G] layout of the paged cache
|
| 1283 |
self._sink = {}
|
|
|
|
| 2139 |
|
| 2140 |
def prefill(self, ids, embeds=None, pos=None, want_logits=True, stop=None):
|
| 2141 |
with self._mutex:
|
| 2142 |
+
if self.mix is not None and len(ids) >= GROUPED_MIN: # [2.0] the grouped-MoE prompt path
|
| 2143 |
+
from . import grouped
|
| 2144 |
+
return grouped.prefill(self, ids, embeds, pos, want_logits, stop)
|
| 2145 |
return self._prefill_layers(ids, embeds, pos, want_logits, stop)
|
| 2146 |
|
| 2147 |
def _step(self, ids, embeds=None, pos=None, want_logits=True, size=None):
|
|
|
|
| 2150 |
MRoPE positions (default: slot + delta on all rows). want_logits=False skips the LM head segment (prompt
|
| 2151 |
blocks whose logits are not needed) and returns None. size: this block size (default: the smallest that
|
| 2152 |
holds the block)."""
|
| 2153 |
+
if self._wired is not None and len(ids) == 1 and embeds is None and pos is None and size in (None, 1):
|
| 2154 |
+
return self._wired.step(int(ids[0]), want_logits)
|
| 2155 |
S, n, x, common = self._block_inputs(ids, embeds, pos, S=size)
|
| 2156 |
+
if S == 1 and self._wired is not None:
|
| 2157 |
+
self._wired.dirty = True # (this path binds its own arrays into the 1-token requests)
|
| 2158 |
self._tok += 1
|
| 2159 |
# what truncate() needs to undo part of a multi-token (verification) block
|
| 2160 |
self._last = {"start": self.cur, "cx": {}, "rows": {},
|
onw/web/manage.html
CHANGED
|
@@ -256,7 +256,7 @@
|
|
| 256 |
</div>
|
| 257 |
<div class="opt"><span class="k">エキスパートのメモリ</span><select id="f-expert_gb" style="width:220px"><option value="auto">auto(足りないときだけ SSD から)</option><option value="off">すべてメモリに置く</option><option value="2">2 GB</option><option value="3">3 GB</option><option value="4">4 GB</option><option value="5">5 GB</option><option value="6">6 GB</option><option value="8">8 GB</option></select>
|
| 258 |
<span class="desc">Qwen3.6 系の MoE 用。上限を超えるエキスパートは SSD に置いたまま必要なときだけ読みます(動きますが遅くなります)。</span></div>
|
| 259 |
-
<div class="opt"><span class="k">64 トークン処理<span class="rc" title="変えるとコンパイルし直します">※</span></span><select id="f-big_blocks" style="width:300px"><option value="auto">auto(
|
| 260 |
<span class="desc">長いプロンプトの処理が速くなる代わりに、そのぶんメモリを使います(Gemma)。</span>
|
| 261 |
<span class="desc">※ 64 トークン処理は on にしたときだけ(off にするのはすぐです)。</span></div>
|
| 262 |
</div>
|
|
|
|
| 256 |
</div>
|
| 257 |
<div class="opt"><span class="k">エキスパートのメモリ</span><select id="f-expert_gb" style="width:220px"><option value="auto">auto(足りないときだけ SSD から)</option><option value="off">すべてメモリに置く</option><option value="2">2 GB</option><option value="3">3 GB</option><option value="4">4 GB</option><option value="5">5 GB</option><option value="6">6 GB</option><option value="8">8 GB</option></select>
|
| 258 |
<span class="desc">Qwen3.6 系の MoE 用。上限を超えるエキスパートは SSD に置いたまま必要なときだけ読みます(動きますが遅くなります)。</span></div>
|
| 259 |
+
<div class="opt"><span class="k">64 トークン処理<span class="rc" title="変えるとコンパイルし直します">※</span></span><select id="f-big_blocks" style="width:300px"><option value="auto">auto(オン。NPU 3720 などでは 16 トークン処理と入れ替え)</option><option value="on">on</option><option value="off">off</option></select>
|
| 260 |
<span class="desc">長いプロンプトの処理が速くなる代わりに、そのぶんメモリを使います(Gemma)。</span>
|
| 261 |
<span class="desc">※ 64 トークン処理は on にしたときだけ(off にするのはすぐです)。</span></div>
|
| 262 |
</div>
|
onw/wired.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""[2.0] Decoding (1-token steps) of a MoE model with the requests wired together once ("prepared runs", the onwx
|
| 2 |
+
decode idea in OpenVINO terms): each part's carried outputs are bound as the next part's inputs at setup (the same
|
| 3 |
+
NPU-visible tensors - no host copy, no per-call input dicts); per token the host only writes the embedding row and the
|
| 4 |
+
rope tables, reads the router's top-k, rebinds the expert slots, runs the paged attention and swaps the DeltaNet state
|
| 5 |
+
buffers; the conv-window bookkeeping of part i runs while part i + 1 is on the NPU. Same tokens as
|
| 6 |
+
SegmentedModel._step (NPU 3720, Qwen3.6-35B-A3B: 146.5 -> 139.5 ms a token). ONW_WIRED=0: off."""
|
| 7 |
+
import numpy as np
|
| 8 |
+
from .store import host_tensor
|
| 9 |
+
|
| 10 |
+
COMMON = ("inputs_embeds", "rope_cos", "rope_sin", "position_ids", "token_valid", "mask")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class Wired:
|
| 14 |
+
def __init__(self, m):
|
| 15 |
+
self.m = m
|
| 16 |
+
self.parts = m.segs[1]
|
| 17 |
+
mk = host_tensor(m._ctx["core"], m._ctx["device"])
|
| 18 |
+
self.common, self.plan, producer, self.copies = {}, [], {}, 0
|
| 19 |
+
for i, (req, meta, names) in enumerate(self.parts):
|
| 20 |
+
outs = {o.get_any_name() for o in req.model_outputs}
|
| 21 |
+
ins = {p.get_any_name(): p for p in req.model_inputs}
|
| 22 |
+
st = {"req": req, "meta": meta, "copy": [], "pp": [], "route_from": None, "bind": []}
|
| 23 |
+
for n, p in ins.items():
|
| 24 |
+
own = lambda p=p: mk(p.get_element_type(), list(p.get_shape()))
|
| 25 |
+
if n.startswith("carry.") and not n.startswith("carry.attn."):
|
| 26 |
+
j, on = producer[n]
|
| 27 |
+
src = self.parts[j][0].get_tensor(on)
|
| 28 |
+
if src.get_element_type() == p.get_element_type() and list(src.get_shape()) == list(p.get_shape()):
|
| 29 |
+
st["bind"].append((n, src)) # wired: no copy, no host work a token
|
| 30 |
+
else:
|
| 31 |
+
st["copy"].append((n, j, on))
|
| 32 |
+
st["bind"].append((n, own()))
|
| 33 |
+
self.copies += 1
|
| 34 |
+
elif n in COMMON or n.startswith("hostrope."):
|
| 35 |
+
if n not in self.common:
|
| 36 |
+
self.common[n] = own()
|
| 37 |
+
st["bind"].append((n, self.common[n]))
|
| 38 |
+
elif n.startswith("carry.attn.") or n == "route.slot_w":
|
| 39 |
+
st["bind"].append((n, own()))
|
| 40 |
+
elif n in m._pp:
|
| 41 |
+
st["pp"].append(n)
|
| 42 |
+
if meta.get("consume_moe") is not None:
|
| 43 |
+
st["route_from"] = producer["route"]
|
| 44 |
+
for on in outs:
|
| 45 |
+
if on.startswith("next."):
|
| 46 |
+
producer["carry." + on[5:]] = (i, on)
|
| 47 |
+
if "route.idx" in outs:
|
| 48 |
+
producer["route"] = i
|
| 49 |
+
st["cx"] = [o for o in outs if o.startswith("cx.")]
|
| 50 |
+
st["ssm"] = [o for o in outs if o.startswith("ssm_new.")]
|
| 51 |
+
st["kv"] = meta.get("paged_attn")
|
| 52 |
+
st["kvw"] = st["kv"] is not None and f"new_k.{st['kv']}" in outs
|
| 53 |
+
st["head"] = "logits" in outs
|
| 54 |
+
self.plan.append(st)
|
| 55 |
+
self.dirty = True # (bind() before the first step)
|
| 56 |
+
|
| 57 |
+
def bind(self):
|
| 58 |
+
"""(again after the stock path ran these requests with its own input arrays)"""
|
| 59 |
+
for st in self.plan:
|
| 60 |
+
for n, t in st["bind"]:
|
| 61 |
+
st["req"].set_tensor(n, t)
|
| 62 |
+
self.dirty = False
|
| 63 |
+
|
| 64 |
+
def step(self, tok, want_logits=True):
|
| 65 |
+
m = self.m
|
| 66 |
+
if self.dirty:
|
| 67 |
+
self.bind()
|
| 68 |
+
_, _, x, common = m._block_inputs([tok], None, None, S=1)
|
| 69 |
+
for k, t in self.common.items():
|
| 70 |
+
t.data[:] = x if k == "inputs_embeds" else common[k]
|
| 71 |
+
m._tok += 1
|
| 72 |
+
m._last = {"start": m.cur, "cx": {}, "rows": {}, "prev": None, "pp": None}
|
| 73 |
+
cur, later, logits = m.cur, None, None
|
| 74 |
+
for pi, st in enumerate(self.plan):
|
| 75 |
+
req, meta = st["req"], st["meta"]
|
| 76 |
+
if st["head"] and not want_logits:
|
| 77 |
+
continue
|
| 78 |
+
for n_, j, on in st["copy"]:
|
| 79 |
+
req.get_tensor(n_).data[:] = self.parts[j][0].get_tensor(on).data
|
| 80 |
+
if st["route_from"] is not None:
|
| 81 |
+
pr = self.parts[st["route_from"]][0]
|
| 82 |
+
idx, w = pr.get_tensor("route.idx").data, pr.get_tensor("route.w").data
|
| 83 |
+
layer = meta["consume_moe"]
|
| 84 |
+
m._bound = (m._tok, layer)
|
| 85 |
+
keys = [(layer, int(e)) for e in idx[0]]
|
| 86 |
+
pairs = m.bank.get_many(keys) if m.streaming else [m.bank[k] for k in keys]
|
| 87 |
+
for j, (w_t, s_t) in enumerate(pairs):
|
| 88 |
+
req.set_tensor(f"w_{j}", w_t)
|
| 89 |
+
req.set_tensor(f"s_{j}", s_t)
|
| 90 |
+
req.get_tensor("route.slot_w").data[0] = w[0]
|
| 91 |
+
for k in st["pp"]:
|
| 92 |
+
bufs, c = m._pp[k]
|
| 93 |
+
req.set_tensor(k, bufs[c])
|
| 94 |
+
req.set_tensor("ssm_new." + k[4:], bufs[1 - c])
|
| 95 |
+
req.start_async()
|
| 96 |
+
if later is not None:
|
| 97 |
+
later()
|
| 98 |
+
later = None
|
| 99 |
+
req.wait()
|
| 100 |
+
L = st["kv"]
|
| 101 |
+
if L is not None: # paged attention: rows into the cache, attention on the NPU
|
| 102 |
+
if st["kvw"]: # (a layer sharing another's cache only reads it)
|
| 103 |
+
m.kv.write(L, cur, req.get_tensor(f"new_k.{L}").data[0, :, :1], req.get_tensor(f"new_v.{L}").data[0, :, :1])
|
| 104 |
+
q = req.get_tensor(f"q.{L}").data
|
| 105 |
+
self.parts[pi + 1][0].get_tensor(f"carry.attn.{L}").data[:] = m.kv.attend(
|
| 106 |
+
L, q[0], cur, 1, 1, sink=m._sink.get(L), rows_out=True)
|
| 107 |
+
|
| 108 |
+
def book(st=st, req=req):
|
| 109 |
+
for o in st["cx"]:
|
| 110 |
+
v = req.get_tensor(o).data
|
| 111 |
+
s_ = m.state["conv." + o[3:]]
|
| 112 |
+
s_[:] = np.concatenate([s_, v[:, :1]], 1)[:, -s_.shape[1]:]
|
| 113 |
+
for o in st["ssm"]:
|
| 114 |
+
e = m._pp["ssm." + o[8:]]
|
| 115 |
+
e[1] = 1 - e[1]
|
| 116 |
+
m.state["ssm." + o[8:]] = e[0][e[1]].data
|
| 117 |
+
later = book
|
| 118 |
+
if st["head"]:
|
| 119 |
+
logits = np.array(req.get_tensor("logits").data[0, :1])
|
| 120 |
+
if later is not None:
|
| 121 |
+
later()
|
| 122 |
+
m.cur += 1
|
| 123 |
+
if logits is not None:
|
| 124 |
+
if not np.isfinite(logits).all():
|
| 125 |
+
raise FloatingPointError("the 1-token graph returned NaN/inf logits")
|
| 126 |
+
if m.banned:
|
| 127 |
+
logits[:, m.banned] = -np.inf
|
| 128 |
+
return logits
|
| 129 |
+
|
| 130 |
+
@staticmethod
|
| 131 |
+
def usable(m):
|
| 132 |
+
"""wiring needs: a MoE model with paged attention, 1-token graphs, DeltaNet states in two buffers (the stock
|
| 133 |
+
path's every other input kind is unknown here)"""
|
| 134 |
+
if not (m.E and m.paged and 1 in m.segs and m.mtp is None and not m.predict_k):
|
| 135 |
+
return False
|
| 136 |
+
known = set(COMMON) | {"route.slot_w"}
|
| 137 |
+
for req, meta, names in m.segs[1]:
|
| 138 |
+
for n in names:
|
| 139 |
+
if not (n in known or n.startswith(("carry.", "hostrope.", "w_", "s_", "shared.")) or n in m._pp
|
| 140 |
+
or n in m._owner):
|
| 141 |
+
return False
|
| 142 |
+
return True
|