ryugyosoft commited on
Commit
aee23d2
·
verified ·
1 Parent(s): e8ee6d8

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 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 倍速くなります(Gemma、Qwen3.5 系の NF4 版)。Lunar Lake 以降は重みを共有するのでメモリはほぼ増えません。NPU 3720 ではそのぶんメモリを使います。auto は Lunar Lake 以降か、メモリ 24 GB 以上でオン |
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 ~2x faster (Gemma, the NF4 Qwen3.5-family builds). From Lunar Lake the weights are shared, so memory hardly grows; on NPU 3720 it costs that much memory. auto turns it on from Lunar Lake or with 24 GB+ RAM |
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
- 先読み検証は、16 トークンの検証ブロックが十分安く済むモデル(Qwen3.5-9B、Gemma、LFM 系)で自動的に使われます。
 
 
 
 
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 トークン処理(Gemma、Qwen3.5 系 NF4 版。auto は Lunar Lake 以降か RAM 24 GB 以上でオン) | `ONW_S64=1/0`/設定画面 | 初回のみ |
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
- Prompt lookup decoding is used automatically where a 16-token verification block is cheap enough (Qwen3.5-9B,
122
- Gemma, the LFM models), and every model keeps the conversation state between turns (a follow-up with an image
 
 
 
 
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 (Gemma, the NF4 Qwen3.5-family builds; auto = on from Lunar Lake or with 24 GB+ RAM) | `ONW_S64=1/0` / settings | once, the first time |
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__ = "1.4.13"
 
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
- """Same rule as chat.block_sizes: 64-token blocks only with >= 24 GB RAM unless forced in the settings."""
297
- b = cfg.get("big_blocks", "auto")
298
- return b == "on" if b in ("on", "off") else (ram is not None and ram >= 24)
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
- if not (gpt or nf4) or not downloaded_by_onw(p):
 
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. Prompt blocks above 16 tokens prefill faster (Lunar Lake: 64-token blocks ~2x); with the
55
- weights shared between the sizes (NPUW) they cost next to nothing, else every size holds its own copy of the
56
- in-graph weights, so they are used only with >= 24 GB RAM; ONW_S64=1 / 0 forces them on / off."""
57
- sizes = meta["sizes"]
 
 
 
58
  only = os.environ.get("ONW_SIZES") # e.g. "16": decode with the 16-token graphs only
 
 
 
 
59
  if only:
60
- sizes = [S for S in sizes if str(S) in only.split(",")] or sizes
 
 
 
61
  env = os.environ.get("ONW_S64")
62
- ram = total_ram_gb()
63
- big = env == "1" if env in ("0", "1") else (shared or (ram is not None and ram >= 24))
64
- sizes = [S for S in sizes if S <= 16 or big]
65
- if not only:
66
- small = [S for S in sizes if S <= 16]
67
- verify = [S for S in small if S >= 4]
68
- if shared and mtp and verify:
69
- # MTP drafts: every answer step is a verification block (x + 2 drafts, + rows to redo) on the smallest
70
- # graph of >= 4 rows (Lunar Lake: the 4-token one ~15% cheaper than the 16-token one); no 1-token graphs
71
- # (no single steps are left, and the NPU driver runs out of command queues past ~40 compiled parts:
72
- # Ornith 9B has 13 per size + 3 for MTP)
73
- keep = {min(verify)}
74
- else:
75
- # otherwise the 1- and 16-token graphs (4 / 8-token ones stay out: without NPUW each size holds its own
76
- # copy of the weights)
77
- keep = {S for S in small if S in (1, 16)} or set(small)
78
- sizes = [S for S in sizes if S > 16 or S in keep]
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
- sizes = block_sizes(meta, shared, mtp)
 
 
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(Lunar Lake 以降か RAM 24 GB 以上でオン)": "auto (on with Lunar Lake or later, or 24 GB+ RAM)",
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(Lunar Lake 以降か RAM 24 GB 以上でオン)": "auto(Lunar Lake 及更新机型或内存 24 GB 以上时开启)",
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, "slots": slots}
 
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 npuw:
 
 
 
 
 
 
 
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["sizes"]
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") or sizes) if self.paged and self.window else None
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(Lunar Lake 以降か RAM 24 GB 以上でオン)</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>
 
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