ryugyosoft commited on
Commit
eff87bb
·
verified ·
1 Parent(s): eab5d72

onw 1.4.0: MTP draft tokens (~1.5x answers on Lunar Lake, NF4 Qwen3.5 family), 64-token prompt blocks on Lunar Lake, less host work per step, fix all-zero logit rows

Browse files
CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@
5
  日付は日本時間です。各版は Hugging Face の `ryugyosoft/onw` にタグ(`v0.4.2` 以降)付きで残っています。
6
  onw ウィンドウは、新しい版があるとこのファイルから変更点を表示します(`## v…` の見出しで区切っています)。
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  ## v1.3.0 (2026-10-04)
9
  - **Anthropic の Messages API(`/v1/messages`)と OpenAI の Responses API(`/v1/responses`)に対応**。Claude Code や Codex CLI など、
10
  OpenAI のチャット API 以外の形式で話すアプリからも使えます(ストリーミング、ツール呼び出し、思考に対応。`count_tokens`、
 
5
  日付は日本時間です。各版は Hugging Face の `ryugyosoft/onw` にタグ(`v0.4.2` 以降)付きで残っています。
6
  onw ウィンドウは、新しい版があるとこのファイルから変更点を表示します(`## v…` の見出しで区切っています)。
7
 
8
+ ## v1.4.0 (2026-10-06)
9
+ - **MTP 先読みで回答が約 1.5 倍に(Lunar Lake 以降、Qwen3.5 系の NF4 版)**:Qwen3.5 系のモデルに付いている MTP 層
10
+ (次の次のトークンを予想する小さな層)で 2 トークン先まで下書きし、本体で一度に確かめます。NPU は数トークンを 1 トークンと
11
+ ほぼ同じ時間で計算できるので、当たった分だけ速くなります。温度ありの生成でも、1 トークンずつ生成したときと同じ確率でトークンを
12
+ 選びます。Lunar Lake(8 種類の質問の平均)で Qwen3.5-9B(NF4)9.7 → 15.1 tok/s(温度 0.6:8.6 → 13.8)、Ornith-1.5-9B(NF4)
13
+ 10.2 → 15.1 tok/s(8.7 → 13.8)。確かめるのは 4 トークン用のグラフで、外れた下書きの分だけ正確に巻き戻します。
14
+ NPU 3720 では確かめるブロックが重く、かえって遅くなるため使いません。
15
+ - **Lunar Lake 以降で長いプロンプトの処理が約 2 倍に**:「64 トークン処理」の auto が、Lunar Lake 以降でもオンになりました
16
+ (これまでは RAM 24 GB 以上)。重みを共有するのでメモリはほぼ増えません(+0.1〜0.3 GB)。Gemma 4 E4B 73 → 140 tok/s、
17
+ Qwen3.5-9B(NF4)119 → 220 tok/s、Ornith-1.5-9B(NF4)117 → 212 tok/s(約 2,000 トークン)。
18
+ - Qwen3.5-9B(NF4)と Ornith-1.5-9B(NF4)のモデルを、MTP 層・4 トークン版・64 トークン版の入りに更新しました。入れてあるモデルは
19
+ モデル一覧に「更新あり」と出るので、「更新」で変わったファイルだけ取り直してください(約 3.9 GB)。
20
+ - 回答の 1 ステップごとのホスト側の処理を約 3 割削減(NPU への入力をコピーせずに渡す、DeltaNet の状態を 2 つのバッファで交互に
21
+ 使う、アテンションの出力を次の区間の形へ直接書く、など)。生成結果は変わりません(全モデルで、変更前と同じトークン列を確認)。
22
+ - **修正**:複数トークンのブロック(先読み検証、MTP、プロンプトの最後のブロック)で、活性の大きい行の最終正規化が fp16 で溢れて
23
+ ロジットが行ごと 0 になり、意味のないトークン(他の言語の単語など)がまれに混ざっていました。その行だけ縮めて計算し直します。
24
+ - **修正**:NPU 4000 以降で、ブロックサイズごとのコンパイル結果が別々の回に作られていると(64 トークン処理を後からオンにした、
25
+ onw の更新で一部だけコンパイルし直した、など)、重みの共有が外れてメモリを 2.5〜3.8 GB 余計に使っていました。混ざっていたら
26
+ 一度だけまとめてコンパイルし直します(Lunar Lake で約 10 秒)。
27
+ - 検証画面のモデル情報に「MTP 先読み」を追加。
28
+ - `python -m onw.server` で直接起動したとき、Anthropic・Responses の API(`/v1/messages`、`/v1/responses`)が 404 になって
29
+ いたのを修正(onw ウィンドウや `onw serve` から起動した場合は問題ありませんでした)。
30
+
31
  ## v1.3.0 (2026-10-04)
32
  - **Anthropic の Messages API(`/v1/messages`)と OpenAI の Responses API(`/v1/responses`)に対応**。Claude Code や Codex CLI など、
33
  OpenAI のチャット API 以外の形式で話すアプリからも使えます(ストリーミング、ツール呼び出し、思考に対応。`count_tokens`、
CHANGELOG_en.md CHANGED
@@ -4,6 +4,32 @@
4
 
5
  Dates are JST. Every version is on Hugging Face `ryugyosoft/onw`, tagged from `v0.4.2` on.
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  ## v1.3.0 (2026-10-04)
8
  - **Anthropic's Messages API (`/v1/messages`) and OpenAI's Responses API (`/v1/responses`)**: apps that speak these instead of
9
  OpenAI's chat API, such as Claude Code and Codex CLI, work too (streaming, tool calls, thinking; `count_tokens` and
 
4
 
5
  Dates are JST. Every version is on Hugging Face `ryugyosoft/onw`, tagged from `v0.4.2` on.
6
 
7
+ ## v1.4.0 (2026-10-06)
8
+ - **MTP draft tokens: answers ~1.5x faster (Lunar Lake and newer, the NF4 Qwen3.5-family builds)**: the MTP layer that
9
+ Qwen3.5-family models carry (a small layer predicting the token after next) drafts two tokens ahead, and the model checks
10
+ them in one pass. The NPU computes a few tokens in about the time of one, so every kept draft is time saved. With
11
+ temperature the tokens are drawn with the same probabilities as one at a time. On Lunar Lake (average over 8 kinds of
12
+ prompts): Qwen3.5-9B (NF4) 9.7 -> 15.1 tok/s (temperature 0.6: 8.6 -> 13.8), Ornith-1.5-9B (NF4) 10.2 -> 15.1 tok/s
13
+ (8.7 -> 13.8). The checking runs on 4-token graphs and cuts back exactly past a rejected draft. Not used on NPU 3720,
14
+ where the checking block is expensive and MTP came out slower.
15
+ - **Long prompts ~2x faster on Lunar Lake and newer**: "64-token blocks" on auto now turns on from Lunar Lake as well (until
16
+ now: 24 GB+ RAM). The weights are shared, so memory hardly grows (+0.1-0.3 GB). Gemma 4 E4B 73 -> 140 tok/s,
17
+ Qwen3.5-9B (NF4) 119 -> 220 tok/s, Ornith-1.5-9B (NF4) 117 -> 212 tok/s (~2,000 tokens).
18
+ - The Qwen3.5-9B (NF4) and Ornith-1.5-9B (NF4) models now include the MTP layer and the 4- and 64-token graphs. Installed
19
+ copies show "Update available" in the model list: "Update" fetches just the changed files (~3.9 GB).
20
+ - About 30% less host-side work per answer step (inputs handed to the NPU without copies, DeltaNet states in two
21
+ alternating buffers, attention output written straight in the next part's layout, ...). Same output: every model gives
22
+ the same tokens as before.
23
+ - **Fixed**: in multi-token blocks (prompt lookup and MTP verification, a prompt's last block) the final norm of rows with
24
+ large activations overflowed fp16 and the whole row of logits came back as zeros - now and then a meaningless token (a
25
+ word from another language) slipped in. Those rows are computed again, scaled down.
26
+ - **Fixed**: on NPU 4000 and newer, block sizes compiled in different runs (64-token blocks switched on later, a few graphs
27
+ compiled again after an onw update, ...) stopped sharing their weights and used 2.5-3.8 GB more memory. A mixed cache is now
28
+ compiled again once, in one run (~10 s on Lunar Lake).
29
+ - "MTP draft tokens" in the model information of the checks page.
30
+ - Started directly with `python -m onw.server`, the Anthropic and Responses APIs (`/v1/messages`, `/v1/responses`) answered
31
+ 404 (started from the onw window or `onw serve` they were fine).
32
+
33
  ## v1.3.0 (2026-10-04)
34
  - **Anthropic's Messages API (`/v1/messages`) and OpenAI's Responses API (`/v1/responses`)**: apps that speak these instead of
35
  OpenAI's chat API, such as Claude Code and Codex CLI, work too (streaming, tool calls, thinking; `count_tokens` and
MANUAL.md CHANGED
@@ -165,7 +165,7 @@
165
  | メモリ節約モード | 1 トークン用と 16 トークン用のグラフを入れ替えて、片方だけを持ちます。待機中のメモリが約 4 割減る代わりに、回答の出だし数秒は遅めで、先読み検証は使いません。効くモデルがあるときだけ表示され、この PC で効くモデルの名前が出ます |
166
  | 厳格 | メモリ節約モードの追加。2 種類のグラフを同時に持たないので、メモリは最小になりますが、回答のたびに書き始めるまで数秒待ちます |
167
  | エキスパートのメモリ | Qwen3.6 系の MoE 用。上限を超えるエキスパートは SSD に置いたまま、必要なときだけ読みます(メモリが少なくても動きますが、遅くなります) |
168
- | 64 トークン処理 | 長いプロンプトの処理が速くなる代わりに、そのぶんメモリを使います(Gemma)。auto はメモリ 24 GB 以上でオン |
169
 
170
  ### 詳細
171
 
 
165
  | メモリ節約モード | 1 トークン用と 16 トークン用のグラフを入れ替えて、片方だけを持ちます。待機中のメモリが約 4 割減る代わりに、回答の出だし数秒は遅めで、先読み検証は使いません。効くモデルがあるときだけ表示され、この PC で効くモデルの名前が出ます |
166
  | 厳格 | メモリ節約モードの追加。2 種類のグラフを同時に持たないので、メモリは最小になりますが、回答のたびに書き始めるまで数秒待ちます |
167
  | エキスパートのメモリ | Qwen3.6 系の MoE 用。上限を超えるエキスパートは SSD に置いたまま、必要なときだけ読みます(メモリが少なくても動きますが、遅くなります) |
168
+ | 64 トークン処理 | 長いプロンプトの処理が約 2 倍速くなります(Gemma、Qwen3.5 系の NF4 版)。Lunar Lake 以降は重みを共有するのでメモリはほぼ増えません。NPU 3720 ではそのぶんメモリを使います。auto は Lunar Lake 以降か、メモリ 24 GB 以上でオン |
169
 
170
  ### 詳細
171
 
MANUAL_en.md CHANGED
@@ -175,7 +175,7 @@ applies them ("Discard" throws them away). For settings that need a server resta
175
  | 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. Shown only when it applies, with the models it works for on this PC |
176
  | 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 |
177
  | 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) |
178
- | 64-token blocks | Long prompts are processed faster, at the cost of more memory (Gemma). auto turns it on with 24 GB+ RAM |
179
 
180
  ### Advanced
181
 
 
175
  | 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. Shown only when it applies, with the models it works for on this PC |
176
  | 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 |
177
  | 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) |
178
+ | 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 |
179
 
180
  ### Advanced
181
 
README.md CHANGED
@@ -24,9 +24,6 @@ LLM を **Intel NPU だけで** 動かす小さなエンジンです。MoE(専
24
  llama.cpp と GGUF の関係と同じく、エンジンとモデルは別々にダウンロードします。エンジンは一つ、コマンドも一つで、
25
  変換済みのモデルならどれでも動きます。
26
 
27
- 低電力の NPU で動かすので、ファンが低回転のままでも静かに LLM を使えます。Lunar Lake で計測したところ、1 トークンあたりの電力は
28
- iGPU(llama.cpp)より少なく、CPU の約 4 割でした。詳しい数字と測り方は [電力効率と温度](#電力効率と温度) にあります。
29
-
30
  ## インストール(コマンドを 1 行貼るだけ)
31
 
32
  Intel Core Ultra(NPU 付き)の PC なら、次の 1 行で入ります(詳しい条件は下の「必要なもの」)。
@@ -88,11 +85,11 @@ Python や git の知識は要りません。Python が入っていなければ
88
  |---|---|---|---|---|---|---|
89
  | [ryugyosoft/gemma-4-E4B-it-onw](https://huggingface.co/ryugyosoft/gemma-4-E4B-it-onw) | `gemma4`(検証済みの Gemma NPU グラフを取り込み) | テキスト+画像 | 切替 | 7.2 tok/s | 4.3 GB | 約 6 GB(画像処理中 8.5 GB) |
90
  | [ryugyosoft/Qwen3.5-9B-onw](https://huggingface.co/ryugyosoft/Qwen3.5-9B-onw) | `qwen3_5`(Gated DeltaNet+ゲート付きアテンション、dense) | テキスト+画像 | 切替 | 4.1 tok/s | 5.3 GB | 約 11 GB |
91
- | [ryugyosoft/Qwen3.5-9B-nf4-onw](https://huggingface.co/ryugyosoft/Qwen3.5-9B-nf4-onw) | 同上、重みはチャネル単位 NF4(**Lunar Lake 以降専用**) | テキスト+画像 | 切替 | —(Lunar Lake で 9.4 tok/s、通常版の約 1.3 倍) | 5.2 GB | 通常版の約 8 割 |
92
  | [ryugyosoft/Qwen3.6-REAP-18B-A3B-onw](https://huggingface.co/ryugyosoft/Qwen3.6-REAP-18B-A3B-onw) | `qwen3_5_moe`、256 個中 128 個のエキスパートを残す(REAP) | テキスト+画像 | 切替 | 6.8 tok/s | 9.6 GB | 約 12 GB |
93
  | [ryugyosoft/Qwen3.6-35B-A3B-onw](https://huggingface.co/ryugyosoft/Qwen3.6-35B-A3B-onw) | `qwen3_5_moe`(Gated DeltaNet+ゲート付きアテンション+MoE 256 個から 8 個+共有エキスパート) | テキスト+画像 | 切替 | 6.8 tok/s | 17 GB | 約 20 GB(SSD から読めば 8〜13 GB) |
94
  | [ryugyosoft/Ornith-1.5-9B-onw](https://huggingface.co/ryugyosoft/Ornith-1.5-9B-onw) | `qwen3_5`(Qwen3.5-9B をエージェント・ツール呼び出し向けに鍛えた Ornith-1.5) | テキスト+画像 | 切替 | 4.9 tok/s | 5.3 GB | 約 11 GB |
95
- | [ryugyosoft/Ornith-1.5-9B-nf4-onw](https://huggingface.co/ryugyosoft/Ornith-1.5-9B-nf4-onw) | 同上、重みはチャネル単位 NF4(**Lunar Lake 以降専用**) | テキスト+画像 | 切替 | —(Lunar Lake で 9.3 tok/s、通常版の約 1.3 倍) | 5.2 GB | 通常版の約 8 割 |
96
  | [ryugyosoft/NeoHorse-1-4B-onw](https://huggingface.co/ryugyosoft/NeoHorse-1-4B-onw) | `qwen3_5`(Qwen3.5-4B をエージェント・ツール呼び出し向けに追加学習した NeoHorse-1) | テキスト | 切替 | 6.4 tok/s | 2.4 GB | 約 6 GB |
97
  | [ryugyosoft/granite-4.2-3b-onw](https://huggingface.co/ryugyosoft/granite-4.2-3b-onw) | `granite`(IBM Granite 4.2:思考とツール呼び出しを強化した 3B。Llama 型) | テキスト | 切替 | 6.7 tok/s(チャット約 7.7) | 2.1 GB | 約 6 GB |
98
  | [ryugyosoft/granite-4.2-8b-onw](https://huggingface.co/ryugyosoft/granite-4.2-8b-onw) | `granite`(同上の 8B) | テキスト | 切替 | 3.6 tok/s(チャット約 3.9) | 5.0 GB | 約 13 GB |
@@ -104,9 +101,15 @@ Python や git の知識は要りません。Python が入っていなければ
104
  | [ryugyosoft/gpt-oss-20b-int8-onw](https://huggingface.co/ryugyosoft/gpt-oss-20b-int8-onw) | 同上、エキスパート INT8(bf16 に近い精度) | テキスト | **常に** | 6 tok/s | 21 GB | 約 21 GB(SSD から読めば 8〜13 GB) |
105
 
106
  **NF4 版(Lunar Lake 以降専用)**:Lunar Lake(NPU 4000)以降の NPU は、NF4(正規分布に合わせた 4 ビット)の重みを
107
- ハードウェアで計算できます。NF4 版はチャネル単位の NF4 にしたモデルで、Lunar Lake では通常版より生成が約 3 割・プロンプト処理が
108
- 約 2 倍速く、メモリは約 2 割少なくて済みます(精度はほぼ同等)。NPU 3720(Meteor Lake・Arrow Lake)では動かないため、onw ウィンドウは
109
- Lunar Lake 以降の PC でだけ一覧に出します。
 
 
 
 
 
 
110
 
111
  使用メモリは、テスト機(Core Ultra 9 285HX、NPU 3720)で、64 トークン処理なし・画像エンコーダーは使ったら解放(メモリ
112
  24 GB 未満の PC の既定)で測ったプロセスのピークです。onw ウィンドウの「メモリ目安」は、この値にその PC の設定
@@ -174,7 +177,8 @@ onw list # 対応アーキ
174
  | temperature、top_p、top_k、繰り返し抑制(repetition / presence / frequency penalty)、max_tokens、stop、seed、思考モード | リクエストごと(API またはチャット画面) | 不要 |
175
  | ポート、待ち受けアドレス、モデル、デバイス、先読み検証のオン/オフ | サーバーのオプション/設定画面、再起動(約 20 秒) | 不要 |
176
  | **コンテキスト長**(`--context`、既定 32K、モデルの最大まで) | サーバーのオプション/設定画面 | 不要(KV キャッシュの上限が変わるだけ) |
177
- | 64 トークン処理(Gemma) | `ONW_S64=1/0`/設定画面 | 初回のみ |
 
178
  | エキスパートのメモリ(Qwen3.6 系。上限を超える分は SSD から読む) | `ONW_EXPERT_GB=5`/設定画面 | 不要 |
179
  | NPUW 重み共有(試験的。auto は Lunar Lake 以降でオン、MoE モデル(LFM2・Qwen3.6)と区間の多いモデル(Granite・llm-jp)は対象外:重みを 1 コピーにしてメモリとコンパイル時間を削減) | `ONW_NPUW=1/0`/設定画面 | 初回のみ |
180
  | メモリ節約モード(1 トークン用と 16 トークン用で重みを共有できない dense モデル:NPU 3720 ではすべて、Lunar Lake 以降は Granite・llm-jp と NPUW オフのとき。普段は 16 トークン用のグラフだけを持ち、回答を書く間だけ 1 トークン用と入れ替える。待機中のメモリ約 4〜6 割減、回答の出だし数秒は遅め、先読み検証なし) | `ONW_LAZY16=1`/設定画面(効くモデルがあるときだけ表示) | 初回のみ |
@@ -217,43 +221,6 @@ KV キャッシュは INT8 で、会話が実際に使った長さの分だけ
217
  | `setup.*`、`install.*`、`find_python.bat` | インストーラー(Python の検出と winget) |
218
  | `check_ref.py`、`check_vision.py`、`test_*.py`、`probe_segments.py`、`prof_*.py`、`mem_*.py`、`bench_*.py` | 元の HF モデルとの比較、生成・画像・複数ターンの検証、区間ごとの NPU と CPU の比較、速度・メモリ計測 |
219
 
220
- ## 電力効率と温度
221
-
222
- 同じ PC(LattePanda Mu Ultra、Core Ultra 5 226V=Lunar Lake)で、Qwen3.5-9B の生成中のパッケージ電力と温度を、NPU・iGPU・CPU で比べました。
223
- 日本語の長文生成(768 トークン、temperature 0)を各 3 回測った平均です。
224
-
225
- | デバイス | 実行環境 | 生成速度 | パッケージ電力 | パッケージ温度(平均 / 最大) | 1 トークンあたり | 同(アイドル分を引いた値) |
226
- |---|---|---|---|---|---|---|
227
- | **NPU** | onw(Qwen3.5-9B-onw、INT4、5.3 GB) | 7.3 tok/s | **12.0 W** | 52 / **67 ℃** | **1.66 J** | **1.27 J** |
228
- | iGPU(Arc) | llama.cpp SYCL(Q4_K_M、6.2 GB) | **12.6 tok/s** | 23.5 W | 66 / 83 ℃ | 1.91 J | 1.67 J |
229
- | CPU | llama.cpp(Q4_K_M、6.2 GB) | 5.7 tok/s | 22.3 W | 93 / 96 ℃ | 3.99 J | 3.47 J |
230
-
231
- アイドル時のパッケージ電力は約 2.9 W、温度は約 42〜46 ℃ で��。
232
-
233
- 1 万トークンを生成したとき(上の平均値からの計算):
234
-
235
- | デバイス | かかる時間 | 消費エネルギー | 同(アイドル分を引いた値) |
236
- |---|---|---|---|
237
- | **NPU** | 約 23 分 | **4.6 Wh** | 3.5 Wh |
238
- | iGPU | 約 13 分 | 5.3 Wh | 4.6 Wh |
239
- | CPU | 約 30 分 | 11.1 Wh | 9.6 Wh |
240
-
241
- NPU は CPU の約 4 割、iGPU より約 13% 少ないエネルギーで済みます。生成中の電力は 12 W で iGPU の約半分、最大温度は 67 ℃ で、ファンも低回転のまま動かせました(ファンの回転数は測れていないため、目視・体感の観察です)。
242
- 1 万トークンの値は 768 トークンの測定からの計算で、実測ではありません。長く回すと、熱で CPU の電力が下がって遅くなり、時間が延びることがあります。
243
-
244
-
245
- - 1 トークンあたりの電力は、NPU が最も少なく、iGPU より 13〜24% 少なく、CPU の約 4 割以下です。
246
- - 速さは iGPU が最も速く、NPU の約 1.7 倍です。ただし電力は約 2 倍かかります。
247
- - NPU は最大でも 67 ℃ に収まります。iGPU は 83 ℃、CPU は 96 ℃ まで上がりました。
248
-
249
- 測り方と注意:
250
- - 電力は Linux の RAPL(`intel-rapl:0`、package-0)の電力量を 0.5 秒ごとに読んだものです。SoC(CPU・iGPU・NPU)だけの値で、DRAM(約 0.4〜0.8 W)や PC 全体は含みません。温度は `x86_pkg_temp` です。
251
- - 測るのは生成の区間だけで、モデルの読み込みは除いています。毎回、温度が下がるのを待ってからアイドル電力を 30 秒測りました。デバイスの順番による偏りが出ないよう、NPU、iGPU、CPU を 3 周しています。
252
- - ファン回転数は、この PC では CPU クーラーの値を OS から読めないため、測っていません。NPU の生成中、ファンは止まらず低回転のままでした(観察)。
253
- - onw と llama.cpp ではエンジンが違い、重みの量子化も同一ではありません(onw は独自の INT4、llama.cpp は Q4_K_M)。モデルと実行環境をまとめた比較として読んでください。
254
- - ばらつき:NPU と CPU は 3 回でほぼ揃いました(NPU の 1 トークンあたり 1.65〜1.67 J)。iGPU は生成速度が 10.9〜13.5 tok/s と回ごとに差があり、1 トークンあたり 1.84〜2.05 J でした。
255
- - 別の PC、別のプロンプト、別の冷却では値が変わります。
256
-
257
  ## 注意点と制限
258
 
259
  - コンテキスト長の既定は 32K トークンで、モデルの最大(Qwen3.5/3.6 は 262K、Gemma 4 E4B は 128K)まで設定できます。
@@ -261,8 +228,8 @@ NPU は CPU の約 4 割、iGPU より約 13% 少ないエネルギーで済み
261
  (最大 280 トークン)。
262
  - エキスパートはチャネル単位 INT4(四捨五入量子化)です。確認した範囲では、最初のトークンのロジットは bf16 との差が
263
  約 33% で、1 位の予測は一致しました。回答は自然ですが、数トークン先から言い回しが bf16 モデルと分かれます。
264
- - 16 トークンより大きいプロンプトブロック(Gemma の 64 トークン版)は RAM 24 GB 以上のときだけ読み込みます
265
- (`ONW_S64=1/0` で強制)。
266
  - OpenVINO のプレリリース版(2026.5.0b1。OpenVINO の pre-release 索引から入ります)が必要です。NPU 3720(Windows 11)と NPU 4000(Lunar Lake、Ubuntu、
267
  メモリ 16 GB)で全モデルの動作を確認しています。NPU ドライバーがない環境では CPU で動きます。
268
 
 
24
  llama.cpp と GGUF の関係と同じく、エンジンとモデルは別々にダウンロードします。エンジンは一つ、コマンドも一つで、
25
  変換済みのモデルならどれでも動きます。
26
 
 
 
 
27
  ## インストール(コマンドを 1 行貼るだけ)
28
 
29
  Intel Core Ultra(NPU 付き)の PC なら、次の 1 行で入ります(詳しい条件は下の「必要なもの」)。
 
85
  |---|---|---|---|---|---|---|
86
  | [ryugyosoft/gemma-4-E4B-it-onw](https://huggingface.co/ryugyosoft/gemma-4-E4B-it-onw) | `gemma4`(検証済みの Gemma NPU グラフを取り込み) | テキスト+画像 | 切替 | 7.2 tok/s | 4.3 GB | 約 6 GB(画像処理中 8.5 GB) |
87
  | [ryugyosoft/Qwen3.5-9B-onw](https://huggingface.co/ryugyosoft/Qwen3.5-9B-onw) | `qwen3_5`(Gated DeltaNet+ゲート付きアテンション、dense) | テキスト+画像 | 切替 | 4.1 tok/s | 5.3 GB | 約 11 GB |
88
+ | [ryugyosoft/Qwen3.5-9B-nf4-onw](https://huggingface.co/ryugyosoft/Qwen3.5-9B-nf4-onw) | 同上、重みはチャネル単位 NF4、MTP 先読み付き(**Lunar Lake 以降専用**) | テキスト+画像 | 切替 | —(Lunar Lake で約 15 tok/s、通常版の約 2 倍) | 5.6 GB | 通常版の約 85% |
89
  | [ryugyosoft/Qwen3.6-REAP-18B-A3B-onw](https://huggingface.co/ryugyosoft/Qwen3.6-REAP-18B-A3B-onw) | `qwen3_5_moe`、256 個中 128 個のエキスパートを残す(REAP) | テキスト+画像 | 切替 | 6.8 tok/s | 9.6 GB | 約 12 GB |
90
  | [ryugyosoft/Qwen3.6-35B-A3B-onw](https://huggingface.co/ryugyosoft/Qwen3.6-35B-A3B-onw) | `qwen3_5_moe`(Gated DeltaNet+ゲート付きアテンション+MoE 256 個から 8 個+共有エキスパート) | テキスト+画像 | 切替 | 6.8 tok/s | 17 GB | 約 20 GB(SSD から読めば 8〜13 GB) |
91
  | [ryugyosoft/Ornith-1.5-9B-onw](https://huggingface.co/ryugyosoft/Ornith-1.5-9B-onw) | `qwen3_5`(Qwen3.5-9B をエージェント・ツール呼び出し向けに鍛えた Ornith-1.5) | テキスト+画像 | 切替 | 4.9 tok/s | 5.3 GB | 約 11 GB |
92
+ | [ryugyosoft/Ornith-1.5-9B-nf4-onw](https://huggingface.co/ryugyosoft/Ornith-1.5-9B-nf4-onw) | 同上、重みはチャネル単位 NF4、MTP 先読み付き(**Lunar Lake 以降専用**) | テキスト+画像 | 切替 | —(Lunar Lake で約 15 tok/s、通常版の約 2 倍) | 5.6 GB | 通常版の約 85% |
93
  | [ryugyosoft/NeoHorse-1-4B-onw](https://huggingface.co/ryugyosoft/NeoHorse-1-4B-onw) | `qwen3_5`(Qwen3.5-4B をエージェント・ツール呼び出し向けに追加学習した NeoHorse-1) | テキスト | 切替 | 6.4 tok/s | 2.4 GB | 約 6 GB |
94
  | [ryugyosoft/granite-4.2-3b-onw](https://huggingface.co/ryugyosoft/granite-4.2-3b-onw) | `granite`(IBM Granite 4.2:思考とツール呼び出しを強化した 3B。Llama 型) | テキスト | 切替 | 6.7 tok/s(チャット約 7.7) | 2.1 GB | 約 6 GB |
95
  | [ryugyosoft/granite-4.2-8b-onw](https://huggingface.co/ryugyosoft/granite-4.2-8b-onw) | `granite`(同上の 8B) | テキスト | 切替 | 3.6 tok/s(チャット約 3.9) | 5.0 GB | 約 13 GB |
 
101
  | [ryugyosoft/gpt-oss-20b-int8-onw](https://huggingface.co/ryugyosoft/gpt-oss-20b-int8-onw) | 同上、エキスパート INT8(bf16 に近い精度) | テキスト | **常に** | 6 tok/s | 21 GB | 約 21 GB(SSD から読めば 8〜13 GB) |
102
 
103
  **NF4 版(Lunar Lake 以降専用)**:Lunar Lake(NPU 4000)以降の NPU は、NF4(正規分布に合わせた 4 ビット)の重みを
104
+ ハードウェアで計算できます。NF4 版はチャネル単位の NF4 にしたモデルで、Lunar Lake では通常版より生成が約 2 倍(MTP 先読み込み)・
105
+ プロンプト処理が約 3 倍速く、メモリは約 15% 少なくて済みます(精度はほぼ同等)。NPU 3720(Meteor Lake・Arrow Lake)では動かないため、
106
+ onw ウィンドウは Lunar Lake 以降の PC でだけ一覧に出します。
107
+
108
+ **MTP 先読み(onw 1.4.0、Lunar Lake 以降)**:Qwen3.5 系のモデルには、次の次のトークンを予想する小さな層(MTP)が付いています。
109
+ onw はこれで 2 トークン先まで下書きし、本体の 4 トークン用のグラフで一度に確かめます(外れた下書きの分だけ正確に巻き戻します)。
110
+ NPU は数トークンを 1 トークンとほぼ同じ時間で計算できるので、下書きが当たった分だけ速くなります(8 種類の質問の平均で約 1.5 倍)。
111
+ 温度ありの生成でも、1 トークンずつ生成したときと同じ確率でトークンを選びます。NF4 版に入っています。NPU 3720 では確かめるブロックが
112
+ 2 トークンでも 1 トークンの 1.7 倍かかり、かえって遅くなるため使いません。
113
 
114
  使用メモリは、テスト機(Core Ultra 9 285HX、NPU 3720)で、64 トークン処理なし・画像エンコーダーは使ったら解放(メモリ
115
  24 GB 未満の PC の既定)で測ったプロセスのピークです。onw ウィンドウの「メモリ目安」は、この値にその PC の設定
 
177
  | temperature、top_p、top_k、繰り返し抑制(repetition / presence / frequency penalty)、max_tokens、stop、seed、思考モード | リクエストごと(API またはチャット画面) | 不要 |
178
  | ポート、待ち受けアドレス、モデル、デバイス、先読み検証のオン/オフ | サーバーのオプション/設定画面、再起動(約 20 秒) | 不要 |
179
  | **コンテキスト長**(`--context`、既定 32K、モデルの最大まで) | サーバーのオプション/設定画面 | 不要(KV キャッシュの上限が変わるだけ) |
180
+ | 64 トークン処理(Gemma、Qwen3.5 系 NF4 版。auto は Lunar Lake 以降か RAM 24 GB 以上でオン) | `ONW_S64=1/0`/設定画面 | 初回のみ |
181
+ | MTP 先読み(Qwen3.5 系 NF4 版。auto は Lunar Lake 以降でオン) | `ONW_MTP=1/0` | 初回のみ |
182
  | エキスパートのメモリ(Qwen3.6 系。上限を超える分は SSD から読む) | `ONW_EXPERT_GB=5`/設定画面 | 不要 |
183
  | NPUW 重み共有(試験的。auto は Lunar Lake 以降でオン、MoE モデル(LFM2・Qwen3.6)と区間の多いモデル(Granite・llm-jp)は対象外:重みを 1 コピーにしてメモリとコンパイル時間を削減) | `ONW_NPUW=1/0`/設定画面 | 初回のみ |
184
  | メモリ節約モード(1 トークン用と 16 トークン用で重みを共有できない dense モデル:NPU 3720 ではすべて、Lunar Lake 以降は Granite・llm-jp と NPUW オフのとき。普段は 16 トークン用のグラフだけを持ち、回答を書く間だけ 1 トークン用と入れ替える。待機中のメモリ約 4〜6 割減、回答の出だし数秒は遅め、先読み検証なし) | `ONW_LAZY16=1`/設定画面(効くモデルがあるときだけ表示) | 初回のみ |
 
221
  | `setup.*`、`install.*`、`find_python.bat` | インストーラー(Python の検出と winget) |
222
  | `check_ref.py`、`check_vision.py`、`test_*.py`、`probe_segments.py`、`prof_*.py`、`mem_*.py`、`bench_*.py` | 元の HF モデルとの比較、生成・画像・複数ターンの検証、区間ごとの NPU と CPU の比較、速度・メモリ計測 |
223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  ## 注意点と制限
225
 
226
  - コンテキスト長の既定は 32K トークンで、モデルの最大(Qwen3.5/3.6 は 262K、Gemma 4 E4B は 128K)まで設定できます。
 
228
  (最大 280 トークン)。
229
  - エキスパートはチャネル単位 INT4(四捨五入量子化)です。確認した範囲では、最初のトークンのロジットは bf16 との差が
230
  約 33% で、1 位の予測は一致しました。回答は自然ですが、数トークン先から言い回しが bf16 モデルと分かれます。
231
+ - 16 トークンより大きいプロンプトブロック(Gemma と Qwen3.5 系 NF4 版の 64 トークン版)は、Lunar Lake 以降(重みを共有するので
232
+ メモリはほぼ増えません)か RAM 24 GB 以上のときに読み込みます(`ONW_S64=1/0` で強制)。
233
  - OpenVINO のプレリリース版(2026.5.0b1。OpenVINO の pre-release 索引から入ります)が必要です。NPU 3720(Windows 11)と NPU 4000(Lunar Lake、Ubuntu、
234
  メモリ 16 GB)で全モデルの動作を確認しています。NPU ドライバーがない環境では CPU で動きます。
235
 
README_en.md CHANGED
@@ -10,9 +10,6 @@ A small engine that runs LLMs — including MoE and hybrid DeltaNet models, with
10
  Intel NPU**, with an OpenAI-compatible server and a browser chat UI. Engine and models are separate downloads,
11
  like llama.cpp + a GGUF: one engine, one command, any converted model.
12
 
13
- It runs on the low-power NPU, so you can use an LLM quietly, with the fan staying at low speed. Measured on Lunar Lake, it used
14
- less energy per token than the iGPU (llama.cpp) and about 40% of the CPU. The numbers and how they were measured are in [Power efficiency and temperature](#power-efficiency-and-temperature).
15
-
16
  ## Install (paste one line)
17
 
18
  On a PC with an Intel Core Ultra (it has the NPU) this one line installs it (details under "What you need" below).
@@ -75,11 +72,11 @@ troubleshooting), is the **[user guide](MANUAL_en.md)**.
75
  |---|---|---|---|---|---|---|
76
  | [ryugyosoft/gemma-4-E4B-it-onw](https://huggingface.co/ryugyosoft/gemma-4-E4B-it-onw) | `gemma4` (imported from the validated Gemma NPU graphs) | text + image | switchable | 7.2 tok/s | 4.3 GB | ~6 GB (8.5 GB while reading an image) |
77
  | [ryugyosoft/Qwen3.5-9B-onw](https://huggingface.co/ryugyosoft/Qwen3.5-9B-onw) | `qwen3_5` (Gated DeltaNet + gated attention, dense) | text + image | switchable | 4.1 tok/s | 5.3 GB | ~11 GB |
78
- | [ryugyosoft/Qwen3.5-9B-nf4-onw](https://huggingface.co/ryugyosoft/Qwen3.5-9B-nf4-onw) | the same, channel-wise NF4 weights (**Lunar Lake and newer only**) | text + image | switchable | - (9.4 tok/s on Lunar Lake, ~1.3x the regular build) | 5.2 GB | ~80% of the regular build |
79
  | [ryugyosoft/Qwen3.6-REAP-18B-A3B-onw](https://huggingface.co/ryugyosoft/Qwen3.6-REAP-18B-A3B-onw) | `qwen3_5_moe`, 128 of 256 experts kept (REAP) | text + image | switchable | 6.8 tok/s | 9.6 GB | ~12 GB |
80
  | [ryugyosoft/Qwen3.6-35B-A3B-onw](https://huggingface.co/ryugyosoft/Qwen3.6-35B-A3B-onw) | `qwen3_5_moe` (Gated DeltaNet + gated attention + MoE 256x top-8 + shared expert) | text + image | switchable | 6.8 tok/s | 17 GB | ~20 GB (8-13 GB with experts from the SSD) |
81
  | [ryugyosoft/Ornith-1.5-9B-onw](https://huggingface.co/ryugyosoft/Ornith-1.5-9B-onw) | `qwen3_5` (Ornith-1.5: Qwen3.5-9B trained further for agents and tool calls) | text + image | switchable | 4.9 tok/s | 5.3 GB | ~11 GB |
82
- | [ryugyosoft/Ornith-1.5-9B-nf4-onw](https://huggingface.co/ryugyosoft/Ornith-1.5-9B-nf4-onw) | the same, channel-wise NF4 weights (**Lunar Lake and newer only**) | text + image | switchable | - (9.3 tok/s on Lunar Lake, ~1.3x the regular build) | 5.2 GB | ~80% of the regular build |
83
  | [ryugyosoft/NeoHorse-1-4B-onw](https://huggingface.co/ryugyosoft/NeoHorse-1-4B-onw) | `qwen3_5` (NeoHorse-1: Qwen3.5-4B trained further for agents and tool calls) | text | switchable | 6.4 tok/s | 2.4 GB | ~6 GB |
84
  | [ryugyosoft/granite-4.2-3b-onw](https://huggingface.co/ryugyosoft/granite-4.2-3b-onw) | `granite` (IBM Granite 4.2: a 3B with thinking and stronger tool calls; Llama-type) | text | switchable | 6.7 tok/s (~7.7 in chat) | 2.1 GB | ~6 GB |
85
  | [ryugyosoft/granite-4.2-8b-onw](https://huggingface.co/ryugyosoft/granite-4.2-8b-onw) | `granite` (the same, 8B) | text | switchable | 3.6 tok/s (~3.9 in chat) | 5.0 GB | ~13 GB |
@@ -91,9 +88,16 @@ troubleshooting), is the **[user guide](MANUAL_en.md)**.
91
  | [ryugyosoft/gpt-oss-20b-int8-onw](https://huggingface.co/ryugyosoft/gpt-oss-20b-int8-onw) | same with INT8 experts (close to bf16) | text | **always** | 6 tok/s | 21 GB | ~21 GB (8-13 GB with experts from the SSD) |
92
 
93
  **NF4 builds (Lunar Lake and newer only)**: NPUs from Lunar Lake (NPU 4000) on compute NF4 weights (4 bits placed like a
94
- normal distribution) in hardware. The NF4 builds use channel-wise NF4: on Lunar Lake they decode ~30% faster, process
95
- prompts ~2x faster and need ~20% less memory than the regular builds, at about the same accuracy. NPU 3720 (Meteor Lake,
96
- Arrow Lake) cannot run them, so the onw window lists them only on Lunar Lake and newer PCs.
 
 
 
 
 
 
 
97
 
98
  Memory is the process peak on the test machine (Core Ultra 9 285HX, NPU 3720) without 64-token blocks and with the
99
  vision encoder freed after each image (the default below 24 GB of RAM). The "Memory (est.)" column in the onw
@@ -160,7 +164,8 @@ What needs what:
160
  | temperature, top_p, top_k, repetition / presence / frequency penalty, max_tokens, stop, seed, thinking | per request (API or chat UI) | no |
161
  | port, address, model, device, prompt lookup on/off | server option / settings, restart (~20 s) | no |
162
  | **context length** (`--context`, 32K by default, up to the model's maximum) | server option / settings | no (it only bounds the KV cache) |
163
- | 64-token prompt blocks (Gemma) | `ONW_S64=1/0` / settings | once, the first time |
 
164
  | expert memory (Qwen3.6 models; the rest streams from the SSD) | `ONW_EXPERT_GB=5` / settings | no |
165
  | 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 |
166
  | Memory-saving mode (dense models whose 1-token and 16-token graphs cannot share weights: all of them on NPU 3720; from Lunar Lake on Granite / llm-jp, and any with NPUW off. Only the 16-token graphs are kept, swapped for the 1-token ones while an answer is written; 40-60% less memory when idle, the first seconds of an answer slower, no prompt lookup) | `ONW_LAZY16=1` / settings (shown only when a model it helps is there) | once, the first time |
@@ -202,43 +207,6 @@ install it anywhere with `pip install -e . --extra-index-url https://storage.ope
202
  | `setup.*`, `install.*`, `find_python.bat` | installers (Python detection + winget) |
203
  | `check_ref.py`, `check_vision.py`, `test_*.py`, `probe_segments.py`, `prof_*.py`, `mem_*.py`, `bench_*.py` | vs the HF model, generation / image / multi-turn checks, NPU-vs-CPU per segment, timing and memory |
204
 
205
- ## Power efficiency and temperature
206
-
207
- Package power and temperature while generating with Qwen3.5-9B on the same PC (LattePanda Mu Ultra, Core Ultra 5 226V / Lunar Lake), on the NPU, iGPU and CPU.
208
- Average of 3 runs each of a long Japanese generation (768 tokens, temperature 0).
209
-
210
- | Device | Engine | Decode | Package power | Package temp (avg / max) | Energy per token | Same, idle subtracted |
211
- |---|---|---|---|---|---|---|
212
- | **NPU** | onw (Qwen3.5-9B-onw, INT4, 5.3 GB) | 7.3 tok/s | **12.0 W** | 52 / **67 °C** | **1.66 J** | **1.27 J** |
213
- | iGPU (Arc) | llama.cpp SYCL (Q4_K_M, 6.2 GB) | **12.6 tok/s** | 23.5 W | 66 / 83 °C | 1.91 J | 1.67 J |
214
- | CPU | llama.cpp (Q4_K_M, 6.2 GB) | 5.7 tok/s | 22.3 W | 93 / 96 °C | 3.99 J | 3.47 J |
215
-
216
- Idle package power is about 2.9 W at 42-46 °C.
217
-
218
- For 10,000 generated tokens (calculated from the averages above):
219
-
220
- | Device | Time | Energy | Same, idle subtracted |
221
- |---|---|---|---|
222
- | **NPU** | about 23 min | **4.6 Wh** | 3.5 Wh |
223
- | iGPU | about 13 min | 5.3 Wh | 4.6 Wh |
224
- | CPU | about 30 min | 11.1 Wh | 9.6 Wh |
225
-
226
- The NPU needs about 40% of the CPU's energy and about 13% less than the iGPU. It draws 12 W during generation, about half the iGPU, peaks at 67 °C, and ran with the fan at low speed (fan speed was not measured; this is an observation).
227
- The 10,000-token figures are calculated from the 768-token measurements, not measured. Over a longer run the CPU may throttle and take longer.
228
-
229
-
230
- - The NPU uses the least energy per token: 13-24% less than the iGPU, and under 40% of the CPU.
231
- - The iGPU is the fastest, about 1.7x the NPU, at about twice the power.
232
- - The NPU peaks at 67 °C; the iGPU reaches 83 °C and the CPU 96 °C.
233
-
234
- Method and caveats:
235
- - Power is the RAPL package-0 energy counter (`intel-rapl:0`) sampled every 0.5 s. It covers the SoC (CPU, iGPU, NPU) only, not DRAM (about 0.4-0.8 W) or the whole PC. Temperature is `x86_pkg_temp`.
236
- - Only the generation phase is measured, not model loading. Each run starts after a cool-down, with 30 s of idle power measured first. The devices were run NPU, iGPU, CPU in three rounds to avoid order effects.
237
- - Fan speed was not measured: this PC does not expose the CPU cooler's RPM to the OS. During NPU generation the fan kept running at low speed (observation).
238
- - The engines differ (onw vs llama.cpp) and the weights are not the same quantization (onw's own INT4 vs Q4_K_M), so read this as a comparison of model plus runtime.
239
- - Spread: NPU and CPU were consistent across runs (NPU 1.65-1.67 J/token). The iGPU varied from run to run: 10.9-13.5 tok/s and 1.84-2.05 J/token.
240
- - Other machines, prompts and cooling will give different numbers.
241
-
242
  ## Notes and limitations
243
 
244
  - The context is 32K tokens by default and goes up to the model's maximum (262K for Qwen3.5 / 3.6, 128K for
@@ -246,7 +214,8 @@ Method and caveats:
246
  ratio (up to 280 tokens).
247
  - Experts are channel-wise INT4 (round-to-nearest): first-token logits within ~33% of bf16 with the same top-1 on
248
  our checks; answers are fluent, word choices diverge from the bf16 model after a few tokens.
249
- - Prompt blocks above 16 tokens (Gemma's 64-token block) are loaded only with >= 24 GB RAM (`ONW_S64=1/0`).
 
250
  - Needs a pre-release OpenVINO (2026.5.0b1, from OpenVINO's pre-release index). All models are tested on NPU 3720 (Windows 11) and NPU 4000
251
  (Lunar Lake, Ubuntu, 16 GB RAM). Without an NPU driver everything runs on the CPU.
252
 
 
10
  Intel NPU**, with an OpenAI-compatible server and a browser chat UI. Engine and models are separate downloads,
11
  like llama.cpp + a GGUF: one engine, one command, any converted model.
12
 
 
 
 
13
  ## Install (paste one line)
14
 
15
  On a PC with an Intel Core Ultra (it has the NPU) this one line installs it (details under "What you need" below).
 
72
  |---|---|---|---|---|---|---|
73
  | [ryugyosoft/gemma-4-E4B-it-onw](https://huggingface.co/ryugyosoft/gemma-4-E4B-it-onw) | `gemma4` (imported from the validated Gemma NPU graphs) | text + image | switchable | 7.2 tok/s | 4.3 GB | ~6 GB (8.5 GB while reading an image) |
74
  | [ryugyosoft/Qwen3.5-9B-onw](https://huggingface.co/ryugyosoft/Qwen3.5-9B-onw) | `qwen3_5` (Gated DeltaNet + gated attention, dense) | text + image | switchable | 4.1 tok/s | 5.3 GB | ~11 GB |
75
+ | [ryugyosoft/Qwen3.5-9B-nf4-onw](https://huggingface.co/ryugyosoft/Qwen3.5-9B-nf4-onw) | the same, channel-wise NF4 weights, MTP draft tokens (**Lunar Lake and newer only**) | text + image | switchable | - (~15 tok/s on Lunar Lake, ~2x the regular build) | 5.6 GB | ~85% of the regular build |
76
  | [ryugyosoft/Qwen3.6-REAP-18B-A3B-onw](https://huggingface.co/ryugyosoft/Qwen3.6-REAP-18B-A3B-onw) | `qwen3_5_moe`, 128 of 256 experts kept (REAP) | text + image | switchable | 6.8 tok/s | 9.6 GB | ~12 GB |
77
  | [ryugyosoft/Qwen3.6-35B-A3B-onw](https://huggingface.co/ryugyosoft/Qwen3.6-35B-A3B-onw) | `qwen3_5_moe` (Gated DeltaNet + gated attention + MoE 256x top-8 + shared expert) | text + image | switchable | 6.8 tok/s | 17 GB | ~20 GB (8-13 GB with experts from the SSD) |
78
  | [ryugyosoft/Ornith-1.5-9B-onw](https://huggingface.co/ryugyosoft/Ornith-1.5-9B-onw) | `qwen3_5` (Ornith-1.5: Qwen3.5-9B trained further for agents and tool calls) | text + image | switchable | 4.9 tok/s | 5.3 GB | ~11 GB |
79
+ | [ryugyosoft/Ornith-1.5-9B-nf4-onw](https://huggingface.co/ryugyosoft/Ornith-1.5-9B-nf4-onw) | the same, channel-wise NF4 weights, MTP draft tokens (**Lunar Lake and newer only**) | text + image | switchable | - (~15 tok/s on Lunar Lake, ~2x the regular build) | 5.6 GB | ~85% of the regular build |
80
  | [ryugyosoft/NeoHorse-1-4B-onw](https://huggingface.co/ryugyosoft/NeoHorse-1-4B-onw) | `qwen3_5` (NeoHorse-1: Qwen3.5-4B trained further for agents and tool calls) | text | switchable | 6.4 tok/s | 2.4 GB | ~6 GB |
81
  | [ryugyosoft/granite-4.2-3b-onw](https://huggingface.co/ryugyosoft/granite-4.2-3b-onw) | `granite` (IBM Granite 4.2: a 3B with thinking and stronger tool calls; Llama-type) | text | switchable | 6.7 tok/s (~7.7 in chat) | 2.1 GB | ~6 GB |
82
  | [ryugyosoft/granite-4.2-8b-onw](https://huggingface.co/ryugyosoft/granite-4.2-8b-onw) | `granite` (the same, 8B) | text | switchable | 3.6 tok/s (~3.9 in chat) | 5.0 GB | ~13 GB |
 
88
  | [ryugyosoft/gpt-oss-20b-int8-onw](https://huggingface.co/ryugyosoft/gpt-oss-20b-int8-onw) | same with INT8 experts (close to bf16) | text | **always** | 6 tok/s | 21 GB | ~21 GB (8-13 GB with experts from the SSD) |
89
 
90
  **NF4 builds (Lunar Lake and newer only)**: NPUs from Lunar Lake (NPU 4000) on compute NF4 weights (4 bits placed like a
91
+ normal distribution) in hardware. The NF4 builds use channel-wise NF4: on Lunar Lake they decode ~2x faster (with MTP
92
+ draft tokens), process prompts ~3x faster and need ~15% less memory than the regular builds, at about the same accuracy.
93
+ NPU 3720 (Meteor Lake, Arrow Lake) cannot run them, so the onw window lists them only on Lunar Lake and newer PCs.
94
+
95
+ **MTP draft tokens (onw 1.4.0, Lunar Lake and newer)**: Qwen3.5-family models carry a small layer (MTP) that predicts the
96
+ token after next. onw uses it to draft two tokens ahead and checks them in one pass on the model's 4-token graphs
97
+ (cutting back exactly past a rejected draft). The NPU computes a few tokens in about the time of one, so every kept draft
98
+ is time saved (~1.5x on average over 8 kinds of prompts). With temperature the tokens are still drawn with the same
99
+ probabilities as one at a time. The NF4 builds include it. Not used on NPU 3720, where even a 2-token block costs 1.7
100
+ single steps and MTP came out slower.
101
 
102
  Memory is the process peak on the test machine (Core Ultra 9 285HX, NPU 3720) without 64-token blocks and with the
103
  vision encoder freed after each image (the default below 24 GB of RAM). The "Memory (est.)" column in the onw
 
164
  | temperature, top_p, top_k, repetition / presence / frequency penalty, max_tokens, stop, seed, thinking | per request (API or chat UI) | no |
165
  | port, address, model, device, prompt lookup on/off | server option / settings, restart (~20 s) | no |
166
  | **context length** (`--context`, 32K by default, up to the model's maximum) | server option / settings | no (it only bounds the KV cache) |
167
+ | 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 |
168
+ | MTP draft tokens (the NF4 Qwen3.5-family builds; auto = on from Lunar Lake) | `ONW_MTP=1/0` | once, the first time |
169
  | expert memory (Qwen3.6 models; the rest streams from the SSD) | `ONW_EXPERT_GB=5` / settings | no |
170
  | 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 |
171
  | Memory-saving mode (dense models whose 1-token and 16-token graphs cannot share weights: all of them on NPU 3720; from Lunar Lake on Granite / llm-jp, and any with NPUW off. Only the 16-token graphs are kept, swapped for the 1-token ones while an answer is written; 40-60% less memory when idle, the first seconds of an answer slower, no prompt lookup) | `ONW_LAZY16=1` / settings (shown only when a model it helps is there) | once, the first time |
 
207
  | `setup.*`, `install.*`, `find_python.bat` | installers (Python detection + winget) |
208
  | `check_ref.py`, `check_vision.py`, `test_*.py`, `probe_segments.py`, `prof_*.py`, `mem_*.py`, `bench_*.py` | vs the HF model, generation / image / multi-turn checks, NPU-vs-CPU per segment, timing and memory |
209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  ## Notes and limitations
211
 
212
  - The context is 32K tokens by default and goes up to the model's maximum (262K for Qwen3.5 / 3.6, 128K for
 
214
  ratio (up to 280 tokens).
215
  - Experts are channel-wise INT4 (round-to-nearest): first-token logits within ~33% of bf16 with the same top-1 on
216
  our checks; answers are fluent, word choices diverge from the bf16 model after a few tokens.
217
+ - Prompt blocks above 16 tokens (the 64-token blocks of Gemma and the NF4 Qwen3.5-family builds) are loaded from Lunar Lake
218
+ on (the weights are shared, so memory hardly grows) or with >= 24 GB RAM (`ONW_S64=1/0`).
219
  - Needs a pre-release OpenVINO (2026.5.0b1, from OpenVINO's pre-release index). All models are tested on NPU 3720 (Windows 11) and NPU 4000
220
  (Lunar Lake, Ubuntu, 16 GB RAM). Without an NPU driver everything runs on the CPU.
221
 
TECHNICAL.md CHANGED
@@ -13,6 +13,9 @@ onw をどうやって Intel NPU に載せたかの説明です。使い方は [
13
  - 長い文脈:ページ化した INT8 KV キャッシュ
14
  - onw 0.7 でのメモリ削減
15
  - モデルを小さくする(onw convert --compact / --prune)
 
 
 
16
  - NPU で分かった制約(実測)
17
 
18
  ## NPU で初めて動かした 4 つ
@@ -308,6 +311,52 @@ onw 0.10 では、読み込み時に各区間をアテンションの位置で
308
  残してパープレキシティ +8.9%(75% 残しなら +1.4%。Qwen3.6-REAP-18B-A3B-onw として公開)。エキスパートが 32 個しかない
309
  LFM2 は半分で +35% と落ち方が大きいため、刈り込んでいません。
310
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  ## NPU で分かった制約(実測)
312
 
313
  onw を作る途中で、NPU 3720/4000(OpenVINO 2026.5.0b1)について分かったことです。
@@ -333,3 +382,10 @@ onw を作る途中で、NPU 3720/4000(OpenVINO 2026.5.0b1)について分
333
  - LFM2.5-2.6B は残差の値がとても小さい(RMS 約 0.02)トークンと値の大きい BOS が同じブロックに並び、RMSNorm の二乗平均と
334
  eps(1e-5)が FP16 の正規化数の範囲(6e-5 以上)を下回って、NPU の区間出力が 50〜77% ずれた。固定の倍率では両立しない
335
  ため、各行を自分の最大値で割ってから二乗平均を取る(必ず 1/次元数以上になる)。eps も (√eps/最大値)² として計算(差は 1〜5% に)。
 
 
 
 
 
 
 
 
13
  - 長い文脈:ページ化した INT8 KV キャッシュ
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 で分かった制約(実測)
20
 
21
  ## NPU で初めて動かした 4 つ
 
311
  残してパープレキシティ +8.9%(75% 残しなら +1.4%。Qwen3.6-REAP-18B-A3B-onw として公開)。エキスパートが 32 個しかない
312
  LFM2 は半分で +35% と落ち方が大きいため、刈り込んでいません。
313
 
314
+ ## MTP 先読みと 64 トークン処理(onw 1.4、Lunar Lake)
315
+
316
+ Lunar Lake(NPU 4000、Ornith-1.5-9B NF4)で 1 トークンの生成を測ると、85% が NPU の計算そのもので、16 トークンのブロックは
317
+ 1 トークンとほぼ同じ時間(区間 1 つあたり 7.2 ms と 6.3 ms)でした。NPU は重みを読む時間で決まっていて、計算は余っています。
318
+ そこで、余りを使う 2 つのことをしました。
319
+
320
+ - **64 トークンのプロンプトブロック**:DeltaNet の chunk 計算は 16 トークンのまま(`ir.deltanet_chunks`:64 トークンを 16 ずつ、
321
+ 状態を引き継いで計算。FP16 での累積減衰と三角行列の逆行列が長いブロックで崩れないように)、射影は 64 行まとめて計算します。
322
+ プロンプト処理は約 2 倍。bf16 モデルとの一致率は 16 トークン版と同じでした(教師強制で 69.5% と 71.3%)。
323
+ - **MTP 先読み**:Qwen3.5 系には MTP 層(`mtp.*`:fc で [埋め込み, 最終正規化後の hidden] をまとめ、ゲート付きアテンションの
324
+ デコーダー層 1 つ、mtp.norm、LM ヘッド)が付いています。位置 i の hidden と i+1 のトークンから i+2 を予想します(bf16 で 78% 一致。
325
+ 連結の順を逆にすると 0%)。onw はこれを別のグラフ(INT8、約 0.25 GB、NPUW なし)にして、16 トークンのグラフ 1 つで KV 行の
326
+ 書き込みと下書きを兼ね、2 トークン先まで下書きします。ヘッドは出現頻度の高い 3.2 万語だけ(Wikipedia 日英とコードで数えた
327
+ 上位。ほかの文章の 96% を占める)で、全語彙の 1/8 の読み込み(11.5 → 2.3 ms)。温度ありでは下書き d を確率 p(d) で採り、
328
+ 外れたら d を除いた p から選び直します(下書きが決定的なときの投機的サンプリング。1 トークンずつ生成したのと同じ分布)。
329
+ - **4 トークンの確認グラフと正確な巻き戻し**:確かめるブロックは「x+下書き 2 つ」なので、4 トークン用のグラフで十分です
330
+ (16 トークン用より 1 ブロック約 15% 安い)。4 トークンでは DeltaNet を chunk 形式にできない(NPU 4000 のコンパイラーが
331
+ [1, h, T, T] のテンソルで失敗する)ので、1 トークンの式を 4 回つなぎます。この形だと各行の後の状態もグラフの中にあるので、
332
+ それも出力し(`ssm_rows`)、外れた下書きの後ろだけを捨てます(これまでは DeltaNet の状態を途中まで戻せず、ブロックを丸ごと
333
+ 戻して採ったトークンを次のブロックで計算し直していた)。NPU 4000 のコンパイラーは、最後の行以外の状態を転置したまま
334
+ 出力する(CPU では正しい。グラフ側で先に転置しても打ち消される)ので、読み込み時に各層・各行の並びを、その行数だけのブロックの
335
+ 最終状態とビット単位で照らし合わせて決めます(`_check_rows`。合わなければ従来の丸ごと戻す方式)。
336
+ 8 種類の質問の平均で、MTP なし 10.2 → 16 トークン確認 13.9 → 4 トークン確認 15.1 tok/s(Ornith NF4、greedy)。
337
+ - NPU 3720 では 2 トークンのブロックでも 1 トークンの 1.7 倍(16 トークンは 2.4 倍)かかり、MTP は遅くなった(3.6〜4.7 対
338
+ 4.2〜4.8 tok/s)ので使いません。小さい確認グラフを足すと、NPUW のない 3720 では重みのコピーも 1 つ増えます(約 4 GB)。
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 に
345
+ しました(Gemma、LFM2、Granite、llm-jp、Qwen3.5/3.6、gpt-oss、Ornith で、変更前と同じトークン列を確認)。
346
+
347
+ - 推論の入力をコピーしない(`share_inputs`)。入力の型が違うときだけ変換した配列を渡す。
348
+ - DeltaNet の状態を 2 つのバッファで交互に使う:区間は今の方を読み、新しい状態をもう一方へ直接書く(出力に `set_tensor`)。
349
+ ブロックを戻すときは入れ替えを戻すだけで、25 MB の状態のコピーが要らない。
350
+ - アテンションの出力(KV のブロックが 1 つのとき)を、割り算と並べ替えを 1 回で次の区間の形(f16)へ書く。マスクはブロックごとに
351
+ 1 回だけ作る。greedy でペナルティなしのときは、確率分布を作らずに argmax だけで決める。
352
+
353
+ ## 複数トークンのブロックで、ロジットが行ごと 0 になる(onw 1.4 で修正)
354
+
355
+ 先読み検証・MTP のブロックで、活性の大きい行(数式の `\times` や数字の後など)の最終 RMSNorm の二乗和が NPU の fp16 で溢れ、
356
+ その行のロジットがすべて 0 で返ることがありました(1,400 トークンに 16 回ほど)。温度ありではこれが全語彙の一様分布になり、他の言語の
357
+ 単語などが混ざります。RMSNorm は入力の倍率によらないので、0 の行だけ行の最大値で割ってからヘッドをもう一度実行します
358
+ (正常な行はそのまま)。
359
+
360
  ## NPU で分かった制約(実測)
361
 
362
  onw を作る途中で、NPU 3720/4000(OpenVINO 2026.5.0b1)について分かったことです。
 
382
  - LFM2.5-2.6B は残差の値がとても小さい(RMS 約 0.02)トークンと値の大きい BOS が同じブロックに並び、RMSNorm の二乗平均と
383
  eps(1e-5)が FP16 の正規化数の範囲(6e-5 以上)を下回って、NPU の区間出力が 50〜77% ずれた。固定の倍率では両立しない
384
  ため、各行を自分の最大値で割ってから二乗平均を取る(必ず 1/次元数以上になる)。eps も (√eps/最大値)² として計算(差は 1〜5% に)。
385
+ - NPUW の重み共有(NPUW_WEIGHTS_BANK)は、同じプロセスでコンパイルしたグラフどうしでだけ効く。別々の回にコンパイルして
386
+ キャッシュから読んだグラフは、それぞれ重みを持つ(Ornith 9B NF4:1・16 トークン版を一緒にコンパイル 4.6 GB、16 トークン版まで
387
+ と 64 トークン版を別の回にコンパイル 8.7 GB)。onw はキャッシュのフォルダーにコンパイルした回を記録し、混ざっていたら
388
+ まとめてコンパイルし直す(1.4.0)。ドライバーのメモリは `/proc/meminfo` の Shmem(Unevictable)に出て、プロセスの RSS には出ない。
389
+ - NPU 4000 の Linux ドライバーは、1 つのプロセスで推論リクエスト(コマンドキュー)を約 40 を超えて作ると doorbell の割り当てに
390
+ 失敗し(`Failed to allocate doorbell ID: -16`)、以後は再起動するまでどのプロセスも NPU で推論できなくなった
391
+ (`Failed to create command queue: -74`)。
TECHNICAL_en.md CHANGED
@@ -13,6 +13,9 @@ test machine (Core Ultra 9 285HX, NPU 3720, Windows 11, OpenVINO 2026.5.0b1) unl
13
  - Long contexts: the paged INT8 KV cache
14
  - Memory savings in onw 0.7
15
  - Smaller models (onw convert --compact / --prune)
 
 
 
16
  - What we learned about the NPU (measured)
17
 
18
  ## Four firsts on the NPU
@@ -303,6 +306,54 @@ Same models, engine-side only: 20-60% less memory (test machine, peak).
303
  experts at +8.9% perplexity (+1.4% keeping 75%; published as Qwen3.6-REAP-18B-A3B-onw); LFM2 with only 32 experts
304
  loses +35% at 50%, so it is not pruned.
305
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  ## What we learned about the NPU (measured)
307
 
308
  Found while building onw, on NPU 3720 / 4000 with OpenVINO 2026.5.0b1:
@@ -332,3 +383,11 @@ Found while building onw, on NPU 3720 / 4000 with OpenVINO 2026.5.0b1:
332
  square and eps (1e-5) fell below fp16's normal range (6e-5) and the NPU's segment outputs were off by 50-77%. No fixed
333
  scale fits both, so each row is divided by its own max before the mean square (always >= 1/dim), and eps is applied as
334
  (sqrt(eps) / max)² (now within 1-5%).
 
 
 
 
 
 
 
 
 
13
  - Long contexts: the paged INT8 KV cache
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)
20
 
21
  ## Four firsts on the NPU
 
306
  experts at +8.9% perplexity (+1.4% keeping 75%; published as Qwen3.6-REAP-18B-A3B-onw); LFM2 with only 32 experts
307
  loses +35% at 50%, so it is not pruned.
308
 
309
+ ## MTP draft tokens and 64-token blocks (onw 1.4, Lunar Lake)
310
+
311
+ Measured on Lunar Lake (NPU 4000, Ornith-1.5-9B NF4), 85% of a decoding step is the NPU's own work, and a 16-token block
312
+ takes about as long as one token (7.2 ms against 6.3 ms per part): the NPU waits on reading weights and has compute to
313
+ spare. Two things use it.
314
+
315
+ - **64-token prompt blocks**: the DeltaNet chunk math stays at 16 tokens (`ir.deltanet_chunks`: 64 tokens as four chunks of
316
+ 16 passing the state on, so fp16's cumulative decays and the triangular inverse stay sound), the projections run on all 64
317
+ rows. Prompts ~2x faster, with the same agreement with the bf16 model as the 16-token blocks (69.5% and 71.3% teacher-forced).
318
+ - **MTP draft tokens**: Qwen3.5-family models carry an MTP layer (`mtp.*`: fc over [embedding, final-normed hidden state],
319
+ one gated-attention decoder layer, mtp.norm, the LM head): the hidden state at i and the token at i+1 predict i+2 (78%
320
+ agreement in bf16; 0% with the concatenation the other way round). onw makes it a graph of its own (INT8, ~0.25 GB, no
321
+ NPUW); one 16-token graph both writes its KV rows and drafts, two tokens ahead. Its head scores only the 32K most frequent
322
+ tokens (counted on Japanese / English Wikipedia and code; 96% of other text), an eighth of the LM head's reads (11.5 ->
323
+ 2.3 ms). With temperature a draft d is kept with probability p(d) and otherwise the token is drawn from p without d
324
+ (speculative sampling with a deterministic draft: the same distribution as one token at a time).
325
+ - **4-token checking graphs, cut back exactly**: a checking block is x + 2 drafts, so 4-token graphs do (a block ~15% cheaper
326
+ than on the 16-token ones). At 4 tokens DeltaNet cannot take the chunk form (the NPU 4000 compiler fails on [1, h, T, T]
327
+ tensors), so it is the 1-token step four times - which has every row's state in the graph: those are output too
328
+ (`ssm_rows`) and only the rows past a rejected draft are dropped (before, the recurrent state could not be cut back, so a
329
+ block with a miss was undone and its kept tokens computed again in the next block). The NPU 4000 compiler hands out the
330
+ states of all rows but the last transposed (correct on the CPU; transposing them in the graph first gets cancelled), so at
331
+ load each layer's and row's layout is found by a bit-exact comparison with the final state of a block of just that many
332
+ rows (`_check_rows`; without a match, blocks are undone as before). Averages over 8 kinds of prompts: no MTP 10.2 ->
333
+ 16-token checks 13.9 -> 4-token checks 15.1 tok/s (Ornith NF4, greedy).
334
+ - On NPU 3720 even a 2-token block costs 1.7 single steps (16 tokens: 2.4) and MTP came out slower (3.6-4.7 against
335
+ 4.2-4.8 tok/s): not used there. A small checking graph would also add a weight copy there (no NPUW: ~4 GB).
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
342
+ before checked on Gemma, LFM2, Granite, llm-jp, Qwen3.5 / 3.6, gpt-oss and Ornith):
343
+
344
+ - inference inputs are not copied (`share_inputs`; converted arrays only where an input's type differs);
345
+ - DeltaNet states in two alternating buffers: a segment reads the current one and writes its new state straight into the
346
+ other (`set_tensor` on the output); undoing a block swaps back instead of copying 25 MB of state;
347
+ - the attention output (one KV block) is divided and laid out for the next part (f16) in one pass, masks are built once per
348
+ block, and greedy decoding without penalties takes the argmax without a full distribution.
349
+
350
+ ## Logit rows of all zeros in multi-token blocks (fixed in onw 1.4)
351
+
352
+ In prompt-lookup and MTP checking blocks, the final RMSNorm's sum of squares overflowed fp16 on the NPU for rows with large
353
+ activations (after `\times` or digits in formulas, ...), and the whole row of logits came back as zeros (about 16 times in
354
+ 1,400 tokens). With temperature that is a uniform draw over the vocabulary: words from other languages slipped in. RMSNorm
355
+ does not care about scale, so just those rows run the head again divided by their max (the others are untouched).
356
+
357
  ## What we learned about the NPU (measured)
358
 
359
  Found while building onw, on NPU 3720 / 4000 with OpenVINO 2026.5.0b1:
 
383
  square and eps (1e-5) fell below fp16's normal range (6e-5) and the NPU's segment outputs were off by 50-77%. No fixed
384
  scale fits both, so each row is divided by its own max before the mean square (always >= 1/dim), and eps is applied as
385
  (sqrt(eps) / max)² (now within 1-5%).
386
+ - NPUW's weight sharing (NPUW_WEIGHTS_BANK) works only between graphs compiled in the same process: graphs compiled in
387
+ different runs and read from the cache hold their own copy each (Ornith 9B NF4: 1- and 16-token graphs compiled together
388
+ 4.6 GB, up to 16 tokens and the 64-token graphs from different runs 8.7 GB). onw records the run in each cache folder and
389
+ compiles a mixed set again in one run (1.4.0). The driver's memory shows as Shmem (Unevictable) in `/proc/meminfo`, not in
390
+ the process's RSS.
391
+ - With the NPU 4000 Linux driver, a process creating more than about 40 inference requests (command queues) failed to get a
392
+ doorbell (`Failed to allocate doorbell ID: -16`), and from then until a reboot no process could run inference on the NPU
393
+ (`Failed to create command queue: -74`).
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.3.0"
 
2
 
3
  A small engine that runs LLMs entirely on the Intel NPU: static segment graphs + host-managed state.
4
  """
5
+ __version__ = "1.4.0"
onw/app.py CHANGED
@@ -13,15 +13,15 @@ import json, os, shutil, subprocess, sys, threading, time, urllib.request, webbr
13
  CATALOG = [ # repo, title, takes images, download GB, decode speed (NPU 3720), note (shown through i18n.T)
14
  ("ryugyosoft/gemma-4-E4B-it-onw", "Gemma 4 E4B", True, 4.3, "7 tok/s", "画像も読めます"),
15
  ("ryugyosoft/Qwen3.5-9B-onw", "Qwen3.5-9B", True, 5.3, "4 tok/s", "画像も読めます"),
16
- ("ryugyosoft/Qwen3.5-9B-nf4-onw", "Qwen3.5-9B (NF4)", True, 5.2, "9.4 tok/s",
17
- "Lunar Lake 以降専用の軽量・高速版(重み NF4。通常版よりメモリ約 2 割減・生成約 3 割速い)"),
18
  ("ryugyosoft/Qwen3.6-REAP-18B-A3B-onw", "Qwen3.6-REAP-18B-A3B", True, 9.6, "6.8 tok/s", "35B MoE を半分に刈り込み"),
19
  ("ryugyosoft/Qwen3.6-35B-A3B-onw", "Qwen3.6-35B-A3B", True, 17.1, "6.8 tok/s",
20
  "最大(メモリが足りない PC ではエキスパートを SSD から読みます)"),
21
  ("ryugyosoft/Ornith-1.5-9B-onw", "Ornith-1.5-9B", True, 5.3, "5 tok/s",
22
  "エージェント・ツール呼び出し向け(Qwen3.5-9B ベース)"),
23
- ("ryugyosoft/Ornith-1.5-9B-nf4-onw", "Ornith-1.5-9B (NF4)", True, 5.2, "9.3 tok/s",
24
- "Lunar Lake 以降専用の軽量・高速版(重み NF4。通常版よりメモリ約 2 割減・生成約 3 割速い)"),
25
  ("ryugyosoft/NeoHorse-1-4B-onw", "NeoHorse-1-4B", False, 2.4, "6.4 tok/s",
26
  "エージェント・ツール呼び出し向けの軽量モデル(Qwen3.5-4B ベース)"),
27
  ("ryugyosoft/granite-4.2-3b-onw", "Granite 4.2 3B", False, 2.1, "7.7 tok/s",
@@ -57,12 +57,13 @@ def catalog():
57
  MEMORY = {
58
  "ryugyosoft/gemma-4-E4B-it-onw": (8.4, 1.2, 8),
59
  "ryugyosoft/Qwen3.5-9B-onw": (10.9, 0.0, 16),
60
- "ryugyosoft/Qwen3.5-9B-nf4-onw": (8.3, 0.0, 16), # NF4: 5.1 GB against 6.6 GB on NPU 4000, scaled
 
61
  "ryugyosoft/Qwen3.6-REAP-18B-A3B-onw": (12.3, 0.0, 10),
62
  "ryugyosoft/Qwen3.6-35B-A3B-onw": (19.9, 0.0, 10),
63
  "ryugyosoft/Ornith-1.5-9B-onw": (10.9, 0.0, 16), # the shape of Qwen3.5-9B
64
  # NF4: on NPU 4000 5.1 GB against 6.5 GB for the regular build (system memory after load and an answer), scaled
65
- "ryugyosoft/Ornith-1.5-9B-nf4-onw": (8.6, 0.0, 16),
66
  "ryugyosoft/NeoHorse-1-4B-onw": (6.0, 0.0, 16), # RSS 5.75 GB measured (onw 0.12.1)
67
  "ryugyosoft/granite-4.2-3b-onw": (5.6, 0.0, 80), # 40 layers x 8 kv heads x 128: 80 KB per token
68
  "ryugyosoft/granite-4.2-8b-onw": (13.1, 0.0, 80), # (NPU 3720: one weight copy per block size)
@@ -500,13 +501,16 @@ def model_title(key):
500
  def outdated(repo, p):
501
  """A downloaded catalog model whose files were replaced on the hub since (downloading again into the same folder
502
  fetches just the changed files): the gpt-oss-20b models published with an INT4 embedding table (v0.11.0 era) -
503
- the INT8 one is noticeably more accurate."""
504
- if not repo.startswith("ryugyosoft/gpt-oss-20b") or not downloaded_by_onw(p):
 
 
505
  return False
506
  try:
507
- return "embed" in (json.load(open(os.path.join(p, "engine.json"))).get("tables") or {})
508
  except (OSError, ValueError):
509
  return False
 
510
 
511
 
512
  def downloaded_by_onw(p):
 
13
  CATALOG = [ # repo, title, takes images, download GB, decode speed (NPU 3720), note (shown through i18n.T)
14
  ("ryugyosoft/gemma-4-E4B-it-onw", "Gemma 4 E4B", True, 4.3, "7 tok/s", "画像も読めます"),
15
  ("ryugyosoft/Qwen3.5-9B-onw", "Qwen3.5-9B", True, 5.3, "4 tok/s", "画像も読めます"),
16
+ ("ryugyosoft/Qwen3.5-9B-nf4-onw", "Qwen3.5-9B (NF4)", True, 5.6, "15 tok/s",
17
+ "Lunar Lake 以降専用の軽量・高速版(重み NF4・MTP 先読み付き。通常版よりメモリ約 15% 減・生成約 2 倍)"),
18
  ("ryugyosoft/Qwen3.6-REAP-18B-A3B-onw", "Qwen3.6-REAP-18B-A3B", True, 9.6, "6.8 tok/s", "35B MoE を半分に刈り込み"),
19
  ("ryugyosoft/Qwen3.6-35B-A3B-onw", "Qwen3.6-35B-A3B", True, 17.1, "6.8 tok/s",
20
  "最大(メモリが足りない PC ではエキスパートを SSD から読みます)"),
21
  ("ryugyosoft/Ornith-1.5-9B-onw", "Ornith-1.5-9B", True, 5.3, "5 tok/s",
22
  "エージェント・ツール呼び出し向け(Qwen3.5-9B ベース)"),
23
+ ("ryugyosoft/Ornith-1.5-9B-nf4-onw", "Ornith-1.5-9B (NF4)", True, 5.6, "15 tok/s",
24
+ "Lunar Lake 以降専用の軽量・高速版(重み NF4・MTP 先読み付き。通常版よりメモリ約 15% 減・生成約 2 倍)"),
25
  ("ryugyosoft/NeoHorse-1-4B-onw", "NeoHorse-1-4B", False, 2.4, "6.4 tok/s",
26
  "エージェント・ツール呼び出し向けの軽量モデル(Qwen3.5-4B ベース)"),
27
  ("ryugyosoft/granite-4.2-3b-onw", "Granite 4.2 3B", False, 2.1, "7.7 tok/s",
 
57
  MEMORY = {
58
  "ryugyosoft/gemma-4-E4B-it-onw": (8.4, 1.2, 8),
59
  "ryugyosoft/Qwen3.5-9B-onw": (10.9, 0.0, 16),
60
+ # NF4: 5.1 GB against 6.6 GB on NPU 4000, scaled; onw 1.4.0 adds the MTP layer and the 64-token blocks (+0.7 GB)
61
+ "ryugyosoft/Qwen3.5-9B-nf4-onw": (9.0, 0.0, 16),
62
  "ryugyosoft/Qwen3.6-REAP-18B-A3B-onw": (12.3, 0.0, 10),
63
  "ryugyosoft/Qwen3.6-35B-A3B-onw": (19.9, 0.0, 10),
64
  "ryugyosoft/Ornith-1.5-9B-onw": (10.9, 0.0, 16), # the shape of Qwen3.5-9B
65
  # NF4: on NPU 4000 5.1 GB against 6.5 GB for the regular build (system memory after load and an answer), scaled
66
+ "ryugyosoft/Ornith-1.5-9B-nf4-onw": (9.3, 0.0, 16),
67
  "ryugyosoft/NeoHorse-1-4B-onw": (6.0, 0.0, 16), # RSS 5.75 GB measured (onw 0.12.1)
68
  "ryugyosoft/granite-4.2-3b-onw": (5.6, 0.0, 80), # 40 layers x 8 kv heads x 128: 80 KB per token
69
  "ryugyosoft/granite-4.2-8b-onw": (13.1, 0.0, 80), # (NPU 3720: one weight copy per block size)
 
501
  def outdated(repo, p):
502
  """A downloaded catalog model whose files were replaced on the hub since (downloading again into the same folder
503
  fetches just the changed files): the gpt-oss-20b models published with an INT4 embedding table (v0.11.0 era) -
504
+ the INT8 one is noticeably more accurate; the NF4 Qwen3.5 models published before the MTP layer and the 64-token
505
+ blocks (onw 1.4.0: ~1.3x the answer and 2x the prompt speed on Lunar Lake)."""
506
+ gpt, nf4 = repo.startswith("ryugyosoft/gpt-oss-20b"), repo in NF4_ONLY
507
+ if not (gpt or nf4) or not downloaded_by_onw(p):
508
  return False
509
  try:
510
+ meta = json.load(open(os.path.join(p, "engine.json")))
511
  except (OSError, ValueError):
512
  return False
513
+ return "embed" in (meta.get("tables") or {}) if gpt else "mtp" not in meta
514
 
515
 
516
  def downloaded_by_onw(p):
onw/chat.py CHANGED
@@ -33,8 +33,25 @@ def total_ram_gb():
33
  return None
34
 
35
 
36
- def block_sizes(meta):
37
- """Block sizes to load. Prompt blocks above 16 tokens prefill faster but every size holds its own copy of the
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  in-graph weights, so they are used only with >= 24 GB RAM; ONW_S64=1 / 0 forces them on / off."""
39
  sizes = meta["sizes"]
40
  only = os.environ.get("ONW_SIZES") # e.g. "16": decode with the 16-token graphs only
@@ -42,8 +59,23 @@ def block_sizes(meta):
42
  sizes = [S for S in sizes if str(S) in only.split(",")] or sizes
43
  env = os.environ.get("ONW_S64")
44
  ram = total_ram_gb()
45
- big = env == "1" if env in ("0", "1") else (ram is not None and ram >= 24)
46
- return [S for S in sizes if S <= 16 or big]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
 
49
  # Thinking levels ("low" / "medium" / "high", the API's reasoning_effort): harmony models (gpt-oss, llm-jp) were trained
@@ -58,6 +90,7 @@ class ChatEngine:
58
  swap_strict = False
59
  _split_ids = None # harmony tokens to cut decoding at (llm-jp-4), see __init__
60
  _force = () # tokens _plain emits next instead of sampling (see THINK_BUDGET)
 
61
 
62
  def __init__(self, engine_dir, device=None, pld=True, cache_dir=None, context=None):
63
  core = ov.Core()
@@ -65,9 +98,16 @@ class ChatEngine:
65
  cfg = {"CACHE_DIR": cache_dir or os.path.join(engine_dir, "npu_cache")} if device == "NPU" else \
66
  {"INFERENCE_PRECISION_HINT": "f32"}
67
  t0 = time.time()
68
- self.model = SegmentedModel(core, engine_dir, device, cfg,
69
- sizes=block_sizes(json.load(open(os.path.join(engine_dir, "engine.json")))),
70
- context=context)
 
 
 
 
 
 
 
71
  self.tok = AutoTokenizer.from_pretrained(engine_dir)
72
  # thinking: "switch" = the template takes enable_thinking (Qwen); "forced" = its generation prompt always opens
73
  # <think> (LFM2.5), skipped by closing it at once unless thinking is asked for
@@ -390,9 +430,22 @@ class ChatEngine:
390
  k = min(m.S, stop - s)
391
  lg = m.step(ids[s:s + k], None if embeds is None else embeds[s:s + k],
392
  None if pos is None else pos[:, s:s + k], want_logits=(s + k >= stop and need_logits))
 
 
393
  s += k
394
  return lg[-1] if lg is not None else None
395
 
 
 
 
 
 
 
 
 
 
 
 
396
  # ---- token sources ----
397
  @staticmethod
398
  def _sample(lg, s, rng, seen=None, counts=None):
@@ -418,6 +471,115 @@ class ChatEngine:
418
  k = o[:max(1, int(np.searchsorted(np.cumsum(p[o]), s["top_p"]) + 1))]
419
  return int(k[rng.choice(len(k), p=p[k] / p[k].sum())])
420
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
  def _plain(self, lg, s, rng):
422
  seen, counts = set(self.ctx), {}
423
  while True:
@@ -549,8 +711,11 @@ class ChatEngine:
549
  self.model.start_size(1, need_gb=self.swap_gb + 1.0, then_drop=16)
550
  # exact only for plain greedy; not while block sizes are swapped (the verification blocks go away mid-answer)
551
  use_pld = self.pld and plain_greedy and self.model.S > 1 and not swap and budget is None
552
- self.pld_stats = [0, 0]
553
- src = self._pld(lg) if use_pld else self._plain(lg, samp, rng)
 
 
 
554
  out, finish, held = [], "length", 0
555
  sent = {"r": "", "t": ""} # what has gone out: thinking, answer
556
  stop = [x for x in stop if x]
@@ -637,4 +802,5 @@ class ChatEngine:
637
  yield {"finish_reason": finish, "prompt_tokens": n, "cached_tokens": reuse, "completion_tokens": len(out),
638
  "reasoning_tokens": min(len(out), len(self.tok(thought, add_special_tokens=False)["input_ids"])) if thought else 0,
639
  "vision_ms": t_vis * 1000, "prefill_ms": t_pre * 1000, "decode_tok_s": len(out) / max(time.time() - t1, 1e-9),
640
- "pld": {"blocks": self.pld_stats[0], "accepted": self.pld_stats[1]} if use_pld else None}
 
 
33
  return None
34
 
35
 
36
+ def shares_weights(core, device, engine_dir, meta):
37
+ """Every block size can read one copy of the weights: NPUW on NPU 4000+ (Lunar Lake), for dense models (MoE
38
+ models hold their experts once anyway). Whether the sizes picked fit what the driver holds is checked by the
39
+ caller (ChatEngine; runtime.SegmentedModel turns NPUW off past that)."""
40
+ from .runtime import use_npuw
41
+ return device == "NPU" and use_npuw(core) and not os.path.exists(os.path.join(engine_dir, "experts.bin"))
42
+
43
+
44
+ def use_mtp(meta, shared):
45
+ """Draft tokens from the model's MTP layer (Qwen3.5 dense): where a 16-token verification block costs about one
46
+ single step - NPU 4000+ with NPUW (Lunar Lake: ~1.3x the answer speed). On NPU 3720 a block costs 2.4 steps and
47
+ MTP came out slower than without. ONW_MTP=1 / 0 forces it on / off."""
48
+ env = os.environ.get("ONW_MTP")
49
+ return bool(meta.get("mtp")) and (env == "1" if env in ("0", "1") else shared)
50
+
51
+
52
+ def block_sizes(meta, shared=False, mtp=False):
53
+ """Block sizes to load. Prompt blocks above 16 tokens prefill faster (Lunar Lake: 64-token blocks ~2x); with the
54
+ weights shared between the sizes (NPUW) they cost next to nothing, else every size holds its own copy of the
55
  in-graph weights, so they are used only with >= 24 GB RAM; ONW_S64=1 / 0 forces them on / off."""
56
  sizes = meta["sizes"]
57
  only = os.environ.get("ONW_SIZES") # e.g. "16": decode with the 16-token graphs only
 
59
  sizes = [S for S in sizes if str(S) in only.split(",")] or sizes
60
  env = os.environ.get("ONW_S64")
61
  ram = total_ram_gb()
62
+ big = env == "1" if env in ("0", "1") else (shared or (ram is not None and ram >= 24))
63
+ sizes = [S for S in sizes if S <= 16 or big]
64
+ if not only:
65
+ small = [S for S in sizes if S <= 16]
66
+ verify = [S for S in small if S >= 4]
67
+ if shared and mtp and verify:
68
+ # MTP drafts: every answer step is a verification block (x + 2 drafts, + rows to redo) on the smallest
69
+ # graph of >= 4 rows (Lunar Lake: the 4-token one ~15% cheaper than the 16-token one); no 1-token graphs
70
+ # (no single steps are left, and the NPU driver runs out of command queues past ~40 compiled parts:
71
+ # Ornith 9B has 13 per size + 3 for MTP)
72
+ keep = {min(verify)}
73
+ else:
74
+ # otherwise the 1- and 16-token graphs (4 / 8-token ones stay out: without NPUW each size holds its own
75
+ # copy of the weights)
76
+ keep = {S for S in small if S in (1, 16)} or set(small)
77
+ sizes = [S for S in sizes if S > 16 or S in keep]
78
+ return sizes
79
 
80
 
81
  # Thinking levels ("low" / "medium" / "high", the API's reasoning_effort): harmony models (gpt-oss, llm-jp) were trained
 
90
  swap_strict = False
91
  _split_ids = None # harmony tokens to cut decoding at (llm-jp-4), see __init__
92
  _force = () # tokens _plain emits next instead of sampling (see THINK_BUDGET)
93
+ _mtp_pend = None # MTP: the prompt's last row (normalized hidden state, position)
94
 
95
  def __init__(self, engine_dir, device=None, pld=True, cache_dir=None, context=None):
96
  core = ov.Core()
 
98
  cfg = {"CACHE_DIR": cache_dir or os.path.join(engine_dir, "npu_cache")} if device == "NPU" else \
99
  {"INFERENCE_PRECISION_HINT": "f32"}
100
  t0 = time.time()
101
+ meta = json.load(open(os.path.join(engine_dir, "engine.json")))
102
+ shared = shares_weights(core, device, engine_dir, meta)
103
+ mtp = use_mtp(meta, shared)
104
+ sizes = block_sizes(meta, shared, mtp)
105
+ from . import parts
106
+ if shared and parts.estimate(meta, sizes) > parts.MANY: # (NPUW goes off for so many parts: no sharing)
107
+ shared = False
108
+ mtp = use_mtp(meta, shared)
109
+ sizes = block_sizes(meta, shared, mtp)
110
+ self.model = SegmentedModel(core, engine_dir, device, cfg, sizes=sizes, context=context, mtp=mtp)
111
  self.tok = AutoTokenizer.from_pretrained(engine_dir)
112
  # thinking: "switch" = the template takes enable_thinking (Qwen); "forced" = its generation prompt always opens
113
  # <think> (LFM2.5), skipped by closing it at once unless thinking is asked for
 
430
  k = min(m.S, stop - s)
431
  lg = m.step(ids[s:s + k], None if embeds is None else embeds[s:s + k],
432
  None if pos is None else pos[:, s:s + k], want_logits=(s + k >= stop and need_logits))
433
+ if m.mtp is not None:
434
+ self._mtp_prompt(ids, s, k)
435
  s += k
436
  return lg[-1] if lg is not None else None
437
 
438
+ def _mtp_prompt(self, ids, s, k):
439
+ """After a prompt block ids[s:s+k]: the MTP layer's KV rows for its positions (row j: the main model's hidden
440
+ state at j and the token at j + 1). The prompt's last row waits for the first answer token (_mtp_pend)."""
441
+ m = self.model
442
+ hn = m.mtp_norm(m.last_h, 0)
443
+ full = min(k, len(ids) - 1 - s) # rows whose next token is known
444
+ for a in range(0, full, 16):
445
+ b = min(a + 16, full)
446
+ m.mtp_layer(hn[a:b], ids[s + a + 1:s + b + 1], s + a)
447
+ self._mtp_pend = (hn[full:full + 1], s + full) if full < k else None
448
+
449
  # ---- token sources ----
450
  @staticmethod
451
  def _sample(lg, s, rng, seen=None, counts=None):
 
471
  k = o[:max(1, int(np.searchsorted(np.cumsum(p[o]), s["top_p"]) + 1))]
472
  return int(k[rng.choice(len(k), p=p[k] / p[k].sum())])
473
 
474
+ @staticmethod
475
+ def _probs(lg, s, seen=None, counts=None):
476
+ """The distribution _sample draws from, over the whole vocabulary (float64, sums to 1); greedy: one-hot."""
477
+ z = lg.astype(np.float64)
478
+ if s["repetition_penalty"] != 1.0 and seen:
479
+ i = np.fromiter(seen, np.int64)
480
+ z[i] = np.where(z[i] > 0, z[i] / s["repetition_penalty"], z[i] * s["repetition_penalty"])
481
+ if counts and (s["presence_penalty"] or s["frequency_penalty"]):
482
+ i = np.fromiter(counts.keys(), np.int64)
483
+ c = np.fromiter(counts.values(), np.float64)
484
+ z[i] -= s["presence_penalty"] + s["frequency_penalty"] * c
485
+ p = np.zeros(len(z))
486
+ if s["temperature"] <= 0:
487
+ p[int(z.argmax())] = 1.0
488
+ return p
489
+ z /= s["temperature"]
490
+ if 0 < s["top_k"] < len(z):
491
+ cut = np.partition(z, -s["top_k"])[-s["top_k"]]
492
+ z[z < cut] = -np.inf
493
+ q = np.exp(z - z.max())
494
+ q /= q.sum()
495
+ o = np.argsort(-q)
496
+ k = o[:max(1, int(np.searchsorted(np.cumsum(q[o]), s["top_p"]) + 1))]
497
+ p[k] = q[k] / q[k].sum()
498
+ return p
499
+
500
+ def _mtp(self, lg, s, rng):
501
+ """Decoding with draft tokens from the model's MTP layer, verified in one block pass: a draft d is kept with
502
+ probability p(d) under the sampling distribution p of its row (greedy: when it is the argmax), else the row's
503
+ token is drawn from p without d - the same distribution as sampling token by token (speculative sampling with
504
+ a deterministic draft). Rows after a rejection are dropped like _pld does. The MTP layer drafts from the main
505
+ model's hidden state of the last accepted position and the token after it, then from its own output."""
506
+ m, K = self.model, max(1, int(os.environ.get("ONW_MTP_K", "2")))
507
+ # verification blocks: on the smallest loaded graph of >= 4 rows (x + 2 drafts + a row to redo), at most 16
508
+ V = min(min((S for S in m.segs if S >= 4), default=m.S), 16)
509
+ seen, counts = set(self.ctx), {}
510
+
511
+ plain = s["temperature"] <= 0 and s["repetition_penalty"] == 1.0 and not s["presence_penalty"] and \
512
+ not s["frequency_penalty"]
513
+
514
+ def take(row, d=None):
515
+ """The token of a row: (token, the draft was kept)."""
516
+ if plain: # (what the general path below gives for a one-hot p)
517
+ t = int(row.argmax())
518
+ return t, d is not None and t == d
519
+ p = self._probs(row, s, seen, counts)
520
+ if d is not None and (p[d] >= 1.0 or (p[d] > 0 and rng.random() < p[d])):
521
+ return d, True
522
+ if d is not None:
523
+ p[d] = 0.0
524
+ if p.sum() <= 0:
525
+ return d, True
526
+ p /= p.sum()
527
+ return (int(p.argmax()) if s["temperature"] <= 0 else int(rng.choice(len(p), p=p))), False
528
+
529
+ def note(t):
530
+ seen.add(t)
531
+ counts[t] = counts.get(t, 0) + 1
532
+
533
+ pend, self._mtp_pend = self._mtp_pend, None # the prompt's last row: (normalized hidden, position)
534
+ fill_h, fs = (pend[0], pend[1]) if pend is not None else (None, None)
535
+ x = self._force.pop(0) if self._force else take(lg)[0]
536
+ fill_t, redo = [x], []
537
+ while True:
538
+ forced = self._force[:V - 1 - len(redo)]
539
+ del self._force[:len(forced)]
540
+ room = V - 1 - len(redo) - len(forced)
541
+ drafts = []
542
+ if fill_h is not None and not forced and room > 0 and m.cur + V <= m.T:
543
+ z = m.mtp_layer(fill_h, fill_t, fs)[-1]
544
+ pos = fs + len(fill_t)
545
+ for j in range(min(K, room)):
546
+ zn = m.mtp_norm(z, 1)
547
+ drafts.append(m.mtp_draft(zn))
548
+ if j + 1 < min(K, room):
549
+ z = m.mtp_layer(zn[None], drafts[-1:], pos)[0]
550
+ pos += 1
551
+ start = m.cur
552
+ block, o = redo + [x] + forced + drafts, len(redo)
553
+ lgs = m.step(block)
554
+ hn = m.mtp_norm(m.last_h, 0)
555
+ acc = [x] + forced
556
+ note(x)
557
+ for t in forced:
558
+ note(t)
559
+ r = o + len(acc) - 1 # the row deciding the next token
560
+ for d in drafts:
561
+ t, kept = take(lgs[r], d)
562
+ if not kept:
563
+ y = t
564
+ break
565
+ note(d) # (the next row's context; y is noted as the next x)
566
+ acc.append(d)
567
+ r += 1
568
+ else:
569
+ y, _ = take(lgs[r]) if not self._force else (self._force.pop(0), True)
570
+ a = len(acc) - 1 - len(forced) # drafts kept
571
+ if drafts:
572
+ self.mtp_stats[0] += 1
573
+ self.mtp_stats[1] += a
574
+ redo = []
575
+ if len(acc) - 1 < len(forced) + len(drafts) and not m.truncate(o + len(acc)):
576
+ redo = block[:o + len(acc)]
577
+ # the MTP rows for the accepted positions (and the draft of the next block from the last one)
578
+ fill_h, fs, fill_t = hn[o:o + len(acc)], start + o, acc[1:] + [y]
579
+ self.ctx.extend(acc)
580
+ yield from acc
581
+ x = y
582
+
583
  def _plain(self, lg, s, rng):
584
  seen, counts = set(self.ctx), {}
585
  while True:
 
711
  self.model.start_size(1, need_gb=self.swap_gb + 1.0, then_drop=16)
712
  # exact only for plain greedy; not while block sizes are swapped (the verification blocks go away mid-answer)
713
  use_pld = self.pld and plain_greedy and self.model.S > 1 and not swap and budget is None
714
+ # MTP drafts (Qwen3.5 dense): every sampling setting (exact), the thinking budget included
715
+ mtp = getattr(self.model, "mtp", None) is not None and not swap and any(S >= 4 for S in self.model.segs)
716
+ use_pld = use_pld and not mtp
717
+ self.pld_stats, self.mtp_stats = [0, 0], [0, 0]
718
+ src = self._mtp(lg, samp, rng) if mtp else self._pld(lg) if use_pld else self._plain(lg, samp, rng)
719
  out, finish, held = [], "length", 0
720
  sent = {"r": "", "t": ""} # what has gone out: thinking, answer
721
  stop = [x for x in stop if x]
 
802
  yield {"finish_reason": finish, "prompt_tokens": n, "cached_tokens": reuse, "completion_tokens": len(out),
803
  "reasoning_tokens": min(len(out), len(self.tok(thought, add_special_tokens=False)["input_ids"])) if thought else 0,
804
  "vision_ms": t_vis * 1000, "prefill_ms": t_pre * 1000, "decode_tok_s": len(out) / max(time.time() - t1, 1e-9),
805
+ "pld": {"blocks": self.pld_stats[0], "accepted": self.pld_stats[1]} if use_pld else None,
806
+ "mtp": {"blocks": self.mtp_stats[0], "accepted": self.mtp_stats[1]} if mtp else None}
onw/i18n.json CHANGED
@@ -18,7 +18,7 @@
18
  "IBM の軽量モデル。思考とツール呼び出しに対応(日本語はやや弱め)": "IBM's light model, with thinking and tool calls (Japanese a bit weaker)",
19
  "LAN からの接続を許可する": "Allow connections from the LAN",
20
  "Lunar Lake 以降専用の軽量・高速版": "Lighter, faster build for Lunar Lake and newer only",
21
- "Lunar Lake 以降専用の軽量・高速版(重み NF4。通常版よりメモリ約 2 割減・生成約 3 割速い)": "Lighter, faster build for Lunar Lake and newer only (NF4 weights: ~20% less memory, ~30% faster decode than the regular one)",
22
  "MoE モデルでも NPUW 重み共有を使う(実験的)": "Use NPUW weight sharing for MoE models too (experimental)",
23
  "NPU 向けの準備 {0}/{1}(初回だけ数分〜15 分ほどかかります)": "Preparing for the NPU {0}/{1} (the first time only, a few to ~15 minutes)",
24
  "NPUW の非同期実行(FUNCALL_ASYNC、実験的)": "NPUW asynchronous execution (FUNCALL_ASYNC, experimental)",
@@ -30,7 +30,7 @@
30
  "URL をコピーしました": "URL copied",
31
  "auto(Lunar Lake 以降でオン)": "auto (on for Lunar Lake+)",
32
  "auto(OS に合わせる)": "auto (follow the OS)",
33
- "auto(RAM 24 GB 以上でオン)": "auto (on at 24 GB+ RAM)",
34
  "auto(足りないときだけ SSD から)": "auto (SSD when RAM is short)",
35
  "gpt-oss-20b の高精度版": "The higher-precision gpt-oss-20b",
36
  "gpt-oss-20b の高精度版(エキスパート INT8。メモリ 24 GB 以上向け)": "The higher-precision gpt-oss-20b (INT8 experts; for 24 GB+ of RAM)",
@@ -189,6 +189,7 @@
189
  "停止中": "Stopped",
190
  "元に戻す": "Discard",
191
  "先読み検証(PLD)": "Prompt lookup (PLD)",
 
192
  "先読み検証(PLD)で高速化する": "Speed up with prompt lookup (PLD)",
193
  "入れ替え中": "Replacing files",
194
  "再生成": "Regenerate",
@@ -340,7 +341,7 @@
340
  "IBM の軽量モデル。思考とツール呼び出しに対応(日本語はやや弱め)": "IBM 的轻量模型,支持思考和工具调用(日语稍弱)",
341
  "LAN からの接続を許可する": "允许来自局域网的连接",
342
  "Lunar Lake 以降専用の軽量・高速版": "仅限 Lunar Lake 及更新机型的轻量高速版",
343
- "Lunar Lake 以降専用の軽量・高速版(重み NF4。通常版よりメモリ約 2 割減・生成約 3 割速い)": "仅限 Lunar Lake 及更新机型的轻量高速版(NF4 权重:比普通版内存约少 20%、生成约快 30%)",
344
  "MoE モデルでも NPUW 重み共有を使う(実験的)": "MoE 模型也使用 NPUW 权重共享(实验性)",
345
  "NPU 向けの準備 {0}/{1}(初回だけ数分〜15 分ほどかかります)": "正在为 NPU 准备 {0}/{1}(仅首次,需要几分钟到约 15 分钟)",
346
  "NPUW の非同期実行(FUNCALL_ASYNC、実験的)": "NPUW 异步执行(FUNCALL_ASYNC,实验性)",
@@ -352,7 +353,7 @@
352
  "URL をコピーしました": "已复制 URL",
353
  "auto(Lunar Lake 以降でオン)": "auto(Lunar Lake 起开启)",
354
  "auto(OS に合わせる)": "auto(跟随系统)",
355
- "auto(RAM 24 GB 以上でオン)": "auto(内存 24 GB 以上时开启)",
356
  "auto(足りないときだけ SSD から)": "auto(内存不足时用 SSD)",
357
  "gpt-oss-20b の高精度版": "gpt-oss-20b 的高精度版",
358
  "gpt-oss-20b の高精度版(エキスパート INT8。メモリ 24 GB 以上向け)": "gpt-oss-20b 的高精度版(INT8 专家;面向 24 GB 以上内存)",
@@ -511,6 +512,7 @@
511
  "停止中": "已停止",
512
  "元に戻す": "撤销",
513
  "先読み検証(PLD)": "提示查找(PLD)",
 
514
  "先読み検証(PLD)で高速化する": "使用提示查找(PLD)加速",
515
  "入れ替え中": "正在替换文件",
516
  "再生成": "重新生成",
 
18
  "IBM の軽量モデル。思考とツール呼び出しに対応(日本語はやや弱め)": "IBM's light model, with thinking and tool calls (Japanese a bit weaker)",
19
  "LAN からの接続を許可する": "Allow connections from the LAN",
20
  "Lunar Lake 以降専用の軽量・高速版": "Lighter, faster build for Lunar Lake and newer only",
21
+ "Lunar Lake 以降専用の軽量・高速版(重み NF4・MTP 先読み付き。通常版よりメモリ約 15% 減・生成約 2 倍)": "Lighter, faster build for Lunar Lake and newer only (NF4 weights, MTP draft tokens: ~15% less memory, ~2x the decode speed of the regular one)",
22
  "MoE モデルでも NPUW 重み共有を使う(実験的)": "Use NPUW weight sharing for MoE models too (experimental)",
23
  "NPU 向けの準備 {0}/{1}(初回だけ数分〜15 分ほどかかります)": "Preparing for the NPU {0}/{1} (the first time only, a few to ~15 minutes)",
24
  "NPUW の非同期実行(FUNCALL_ASYNC、実験的)": "NPUW asynchronous execution (FUNCALL_ASYNC, experimental)",
 
30
  "URL をコピーしました": "URL copied",
31
  "auto(Lunar Lake 以降でオン)": "auto (on for Lunar Lake+)",
32
  "auto(OS に合わせる)": "auto (follow the OS)",
33
+ "auto(Lunar Lake 以降か RAM 24 GB 以上でオン)": "auto (on with Lunar Lake or later, or 24 GB+ RAM)",
34
  "auto(足りないときだけ SSD から)": "auto (SSD when RAM is short)",
35
  "gpt-oss-20b の高精度版": "The higher-precision gpt-oss-20b",
36
  "gpt-oss-20b の高精度版(エキスパート INT8。メモリ 24 GB 以上向け)": "The higher-precision gpt-oss-20b (INT8 experts; for 24 GB+ of RAM)",
 
189
  "停止中": "Stopped",
190
  "元に戻す": "Discard",
191
  "先読み検証(PLD)": "Prompt lookup (PLD)",
192
+ "MTP 先読み": "MTP draft tokens",
193
  "先読み検証(PLD)で高速化する": "Speed up with prompt lookup (PLD)",
194
  "入れ替え中": "Replacing files",
195
  "再生成": "Regenerate",
 
341
  "IBM の軽量モデル。思考とツール呼び出しに対応(日本語はやや弱め)": "IBM 的轻量模型,支持思考和工具调用(日语稍弱)",
342
  "LAN からの接続を許可する": "允许来自局域网的连接",
343
  "Lunar Lake 以降専用の軽量・高速版": "仅限 Lunar Lake 及更新机型的轻量高速版",
344
+ "Lunar Lake 以降専用の軽量・高速版(重み NF4・MTP 先読み付き。通常版よりメモリ約 15% 減・生成約 2 倍)": "仅限 Lunar Lake 及更新机型的轻量高速版(NF4 权重、MTP 草稿预测:比普通版内存约少 15%、生成约 2 倍)",
345
  "MoE モデルでも NPUW 重み共有を使う(実験的)": "MoE 模型也使用 NPUW 权重共享(实验性)",
346
  "NPU 向けの準備 {0}/{1}(初回だけ数分〜15 分ほどかかります)": "正在为 NPU 准备 {0}/{1}(仅首次,需要几分钟到约 15 分钟)",
347
  "NPUW の非同期実行(FUNCALL_ASYNC、実験的)": "NPUW 异步执行(FUNCALL_ASYNC,实验性)",
 
353
  "URL をコピーしました": "已复制 URL",
354
  "auto(Lunar Lake 以降でオン)": "auto(Lunar Lake 起开启)",
355
  "auto(OS に合わせる)": "auto(跟随系统)",
356
+ "auto(Lunar Lake 以降か RAM 24 GB 以上でオン)": "auto(Lunar Lake 及更新机型或内存 24 GB 以上时开启)",
357
  "auto(足りないときだけ SSD から)": "auto(内存不足时用 SSD)",
358
  "gpt-oss-20b の高精度版": "gpt-oss-20b 的高精度版",
359
  "gpt-oss-20b の高精度版(エキスパート INT8。メモリ 24 GB 以上向け)": "gpt-oss-20b 的高精度版(INT8 专家;面向 24 GB 以上内存)",
 
512
  "停止中": "已停止",
513
  "元に戻す": "撤销",
514
  "先読み検証(PLD)": "提示查找(PLD)",
515
+ "MTP 先読み": "MTP 草稿预测",
516
  "先読み検証(PLD)で高速化する": "使用提示查找(PLD)加速",
517
  "入れ替え中": "正在替换文件",
518
  "再生成": "重新生成",
onw/ir.py CHANGED
@@ -367,6 +367,34 @@ def deltanet_chunk(q, k, v, g, beta, S0, T):
367
  return O, ST
368
 
369
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
  def rope_partial(x, cos, sin, rot):
371
  """Rotate the first `rot` dims of x [1,h,S,D] (rotate_half convention); cos/sin [1,1,S,rot]."""
372
  D = dim(x, 3)
 
367
  return O, ST
368
 
369
 
370
+ def deltanet_chunks(q, k, v, g, beta, S0, T, chunk=16, rows=False):
371
+ """deltanet_chunk over T tokens as consecutive chunks of `chunk` (the state carried from one to the next): blocks
372
+ above 16 tokens keep the numerics of the 16-token chunk (fp16 on the NPU: one long chunk's cumulative decays and
373
+ T x T inverse drift), while the projections around it still run on all T rows at once. rows=True (1 < T < chunk):
374
+ also the state after every row, [T, h, dk, dv] - a verification block can then be cut back exactly."""
375
+ assert not rows or 1 < T < chunk
376
+ if 1 < T < chunk:
377
+ # blocks below one chunk (the 4-token verification graphs): token by token in the 1-token form (the S=1
378
+ # graphs' math). The chunk form at T < 16 fails in the NPU 4000 compiler ([1, h, T, T] tensors: "failed to
379
+ # legalize unresolved materialization"), and padded to a 16-token chunk it costs as much as the 16-token graph
380
+ sl1 = lambda t, a: ops.slice(t, np.array([a]), np.array([a + 1]), np.array([1]), np.array([2]))
381
+ outs, S, Ss = [], S0, []
382
+ for a in range(T):
383
+ o, S = deltanet_step(sl1(q, a), sl1(k, a), sl1(v, a), sl1(g, a), sl1(beta, a), S)
384
+ outs.append(o)
385
+ Ss.append(S)
386
+ return (ops.concat(outs, 2), S, ops.concat(Ss, 0)) if rows else (ops.concat(outs, 2), S)
387
+ if T <= chunk:
388
+ return deltanet_chunk(q, k, v, g, beta, S0, T)
389
+ sl = lambda t, a, b_, ax: ops.slice(t, np.array([a]), np.array([b_]), np.array([1]), np.array([ax]))
390
+ outs, S = [], S0
391
+ for a in range(0, T, chunk):
392
+ b_ = min(a + chunk, T)
393
+ o, S = deltanet_chunk(sl(q, a, b_, 2), sl(k, a, b_, 2), sl(v, a, b_, 2), sl(g, a, b_, 2), sl(beta, a, b_, 2), S, b_ - a)
394
+ outs.append(o)
395
+ return ops.concat(outs, 2), S
396
+
397
+
398
  def rope_partial(x, cos, sin, rot):
399
  """Rotate the first `rot` dims of x [1,h,S,D] (rotate_half convention); cos/sin [1,1,S,rot]."""
400
  D = dim(x, 3)
onw/kvcache.py CHANGED
@@ -96,6 +96,7 @@ class PagedKV:
96
  self.layers = {n: _Layer(s["kv"], s["D"], s.get("Dv", s["D"]), s["scale"], s["G"])
97
  for n, s in layers.items() if self.owner[n] == n}
98
  self.graphs, self._open = {}, {}
 
99
 
100
  # ---- storage ----
101
  def _tensor(self, t, shape):
@@ -183,16 +184,18 @@ class PagedKV:
183
  self.graphs[key] = self.core.compile_model(m, self.device, self.config).create_infer_request()
184
  return self.graphs[key]
185
 
186
- def attend(self, name, q, pos, S, n, sink=None):
187
  """q [kv, G*S, D] (rows g*S + s) for tokens at pos..pos+S-1 (the first n real) -> o [kv, G*S, Dv] f32, over
188
  the stored positions <= each row's own (sliding-window layers are not paged: they keep a ring in the graph).
189
- sink [kv, G]: a learned logit per head that takes part in the softmax without a value (gpt-oss)."""
 
190
  L = self.layers[self.owner[name]]
191
  R = q.shape[1]
192
  rows = pos + (np.arange(R) % S) # each row's position
193
  end = pos + n # stored positions: [0, end)
194
  q = np.ascontiguousarray(q, np.float16)
195
  M = Lsum = O = None
 
196
  for b in L.blocks:
197
  s0, C = b["start"], b["C"]
198
  if s0 >= end:
@@ -201,14 +204,31 @@ class PagedKV:
201
  mask = self._open.get((R, C)) # every slot visible to every row
202
  if mask is None:
203
  mask = self._open[(R, C)] = np.zeros((R, C), np.float16)
204
- else:
205
- j = s0 + np.arange(C)
206
- ok = (j[None, :] <= rows[:, None]) & (j[None, :] < end)
207
- mask = np.where(ok, 0.0, MASKED).astype(np.float16)
 
 
 
 
 
 
208
  req = self._graph(L, R, C)
209
  req.set_tensor("k", b["k"])
210
  req.set_tensor("v", b["v"])
211
- r = req.infer({"q": q, "ks": b["ks"], "vs": b["vs"], "mask": mask})
 
 
 
 
 
 
 
 
 
 
 
212
  m, l, o = (np.asarray(r[x], np.float32) for x in ("m", "l", "o"))
213
  if M is None:
214
  M, Lsum, O = m, l, o
@@ -218,4 +238,8 @@ class PagedKV:
218
  M, Lsum, O = nm, Lsum * a + l * c, O * a + o * c
219
  if sink is not None: # one more softmax term, no value
220
  Lsum = Lsum + np.exp(np.repeat(np.asarray(sink, np.float32), S, 1)[:, :, None] - M)
 
 
 
 
221
  return O / Lsum
 
96
  self.layers = {n: _Layer(s["kv"], s["D"], s.get("Dv", s["D"]), s["scale"], s["G"])
97
  for n, s in layers.items() if self.owner[n] == n}
98
  self.graphs, self._open = {}, {}
99
+ self._masks = {} # (R, C) -> (key, mask): the last partly visible block's mask
100
 
101
  # ---- storage ----
102
  def _tensor(self, t, shape):
 
184
  self.graphs[key] = self.core.compile_model(m, self.device, self.config).create_infer_request()
185
  return self.graphs[key]
186
 
187
+ def attend(self, name, q, pos, S, n, sink=None, rows_out=False):
188
  """q [kv, G*S, D] (rows g*S + s) for tokens at pos..pos+S-1 (the first n real) -> o [kv, G*S, Dv] f32, over
189
  the stored positions <= each row's own (sliding-window layers are not paged: they keep a ring in the graph).
190
+ sink [kv, G]: a learned logit per head that takes part in the softmax without a value (gpt-oss).
191
+ rows_out: o as the next segment takes it instead - [1, S, kv * G * Dv] f16 (token-major)."""
192
  L = self.layers[self.owner[name]]
193
  R = q.shape[1]
194
  rows = pos + (np.arange(R) % S) # each row's position
195
  end = pos + n # stored positions: [0, end)
196
  q = np.ascontiguousarray(q, np.float16)
197
  M = Lsum = O = None
198
+ used = sum(1 for b in L.blocks if b["start"] < end) # blocks holding visible positions
199
  for b in L.blocks:
200
  s0, C = b["start"], b["C"]
201
  if s0 >= end:
 
204
  mask = self._open.get((R, C)) # every slot visible to every row
205
  if mask is None:
206
  mask = self._open[(R, C)] = np.zeros((R, C), np.float16)
207
+ else: # (the same for every layer of a block: built once)
208
+ key = (s0, pos, S, n)
209
+ hit = self._masks.get((R, C))
210
+ if hit is not None and hit[0] == key:
211
+ mask = hit[1]
212
+ else:
213
+ j = s0 + np.arange(C)
214
+ ok = (j[None, :] <= rows[:, None]) & (j[None, :] < end)
215
+ mask = np.where(ok, 0.0, MASKED).astype(np.float16)
216
+ self._masks[(R, C)] = (key, mask)
217
  req = self._graph(L, R, C)
218
  req.set_tensor("k", b["k"])
219
  req.set_tensor("v", b["v"])
220
+ # (shared, not copied: every input is a contiguous f16 array, as the graph takes them)
221
+ r = req.infer({"q": q, "ks": b["ks"], "vs": b["vs"], "mask": mask}, share_inputs=True)
222
+ if rows_out and sink is None and used == 1:
223
+ # one block, nothing to merge: o / l straight into the token-major f16 layout (computed in f32 and
224
+ # rounded once, the same values as the general path below)
225
+ kvh, G = q.shape[0], R // S
226
+ Dv = r["o"].shape[2]
227
+ out = np.empty((S, kvh, G, Dv), np.float16)
228
+ np.divide(r["o"].reshape(kvh, G, S, Dv).transpose(2, 0, 1, 3),
229
+ np.asarray(r["l"], np.float32).reshape(kvh, G, S, 1).transpose(2, 0, 1, 3),
230
+ out=out, casting="unsafe")
231
+ return out.reshape(1, S, kvh * G * Dv)
232
  m, l, o = (np.asarray(r[x], np.float32) for x in ("m", "l", "o"))
233
  if M is None:
234
  M, Lsum, O = m, l, o
 
238
  M, Lsum, O = nm, Lsum * a + l * c, O * a + o * c
239
  if sink is not None: # one more softmax term, no value
240
  Lsum = Lsum + np.exp(np.repeat(np.asarray(sink, np.float32), S, 1)[:, :, None] - M)
241
+ if rows_out:
242
+ kvh, R, Dv = O.shape
243
+ return (O / Lsum).reshape(kvh, R // S, S, Dv).transpose(2, 0, 1, 3).reshape(1, S, kvh * (R // S) * Dv) \
244
+ .astype(np.float16)
245
  return O / Lsum
onw/models/qwen3_5_draft_vocab.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9c288259be0973c6800bc3ff92054c1f0e1335cf0cbd1b5ecd9f16873d85337f
3
+ size 131200
onw/models/qwen3_5_moe.py CHANGED
@@ -37,11 +37,25 @@ def rotary(cfg):
37
  return int(cfg["head_dim"] * frac), rp.get("rope_theta", cfg.get("rope_theta", 10000.0))
38
 
39
 
40
- def build_segments(cfg, W, S, T, fmt="q4g128", dn_fmt=None, norm_maxscale=False, P=P, attn_fmt=None):
 
 
 
 
 
 
 
 
 
 
 
41
  """dn_fmt: format of the DeltaNet in_proj_qkv / out_proj weights (default fmt); attn_fmt: the full-attention
42
  q/k/v_proj (default fmt; NF4 models keep them INT8); norm_maxscale: residual-stream
43
  RMSNorms with ir.rmsnorm(maxscale=True); P: checkpoint key prefix (text-only "qwen3_5_text" repos use "model.").
 
44
  See export()."""
 
 
45
  H, E, K, I = cfg["hidden_size"], cfg.get("num_experts", 0), cfg.get("num_experts_per_tok", 0), cfg.get("moe_intermediate_size")
46
  nh, nkv, D = cfg["num_attention_heads"], cfg["num_key_value_heads"], cfg["head_dim"]
47
  rot = rotary(cfg)[0]
@@ -99,7 +113,13 @@ def build_segments(cfg, W, S, T, fmt="q4g128", dn_fmt=None, norm_maxscale=False,
99
  beta = ops.transpose(beta, i64([0, 2, 1]))
100
  s0 = ops.parameter([1, vh, dk, dv], ov.Type.f32, name=f"ssm.{l}")
101
  params.append(s0)
102
- o, s1 = ir.deltanet_step(q, k, v, g, beta, s0) if S == 1 else ir.deltanet_chunk(q, k, v, g, beta, s0, S)
 
 
 
 
 
 
103
  outs[f"ssm_new.{l}"] = s1
104
  o = ops.transpose(o, i64([0, 2, 1, 3])) # [1,S,vh,dv]
105
  o = ops.multiply(ir.rmsnorm(o, W[a + "norm.weight"], eps * QSCALE ** 2, prescale=QSCALE / 16), ir.silu(z))
@@ -160,6 +180,29 @@ def build_segments(cfg, W, S, T, fmt="q4g128", dn_fmt=None, norm_maxscale=False,
160
  u = ir.linear(xn, W[m + "up_proj.weight"], fmt)
161
  return ops.add(h, ir.linear(ops.multiply(ir.silu(g), u), W[m + "down_proj.weight"], fmt))
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  result = []
164
  if not E: # dense: a few layers per segment
165
  per = int(os.environ.get("ONW_DENSE_PER", "8"))
@@ -231,7 +274,7 @@ def prefix(W):
231
 
232
 
233
  def export(src, out, sizes=(1, 16), T=1024, fmt="q4g128", graphs_only=False, dn_fmt=None, norm_maxscale=False,
234
- attn_fmt=None):
235
  """dn_fmt="q8g128": Ornith-1.5-35B-A3B (a Qwen3.6-35B-A3B fine-tune) loses its first answer token with every
236
  weight in q4g128 (the MoE input of layer 0 is 22% off the bf16 model's, the DeltaNet out_proj and in_proj_qkv
237
  being most of it); with those two in INT8 it matches the bf16 model to the end of the answer.
@@ -273,4 +316,27 @@ def export(src, out, sizes=(1, 16), T=1024, fmt="q4g128", graphs_only=False, dn_
273
  print("vision tower saved", flush=True)
274
  if not graphs_only:
275
  export_weights(cfg, W, out, pre)
 
 
276
  common.write_engine(out, meta, graphs_only) # (with graphs_only the vision entry is kept)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  return int(cfg["head_dim"] * frac), rp.get("rope_theta", cfg.get("rope_theta", 10000.0))
38
 
39
 
40
+ class _MTPWeights:
41
+ """The checkpoint with the MTP layer (mtp.layers.0.*) under the name of a layer after the last one, so the
42
+ builders below make it like any full-attention layer (its paged KV cache gets that layer number)."""
43
+ def __init__(self, W, P, L):
44
+ self.W, self.src, self.where = W, f"{P}layers.{L}.", W.where
45
+
46
+ def __getitem__(self, k):
47
+ return self.W[k.replace(self.src, "mtp.layers.0.", 1)]
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.").
55
+ mtp=True: the multi-token-prediction layer instead (see export_mtp; draft_vocab: the token ids its head scores).
56
  See export()."""
57
+ if mtp:
58
+ W = _MTPWeights(W, P, cfg["num_hidden_layers"])
59
  H, E, K, I = cfg["hidden_size"], cfg.get("num_experts", 0), cfg.get("num_experts_per_tok", 0), cfg.get("moe_intermediate_size")
60
  nh, nkv, D = cfg["num_attention_heads"], cfg["num_key_value_heads"], cfg["head_dim"]
61
  rot = rotary(cfg)[0]
 
113
  beta = ops.transpose(beta, i64([0, 2, 1]))
114
  s0 = ops.parameter([1, vh, dk, dv], ov.Type.f32, name=f"ssm.{l}")
115
  params.append(s0)
116
+ if S == 1:
117
+ o, s1 = ir.deltanet_step(q, k, v, g, beta, s0)
118
+ elif S < 16: # verification blocks: every row's state too (exact truncation)
119
+ o, s1, srows = ir.deltanet_chunks(q, k, v, g, beta, s0, S, rows=True)
120
+ outs[f"ssm_rows.{l}"] = srows
121
+ else:
122
+ o, s1 = ir.deltanet_chunks(q, k, v, g, beta, s0, S)
123
  outs[f"ssm_new.{l}"] = s1
124
  o = ops.transpose(o, i64([0, 2, 1, 3])) # [1,S,vh,dv]
125
  o = ops.multiply(ir.rmsnorm(o, W[a + "norm.weight"], eps * QSCALE ** 2, prescale=QSCALE / 16), ir.silu(z))
 
180
  u = ir.linear(xn, W[m + "up_proj.weight"], fmt)
181
  return ops.add(h, ir.linear(ops.multiply(ir.silu(g), u), W[m + "down_proj.weight"], fmt))
182
 
183
+ if mtp:
184
+ # draft tokens (Qwen3.5 MTP, as in vLLM's qwen3_next_mtp): for position i, the main model's final-normed
185
+ # hidden state h_i and the embedding of token i+1 -> fc([norm(e), norm(h)]) -> one gated full-attention
186
+ # decoder layer (its own KV cache) -> a hidden state whose mtp.norm feeds the LM head: token i+2. Both inputs
187
+ # come normalized-or-raw from the host: carry.mtp_e = the raw embedding row, carry.mtp_h = h after the final
188
+ # norm (or, chaining drafts, the previous MTP output after mtp.norm); next.mtp_y = before mtp.norm
189
+ params, outs, shared = [], {}, {}
190
+ e = ops.parameter([1, S, H], ov.Type.f32, name="carry.mtp_e")
191
+ hn = ops.parameter([1, S, H], ov.Type.f32, name="carry.mtp_h")
192
+ params += [e, hn]
193
+ x = ops.concat([norm1(e, "mtp.pre_fc_norm_embedding.weight"), norm1(hn, "mtp.pre_fc_norm_hidden.weight")], 2)
194
+ h = ir.linear(x, W["mtp.fc.weight"], fmt)
195
+ x = rnorm(h, f"{P}layers.{L}.input_layernorm.weight")
196
+ h = dense_mlp(L, ops.add(h, attention(L, x, params, outs, shared)))
197
+ outs["next.mtp_y"] = h
198
+ meta = {"index": 0, "consume_moe": None, "router_layer": None, "S": S, "slots": 0, "mtp": True}
199
+ hp = ops.parameter([1, 1, H], ov.Type.f32, name="carry.mtp_hn") # (normalized on the host)
200
+ # the draft head scores only the most frequent tokens (draft_vocab, 32K of 248K: ~96% of what the model
201
+ # writes): 1/8 of the LM head's reads - a draft outside them is just a draft that is not kept
202
+ head = W["lm_head.weight" if "lm_head.weight" in W.where else f"{P}embed_tokens.weight"]
203
+ lg = ir.linear(hp, np.ascontiguousarray(head[np.asarray(draft_vocab)]), "q8")
204
+ return [(ir.finish(outs, params, f"q35_mtp_S{S}"), meta),
205
+ (ir.finish({"logits": lg}, [hp], "q35_mtp_head_S1"), dict(meta, index=1, S=1, head=True))]
206
  result = []
207
  if not E: # dense: a few layers per segment
208
  per = int(os.environ.get("ONW_DENSE_PER", "8"))
 
274
 
275
 
276
  def export(src, out, sizes=(1, 16), T=1024, fmt="q4g128", graphs_only=False, dn_fmt=None, norm_maxscale=False,
277
+ attn_fmt=None, mtp_fmt="q8"):
278
  """dn_fmt="q8g128": Ornith-1.5-35B-A3B (a Qwen3.6-35B-A3B fine-tune) loses its first answer token with every
279
  weight in q4g128 (the MoE input of layer 0 is 22% off the bf16 model's, the DeltaNet out_proj and in_proj_qkv
280
  being most of it); with those two in INT8 it matches the bf16 model to the end of the answer.
 
316
  print("vision tower saved", flush=True)
317
  if not graphs_only:
318
  export_weights(cfg, W, out, pre)
319
+ if mtp_fmt and cfg.get("mtp_num_hidden_layers") and "mtp.fc.weight" in W.where and not cfg.get("num_experts"):
320
+ meta["mtp"] = export_mtp(cfg, W, out, T, mtp_fmt, norm_maxscale, pre)
321
  common.write_engine(out, meta, graphs_only) # (with graphs_only the vision entry is kept)
322
+
323
+
324
+ def export_mtp(cfg, W, out, T, fmt, norm_maxscale, pre, S=16):
325
+ """The MTP layer for draft tokens (dense models): mtp_S16.xml (one 16-token graph does every MTP call - its cost
326
+ hardly depends on the rows) and mtp_head_S1.xml (an INT8 head over the 32K most frequent tokens, mtp_vocab.npy,
327
+ on one normalized row); mtp_norm.npy: the
328
+ final norm and mtp.norm weights (applied on the host, (1 + w) form). Compiled without NPUW (its own small copy of
329
+ the weights: INT8 ~0.25 GB for 9B) - it stays out of NPUW's part budget. Not in "segments": onw versions without
330
+ MTP do not see it."""
331
+ segs = {}
332
+ vocab = np.load(os.path.join(os.path.dirname(os.path.abspath(__file__)), "qwen3_5_draft_vocab.npy"))
333
+ vocab = vocab[vocab < cfg["vocab_size"]]
334
+ np.save(os.path.join(out, "mtp_vocab.npy"), vocab.astype(np.int32))
335
+ for m, sm in build_segments(cfg, W, S, T, fmt, None, norm_maxscale, pre, None, mtp=True, draft_vocab=vocab):
336
+ name = "mtp_head_S1.xml" if sm.get("head") else f"mtp_S{S}.xml"
337
+ ov.save_model(m, os.path.join(out, name))
338
+ segs[name] = sm
339
+ np.save(os.path.join(out, "mtp_norm.npy"),
340
+ np.stack([W[f"{pre}norm.weight"], W["mtp.norm.weight"]]).astype(np.float32))
341
+ print("MTP layer saved", flush=True)
342
+ return {"segments": segs, "norms": "mtp_norm.npy", "vocab": "mtp_vocab.npy", "fmt": fmt, "eps": cfg["rms_norm_eps"]}
onw/probe.py CHANGED
@@ -17,13 +17,14 @@ def main():
17
  meta = json.load(open(f"{d}/engine.json"))
18
  bins = {} # keeps the .bin buffer alive while compiling
19
  try:
20
- model = read_segment(core, d, name, meta["segments"][name], npuw, bins, T, meta["T"])
 
21
  if os.environ.get("ONW_KV", "paged") != "legacy": # the same graph the parent compiles (host-side rope)
22
  from . import paged
23
  paged.host_rope(model)
24
  if os.environ.get("ONW_RING"): # paged mode: sliding-window caches live in a ring
25
  from .runtime import ring_shapes
26
- model.reshape(ring_shapes(model, int(os.environ["ONW_RING"]), meta["segments"][name]["S"]))
27
  except Exception as e:
28
  print(f"{type(e).__name__}: {e}", flush=True)
29
  sys.exit(2)
 
17
  meta = json.load(open(f"{d}/engine.json"))
18
  bins = {} # keeps the .bin buffer alive while compiling
19
  try:
20
+ segs = {**meta["segments"], **(meta.get("mtp") or {}).get("segments", {})} # (the MTP graphs too)
21
+ model = read_segment(core, d, name, segs[name], npuw, bins, T, meta["T"])
22
  if os.environ.get("ONW_KV", "paged") != "legacy": # the same graph the parent compiles (host-side rope)
23
  from . import paged
24
  paged.host_rope(model)
25
  if os.environ.get("ONW_RING"): # paged mode: sliding-window caches live in a ring
26
  from .runtime import ring_shapes
27
+ model.reshape(ring_shapes(model, int(os.environ["ONW_RING"]), segs[name]["S"]))
28
  except Exception as e:
29
  print(f"{type(e).__name__}: {e}", flush=True)
30
  sys.exit(2)
onw/runtime.py CHANGED
@@ -207,6 +207,49 @@ def blob_path(cache_dir, name, parts):
207
 
208
  USED_BLOBS = set() # onw_blobs files this process imported or wrote (see prune_cache)
209
  USED_NPUW = set() # npuw/<graph>-<key> cache folders this process compiled from
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
 
212
  def npuw_cache_dir(cache, gname, key_parts):
@@ -397,7 +440,7 @@ class _Tried:
397
 
398
 
399
  class SegmentedModel:
400
- def __init__(self, core, d, device="NPU", config=None, sizes=None, context=None):
401
  self.meta = json.load(open(os.path.join(d, "engine.json")))
402
  if self.meta.get("needs") == "nf4" and device == "NPU" and npu_arch(core) < 4000:
403
  raise ValueError("this model's weights are NF4, which only NPU 4 (Lunar Lake) and later can run - this NPU "
@@ -506,30 +549,61 @@ class SegmentedModel:
506
  except ImportError:
507
  rss0 = None
508
  todo = sum(1 for m in self.meta["segments"].values() if m["S"] in self.segs or m["S"] in precompile)
509
- k = 0
510
- for name, m in sorted(self.meta["segments"].items()):
511
- if not (m["S"] in self.segs or m["S"] in precompile): # (a block size not used: not counted either)
512
- continue
513
- # (read by the onw window: the first load compiles for 5-15 minutes, segments take about equally long)
514
- print(f"[progress] segments {k}/{todo}", flush=True)
515
- k += 1
516
- if m["S"] in precompile: # compiled into the blob store and let go at once
517
- reqs = self._build(name, m)
518
- if reqs is None:
519
- precompile.discard(m["S"])
520
- deferred.remove(m["S"])
521
- del reqs
522
- self._trim()
523
- continue
524
- if m["S"] in self.segs and m["S"] not in dropped:
525
- reqs = self._build(name, m)
526
- if reqs is None: # no way to compile this block size here: run without it
527
- print(f"[compile] no {m['S']}-token graphs on this NPU / driver: running without them", flush=True)
528
- self.segs.pop(m["S"])
529
- dropped.add(m["S"])
530
  continue
531
- for req, pm_ in reqs:
532
- self.segs[m["S"]].append((req, pm_, {i.get_any_name() for i in req.model_inputs}))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
533
  tried.save()
534
  print(f"[progress] segments {todo}/{todo}", flush=True)
535
  print(f"[segments] compiled {sum(len(v) for v in self.segs.values())} in {time.time()-t0:.1f}s", flush=True)
@@ -572,6 +646,18 @@ class SegmentedModel:
572
  for S, lst in self.segs.items():
573
  self._bind(S, lst)
574
  self.exact_truncate = not any(k.startswith("ssm.") for k in self.state)
 
 
 
 
 
 
 
 
 
 
 
 
575
  self.S = max(self.segs)
576
  import threading
577
  self._mutex = threading.RLock() # a block size is swapped in only between two blocks
@@ -585,12 +671,128 @@ class SegmentedModel:
585
  self._sink[l] = v.reshape(Lk.kv, Lk.G)
586
  self._last = None
587
  self.reset()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
588
 
589
- def _build(self, name, m):
590
- """Read, split and compile one segment: [(request, part meta)], or None (this block size cannot run here)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
591
  c = self._ctx
592
- core, d, device, config, npuw, tried, built_T = (c[k] for k in ("core", "d", "device", "config", "npuw", "tried",
593
- "built_T"))
 
 
 
 
594
  model = read_segment(core, d, name, m, npuw, c["bins"], built_T if self.paged else self.T, built_T)
595
  if self.streaming and self.predict_k and m["S"] == 1 and m.get("router_layer") is not None:
596
  rw_ = router_weights(model)
@@ -819,6 +1021,7 @@ class SegmentedModel:
819
  default=str)])}
820
  os.makedirs(cfg["CACHE_DIR"], exist_ok=True)
821
  USED_NPUW.add(cfg["CACHE_DIR"])
 
822
  if own and os.path.exists(bp):
823
  try:
824
  req = import_blob(core, bp, device, nocache).create_infer_request()
@@ -854,7 +1057,24 @@ class SegmentedModel:
854
  pass # fall back to compiling here
855
  if r is not True:
856
  raise RuntimeError(r)
 
 
 
857
  cm = core.compile_model(model, device, nocache if own else cfg)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
858
  if own:
859
  os.makedirs(os.path.dirname(bp), exist_ok=True)
860
  import io
@@ -943,7 +1163,7 @@ class SegmentedModel:
943
  """Row lookup rows -> f16 [n, dim] for a host table: INT8 per row, or INT4 with a scale per group."""
944
  spec = self.meta.get("tables", {}).get(t)
945
  if spec:
946
- q, s, g = self.shared[t + "_q4"].data, self.shared[t + "_s4"].data, spec["group"]
947
  D = q.shape[1] * 2
948
 
949
  def rows4(r):
@@ -956,7 +1176,7 @@ class SegmentedModel:
956
  return rows4
957
  if t + "_q" not in self.shared:
958
  return None
959
- q, s = self.shared[t + "_q"].data, self.shared[t + "_s"].data
960
  return lambda r: q[r].astype(np.float16) * s[r, None]
961
 
962
  # ---- state ----
@@ -987,8 +1207,23 @@ class SegmentedModel:
987
  False is returned: the caller must feed those `keep` tokens again."""
988
  last = self._last
989
  assert last is not None and last["prev"] is not None, "truncate() follows a multi-token step"
 
 
 
 
 
 
 
 
 
 
 
990
  if not self.exact_truncate:
991
  self.restore((last["prev"], last["start"], self.delta))
 
 
 
 
992
  return False
993
  for l, cx in last["cx"].items():
994
  st = self.state["conv." + l]
@@ -1053,10 +1288,11 @@ class SegmentedModel:
1053
  return rw
1054
 
1055
  # ---- run ----
1056
- def _block_inputs(self, ids, embeds, pos):
1057
- """Host-built inputs of one block starting at self.cur: (S, n, embeddings, common inputs)."""
 
1058
  n = len(ids)
1059
- S = min((s for s in self.segs if s >= n), default=None)
1060
  assert S is not None and self.cur + n <= self.T, "block too large or context full"
1061
  x = np.zeros((1, S, self.cfg["hidden_size"]), np.float16)
1062
  x[0, :n] = embeds if embeds is not None else self.embed_rows(np.asarray(ids))
@@ -1102,7 +1338,21 @@ class SegmentedModel:
1102
  feed["route.slot_w"] = self._bind_experts(req, m, route[0], route[1], S, n)
1103
  if S == 1 and self.routers and self.predict_k:
1104
  self._predict(m, carry)
1105
- res = {o.get_any_name(): v for o, v in req.infer(feed, share_outputs=True).items()} # no copies: consumed right away
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1106
  cp = (lambda v: v.copy()) if keep else (lambda v: v)
1107
  nxt = {"carry." + k[5:]: cp(v) for k, v in res.items() if k.startswith("next.")}
1108
  if "route.idx" in res:
@@ -1112,10 +1362,8 @@ class SegmentedModel:
1112
  if attn is not None: # paged: store this layer's new rows, run its attention
1113
  if f"new_k.{attn}" in res: # (a layer sharing another's cache only reads it)
1114
  self.kv.write(attn, cur, res[f"new_k.{attn}"][0, :, :n], res[f"new_v.{attn}"][0, :, :n])
1115
- o = self.kv.attend(attn, res[f"q.{attn}"][0], cur, S, n, sink=self._sink.get(attn)) # [kv, G*S, Dv]
1116
- kvh, R, Dv = o.shape
1117
- o = o.reshape(kvh, R // S, S, Dv).transpose(2, 0, 1, 3).reshape(1, S, kvh * (R // S) * Dv)
1118
- nxt[f"carry.attn.{attn}"] = o.astype(np.float16)
1119
  for k, v in res.items():
1120
  if k.startswith(("new_k.", "new_v.")):
1121
  if attn is not None and k[6:] == attn: # the paged layer's rows are in the paged cache
@@ -1130,10 +1378,22 @@ class SegmentedModel:
1130
  self._last["cx"][k[3:]] = v[:, :n].copy()
1131
  st = self.state["conv." + k[3:]]
1132
  st[:] = np.concatenate([st, v[:, :n]], 1)[:, -st.shape[1]:]
 
 
 
1133
  elif k.startswith("ssm_new."):
1134
- self.state["ssm." + k[8:]][:] = v
 
 
 
 
 
 
1135
  elif k == "logits":
1136
  logits = v[0, :n].copy()
 
 
 
1137
  if not np.isfinite(logits).all(): # else argmax picks id 0 (<pad>) forever, silently
1138
  raise FloatingPointError(f"the {S}-token graph returned NaN/inf logits (a numeric problem "
1139
  "on this NPU / driver; try ONW_SIZES=16 or the CPU device)")
@@ -1141,6 +1401,21 @@ class SegmentedModel:
1141
  logits[:, self.banned] = -np.inf
1142
  return nxt, route, logits
1143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1144
  def _predict(self, m, carry):
1145
  """Hand the prediction for this segment's router to a helper thread (the NPU starts at once)."""
1146
  L = m.get("router_layer")
@@ -1172,18 +1447,23 @@ class SegmentedModel:
1172
  with self._mutex:
1173
  return self._prefill_layers(ids, embeds, pos, want_logits)
1174
 
1175
- def _step(self, ids, embeds=None, pos=None, want_logits=True):
1176
  """ids: 1-D token ids (n <= largest S) -> logits [n, V] (f16); appends n tokens to the state.
1177
  embeds [n, H]: use these rows instead of the token embeddings (image features); pos [3, n]: explicit
1178
  MRoPE positions (default: slot + delta on all rows). want_logits=False skips the LM head segment (prompt
1179
- blocks whose logits are not needed) and returns None."""
1180
- S, n, x, common = self._block_inputs(ids, embeds, pos)
 
1181
  self._tok += 1
1182
  # what truncate() needs to undo part of a multi-token (verification) block
1183
- self._last = {"start": self.cur, "cx": {},
1184
- "prev": {k: v.copy() for k, v in self.state.items() if not k.startswith("past_")} if n > 1 else None}
 
 
1185
  carry, route, logits = {"inputs_embeds": x}, None, None
1186
  for i, (req, m, names) in enumerate(self.segs[S]):
 
 
1187
  if m.get("head") and not want_logits:
1188
  continue
1189
  new, route, lg = self._run_segment(req, m, names, carry, common, route, S, n, self.cur)
 
207
 
208
  USED_BLOBS = set() # onw_blobs files this process imported or wrote (see prune_cache)
209
  USED_NPUW = set() # npuw/<graph>-<key> cache folders this process compiled from
210
+ # NPUW's weights bank shares one copy of the weights only between graphs compiled in the same process: cache entries
211
+ # written by different runs (the 64-token graphs switched on later, a few graphs compiled again after an update)
212
+ # each come back with a copy of their own (Lunar Lake, Ornith-1.5-9B NF4: 1+16 compiled together 4.6 GB of driver
213
+ # memory, 1+16 from one run + 64 from another 8.7 GB). Each folder records the run that compiled it (onw_session);
214
+ # a load that finds more than one run, or folders without the record, compiles all of its graphs again in one run.
215
+ NPUW_SESSION = os.urandom(8).hex()
216
+ NPUW_SEEN = {} # npuw folder -> the run that compiled it (None: unknown)
217
+ LIGHT = "light" # the record of a graph without weights to speak of (the LM-head segment): any run will do
218
+
219
+
220
+ def weight_bytes(model):
221
+ """Bytes of an ov.Model's constants (its weights)."""
222
+ return sum(op.get_byte_size() for op in model.get_ordered_ops() if op.get_type_name() == "Constant")
223
+
224
+
225
+ def npuw_check_runs(cache, graphs):
226
+ """Before compiling: the npuw folders of these graphs (seg00_S1, ...; any key) all from one run? If not - folders
227
+ of more than one run or without the record, or a block size with none at all next to sizes that have them (the
228
+ 64-token graphs switched on later) - they are removed, so this load compiles every graph in one run instead of
229
+ holding a second copy of the weights (freed requests do not give theirs back)."""
230
+ nd = os.path.join(cache or "", "npuw")
231
+ if not cache or not os.path.isdir(nd):
232
+ return
233
+ dirs = [os.path.join(nd, f) for f in os.listdir(nd) if f.rsplit("-", 1)[0].split(".p")[0] in graphs]
234
+ runs, by_size = set(), {} # (graphs without weights - LIGHT - do not count)
235
+ for d in dirs:
236
+ try:
237
+ r = open(os.path.join(d, "onw_session")).read().strip()
238
+ except OSError:
239
+ r = None
240
+ if r != LIGHT:
241
+ runs.add(r)
242
+ by_size.setdefault(os.path.basename(d).rsplit("-", 1)[0].split(".p")[0].rsplit("_S", 1)[1], set())
243
+ if not runs:
244
+ return
245
+ sizes = {g.rsplit("_S", 1)[1] for g in graphs}
246
+ if len(runs) == 1 and None not in runs and set(by_size) >= sizes:
247
+ return
248
+ import shutil
249
+ print(f"[npuw] compiled graphs from different runs (each would hold its own copy of the weights): compiling all "
250
+ f"{len(graphs)} segments again in one run", flush=True)
251
+ for d in dirs:
252
+ shutil.rmtree(d, ignore_errors=True)
253
 
254
 
255
  def npuw_cache_dir(cache, gname, key_parts):
 
440
 
441
 
442
  class SegmentedModel:
443
+ def __init__(self, core, d, device="NPU", config=None, sizes=None, context=None, mtp=False):
444
  self.meta = json.load(open(os.path.join(d, "engine.json")))
445
  if self.meta.get("needs") == "nf4" and device == "NPU" and npu_arch(core) < 4000:
446
  raise ValueError("this model's weights are NF4, which only NPU 4 (Lunar Lake) and later can run - this NPU "
 
549
  except ImportError:
550
  rss0 = None
551
  todo = sum(1 for m in self.meta["segments"].values() if m["S"] in self.segs or m["S"] in precompile)
552
+ NPUW_SEEN.clear()
553
+ if npuw and device == "NPU" and os.environ.get("ONW_OWN_BLOBS") != "0":
554
+ npuw_check_runs((config or {}).get("CACHE_DIR"),
555
+ {n[:-4] for n, m in self.meta["segments"].items() if m["S"] in self.segs})
556
+ for attempt in range(2):
557
+ k = 0
558
+ for name, m in sorted(self.meta["segments"].items()):
559
+ if not (m["S"] in self.segs or m["S"] in precompile): # (a block size not used: not counted either)
 
 
 
 
 
 
 
 
 
 
 
 
 
560
  continue
561
+ # (read by the onw window: the first load compiles for 5-15 minutes, segments take about equally long)
562
+ print(f"[progress] segments {k}/{todo}", flush=True)
563
+ k += 1
564
+ if m["S"] in precompile: # compiled into the blob store and let go at once
565
+ reqs = self._build(name, m)
566
+ if reqs is None:
567
+ precompile.discard(m["S"])
568
+ deferred.remove(m["S"])
569
+ del reqs
570
+ self._trim()
571
+ continue
572
+ if m["S"] in self.segs and m["S"] not in dropped:
573
+ reqs = self._build(name, m)
574
+ if reqs is None: # no way to compile this block size here: run without it
575
+ print(f"[compile] no {m['S']}-token graphs on this NPU / driver: running without them", flush=True)
576
+ self.segs.pop(m["S"])
577
+ dropped.add(m["S"])
578
+ continue
579
+ for req, pm_ in reqs:
580
+ self.segs[m["S"]].append((req, pm_, {i.get_any_name() for i in req.model_inputs}))
581
+ runs = set(NPUW_SEEN.values())
582
+ if attempt or not (npuw and NPUW_SEEN and (len(runs) > 1 or None in runs)):
583
+ break
584
+ # graphs of different runs hold one copy of the weights each: compile them all again, in this run
585
+ print(f"[npuw] parts compiled by different runs (a copy of the weights each): compiling the "
586
+ f"{len(NPUW_SEEN)} parts again", flush=True)
587
+ import shutil
588
+ self.segs = {S: [] for S in self.segs}
589
+ self._trim()
590
+ for p_ in NPUW_SEEN:
591
+ shutil.rmtree(p_, ignore_errors=True)
592
+ NPUW_SEEN.clear()
593
+ # the MTP layer (draft tokens, Qwen3.5 dense): compiled without NPUW, before the paged KV cache is set up
594
+ # (its attention layer gets a cache of its own); mtp: wanted (chat.use_mtp)
595
+ self.mtp, self.last_h = None, None
596
+ import weakref
597
+ self._dts = weakref.WeakKeyDictionary() # request -> {input name: numpy dtype} (_run_segment)
598
+ self._dead_note = False # (_head_again has said so once)
599
+ if mtp and self.meta.get("mtp") and any(S >= 4 for S in self.segs): # (verification blocks: chat._mtp)
600
+ # (the NPU 4000 driver runs out of command queues - doorbells - past ~40 parts in one process, and then
601
+ # no process gets the NPU until a reboot: MTP's 3 parts must fit next to the model's)
602
+ from . import parts as _parts
603
+ if npuw and _parts.estimate(self.meta, sorted(self.segs), self.paged) + 3 > _parts.MANY:
604
+ print("[mtp] off: too many compiled parts with these block sizes", flush=True)
605
+ else:
606
+ self.mtp = self._build_mtp(self.meta["mtp"])
607
  tried.save()
608
  print(f"[progress] segments {todo}/{todo}", flush=True)
609
  print(f"[segments] compiled {sum(len(v) for v in self.segs.values())} in {time.time()-t0:.1f}s", flush=True)
 
646
  for S, lst in self.segs.items():
647
  self._bind(S, lst)
648
  self.exact_truncate = not any(k.startswith("ssm.") for k in self.state)
649
+ # DeltaNet states in two buffers each: a segment reads the current one and writes its new state straight into
650
+ # the other (set_tensor, no copy), then the two swap roles; undoing a verification block swaps back instead of
651
+ # copying the states back (Ornith 9B: 25 MB per block). {name: [[buffer, buffer], index of the current one]},
652
+ # the first buffer being the state tensor above. ONW_PINGPONG=0: copies as before
653
+ self._pp = {}
654
+ if os.environ.get("ONW_PINGPONG", "1") != "0":
655
+ hm = host_tensor(core, device)
656
+ for n, t in self._owner.items():
657
+ if n.startswith("ssm."):
658
+ t2 = hm(t.get_element_type(), list(t.get_shape()))
659
+ t2.data[:] = 0
660
+ self._pp[n] = [[t, t2], 0]
661
  self.S = max(self.segs)
662
  import threading
663
  self._mutex = threading.RLock() # a block size is swapped in only between two blocks
 
671
  self._sink[l] = v.reshape(Lk.kv, Lk.G)
672
  self._last = None
673
  self.reset()
674
+ self._rows_t = None # {DeltaNet state: [row given transposed?]} - exact truncation (_check_rows)
675
+ if not self.exact_truncate and any(1 < S < 16 for S in self.segs):
676
+ self._check_rows(min(S for S in self.segs if 1 < S < 16))
677
+
678
+ def _check_rows(self, S):
679
+ """The 4-token graphs give every row's DeltaNet state, so a verification block can be cut back exactly. The
680
+ NPU 4000 compiler hands out the states of the rows before the last with their last two dimensions transposed
681
+ (a layout it keeps between the steps; on the CPU they come as built, and NPUW's default pipeline differs again),
682
+ so each layer's layout per row is found once per load: against the state after a block of just that many rows
683
+ (the final state is always as built). Unless every row of every layer matches one way or the other exactly,
684
+ truncation keeps undoing the whole block (as without these graphs)."""
685
+ ids = list(range(1000, 1000 + 3 * S))
686
+ self.reset()
687
+ self._step(ids[:S], want_logits=False, size=S)
688
+ self._step(ids[S:2 * S], want_logits=False, size=S)
689
+ snap = self.snapshot()
690
+ self._step(ids[2 * S:3 * S], want_logits=False, size=S)
691
+ rows = {k: np.array(v) for k, v in self._last["rows"].items()}
692
+ names = [k for k in self.state if k.startswith("ssm.")]
693
+ if not rows: # (graphs from before onw 1.4.0: no row states)
694
+ self.reset()
695
+ return
696
+ lay = {}
697
+ if rows and all(k in rows for k in names):
698
+ want = {k: np.array(self.state[k])[0] for k in names}
699
+ for n in range(S, 0, -1):
700
+ if n < S:
701
+ self.restore(snap)
702
+ self._step(ids[2 * S:2 * S + n], want_logits=False, size=S)
703
+ want = {k: np.array(self.state[k])[0] for k in names}
704
+ for k in names:
705
+ r = rows[k][n - 1]
706
+ if np.array_equal(r, want[k]):
707
+ lay.setdefault(k, {})[n - 1] = False
708
+ elif np.array_equal(r.transpose(0, 2, 1), want[k]):
709
+ lay.setdefault(k, {})[n - 1] = True
710
+ else:
711
+ lay = None
712
+ break
713
+ if lay is None:
714
+ break
715
+ self.reset()
716
+ if lay:
717
+ self._rows_t = {k: [v[i] for i in range(S)] for k, v in lay.items()}
718
+ t = sum(sum(v) for v in self._rows_t.values())
719
+ print(f"[mtp] exact truncation of {S}-token blocks ({t} of {S * len(names)} row states transposed)", flush=True)
720
+ else:
721
+ print(f"[mtp] the {S}-token graphs' row states do not match: verification blocks are undone on a miss",
722
+ flush=True)
723
 
724
+ def _build_mtp(self, mt):
725
+ """The MTP layer's parts and its head: {"layer": [(request, meta, input names)], "head": ..., "norm": [2, H]
726
+ (final norm, mtp.norm), "eps"}, or None when it does not compile here."""
727
+ out = {"layer": [], "head": None}
728
+ for name, m in sorted(mt["segments"].items()):
729
+ try:
730
+ reqs = self._build(name, m, npuw=False)
731
+ except RuntimeError as e: # (a transient failure: tried again next load)
732
+ print(f"[mtp] {name}: {' '.join(str(e).split())[-200:]}", flush=True)
733
+ reqs = None
734
+ if reqs is None:
735
+ print("[mtp] the MTP layer does not compile on this NPU / driver: no draft tokens", flush=True)
736
+ return None
737
+ lst = [(r, pm, {i.get_any_name() for i in r.model_inputs}) for r, pm in reqs]
738
+ for r, _, names in lst:
739
+ for n in names:
740
+ if n.startswith("shared.") and n[7:] in self.shared:
741
+ r.set_tensor(n, self.shared[n[7:]])
742
+ if m.get("head"):
743
+ out["head"] = lst[0]
744
+ else:
745
+ out["layer"] = lst
746
+ out["norm"] = np.load(os.path.join(self._ctx["d"], mt["norms"])).astype(np.float32)
747
+ out["vocab"] = np.load(os.path.join(self._ctx["d"], mt["vocab"])).astype(np.int64)
748
+ out["eps"] = float(mt["eps"])
749
+ print(f"[mtp] draft layer loaded ({len(out['layer'])} parts)", flush=True)
750
+ return out
751
+
752
+ def mtp_norm(self, h, which):
753
+ """(1 + w) RMSNorm on the host: which 0 = the final norm (the main model's hidden states), 1 = mtp.norm."""
754
+ x = np.asarray(h, np.float32)
755
+ return x / np.sqrt((x * x).mean(-1, keepdims=True) + self.mtp["eps"]) * (1.0 + self.mtp["norm"][which])
756
+
757
+ def mtp_layer(self, hn, toks, start):
758
+ """The MTP layer on n <= 16 rows at positions start.. (row j: the normalized hidden state of position
759
+ start + j and the token at start + j + 1): writes their rows of its KV cache, returns its outputs [n, H]
760
+ (before mtp.norm)."""
761
+ with self._mutex:
762
+ cur = self.cur
763
+ self.cur = start
764
+ try:
765
+ S, n, x, common = self._block_inputs(toks, None, None, S=16)
766
+ finally:
767
+ self.cur = cur
768
+ h = np.zeros_like(x)
769
+ h[0, :n] = hn
770
+ carry = {"carry.mtp_e": x, "carry.mtp_h": h}
771
+ for req, m, names in self.mtp["layer"]:
772
+ new, _, _ = self._run_segment(req, m, names, carry, common, None, S, n, start)
773
+ carry.update(new)
774
+ return np.asarray(carry["carry.mtp_y"][0, :n], np.float32)
775
+
776
+ def mtp_draft(self, hn):
777
+ """The draft token of one MTP output row after mtp.norm: the best of the draft head's tokens."""
778
+ req = self.mtp["head"][0]
779
+ with self._mutex:
780
+ lg = np.array(req.infer({"carry.mtp_hn": np.ascontiguousarray(np.asarray(hn, np.float16).reshape(1, 1, -1))},
781
+ share_inputs=True)["logits"]).reshape(-1)
782
+ if self.banned:
783
+ lg[np.isin(self.mtp["vocab"], self.banned)] = -np.inf
784
+ return int(self.mtp["vocab"][int(lg.argmax())])
785
+
786
+ def _build(self, name, m, npuw=None):
787
+ """Read, split and compile one segment: [(request, part meta)], or None (this block size cannot run here).
788
+ npuw=False: without NPUW whatever the model's setting (the MTP layer)."""
789
  c = self._ctx
790
+ core, d, device, config, npuw_, tried, built_T = (c[k] for k in ("core", "d", "device", "config", "npuw", "tried",
791
+ "built_T"))
792
+ npuw = npuw_ if npuw is None else npuw
793
+ if npuw_ and not npuw: # (the model's config carries the NPUW options)
794
+ config = {k: v for k, v in config.items() if not k.startswith(
795
+ ("NPUW", "NPU_USE_NPUW", "NPU_COMPILATION_MODE_PARAMS", "NPU_COMPILER_", "CACHE_MODE"))}
796
  model = read_segment(core, d, name, m, npuw, c["bins"], built_T if self.paged else self.T, built_T)
797
  if self.streaming and self.predict_k and m["S"] == 1 and m.get("router_layer") is not None:
798
  rw_ = router_weights(model)
 
1021
  default=str)])}
1022
  os.makedirs(cfg["CACHE_DIR"], exist_ok=True)
1023
  USED_NPUW.add(cfg["CACHE_DIR"])
1024
+ npuw_dir = cfg.get("CACHE_DIR") if how.startswith("npuw") and not own else None
1025
  if own and os.path.exists(bp):
1026
  try:
1027
  req = import_blob(core, bp, device, nocache).create_infer_request()
 
1057
  pass # fall back to compiling here
1058
  if r is not True:
1059
  raise RuntimeError(r)
1060
+ if npuw_dir: # a compile of this run, or an entry of which one?
1061
+ empty = not [f for f in os.listdir(npuw_dir) if f != "onw_session"]
1062
+ light = weight_bytes(model) < (1 << 20)
1063
  cm = core.compile_model(model, device, nocache if own else cfg)
1064
+ if npuw_dir:
1065
+ mark = os.path.join(npuw_dir, "onw_session")
1066
+ if light: # (nothing for the weights bank to share)
1067
+ with open(mark, "w") as f:
1068
+ f.write(LIGHT)
1069
+ elif empty:
1070
+ with open(mark, "w") as f:
1071
+ f.write(NPUW_SESSION)
1072
+ NPUW_SEEN[npuw_dir] = NPUW_SESSION
1073
+ else:
1074
+ try:
1075
+ NPUW_SEEN[npuw_dir] = open(mark).read().strip()
1076
+ except OSError:
1077
+ NPUW_SEEN[npuw_dir] = None
1078
  if own:
1079
  os.makedirs(os.path.dirname(bp), exist_ok=True)
1080
  import io
 
1163
  """Row lookup rows -> f16 [n, dim] for a host table: INT8 per row, or INT4 with a scale per group."""
1164
  spec = self.meta.get("tables", {}).get(t)
1165
  if spec:
1166
+ q, s, g = np.asarray(self.shared[t + "_q4"].data), np.asarray(self.shared[t + "_s4"].data), spec["group"]
1167
  D = q.shape[1] * 2
1168
 
1169
  def rows4(r):
 
1176
  return rows4
1177
  if t + "_q" not in self.shared:
1178
  return None
1179
+ q, s = np.asarray(self.shared[t + "_q"].data), np.asarray(self.shared[t + "_s"].data)
1180
  return lambda r: q[r].astype(np.float16) * s[r, None]
1181
 
1182
  # ---- state ----
 
1207
  False is returned: the caller must feed those `keep` tokens again."""
1208
  last = self._last
1209
  assert last is not None and last["prev"] is not None, "truncate() follows a multi-token step"
1210
+ rows = last.get("rows") or {}
1211
+ if not self.exact_truncate and self._rows_t and rows and all(k in rows for k in self._rows_t):
1212
+ # a block from graphs that give every row's recurrent state (4-token verification): cut back exactly
1213
+ for k, t in self._rows_t.items():
1214
+ r = rows[k][keep - 1]
1215
+ self.state[k][:] = (r.transpose(0, 2, 1) if t[keep - 1] else r)[None]
1216
+ for l, cx in last["cx"].items():
1217
+ st = self.state["conv." + l]
1218
+ st[:] = np.concatenate([last["prev"]["conv." + l], cx[:, :keep]], 1)[:, -st.shape[1]:]
1219
+ self.cur = last["start"] + keep
1220
+ return True
1221
  if not self.exact_truncate:
1222
  self.restore((last["prev"], last["start"], self.delta))
1223
+ for k, c in (last.get("pp") or {}).items(): # the states before the block: still in the old buffers
1224
+ e = self._pp[k]
1225
+ e[1] = c
1226
+ self.state[k] = e[0][c].data
1227
  return False
1228
  for l, cx in last["cx"].items():
1229
  st = self.state["conv." + l]
 
1288
  return rw
1289
 
1290
  # ---- run ----
1291
+ def _block_inputs(self, ids, embeds, pos, S=None):
1292
+ """Host-built inputs of one block starting at self.cur: (S, n, embeddings, common inputs). S: this block size
1293
+ (default: the smallest loaded one that holds the block)."""
1294
  n = len(ids)
1295
+ S = S or min((s for s in self.segs if s >= n), default=None)
1296
  assert S is not None and self.cur + n <= self.T, "block too large or context full"
1297
  x = np.zeros((1, S, self.cfg["hidden_size"]), np.float16)
1298
  x[0, :n] = embeds if embeds is not None else self.embed_rows(np.asarray(ids))
 
1338
  feed["route.slot_w"] = self._bind_experts(req, m, route[0], route[1], S, n)
1339
  if S == 1 and self.routers and self.predict_k:
1340
  self._predict(m, carry)
1341
+ # no copies either way: the inputs are read from our arrays (in the dtype each input wants - a converted copy
1342
+ # only when it differs), the outputs are the request's own buffers, consumed right away. Every call of these
1343
+ # requests shares its inputs (a copying call would write into the arrays a sharing call left bound)
1344
+ ent = self._dts.get(req)
1345
+ if ent is None:
1346
+ outs = {o.get_any_name() for o in req.model_outputs}
1347
+ dts = {i.get_any_name(): i.get_element_type().to_dtype() for i in req.model_inputs}
1348
+ ent = self._dts[req] = (dts, [k for k in dts if k in self._pp and "ssm_new." + k[4:] in outs])
1349
+ dts, pp = ent
1350
+ feed = {k: np.ascontiguousarray(v, dts[k]) for k, v in feed.items()}
1351
+ for k in pp: # read the current state buffer, write the other one
1352
+ bufs, c = self._pp[k]
1353
+ req.set_tensor(k, bufs[c])
1354
+ req.set_tensor("ssm_new." + k[4:], bufs[1 - c])
1355
+ res = {o.get_any_name(): v for o, v in req.infer(feed, share_inputs=True, share_outputs=True).items()}
1356
  cp = (lambda v: v.copy()) if keep else (lambda v: v)
1357
  nxt = {"carry." + k[5:]: cp(v) for k, v in res.items() if k.startswith("next.")}
1358
  if "route.idx" in res:
 
1362
  if attn is not None: # paged: store this layer's new rows, run its attention
1363
  if f"new_k.{attn}" in res: # (a layer sharing another's cache only reads it)
1364
  self.kv.write(attn, cur, res[f"new_k.{attn}"][0, :, :n], res[f"new_v.{attn}"][0, :, :n])
1365
+ nxt[f"carry.attn.{attn}"] = self.kv.attend(attn, res[f"q.{attn}"][0], cur, S, n, sink=self._sink.get(attn),
1366
+ rows_out=True) # [1, S, heads * Dv] f16
 
 
1367
  for k, v in res.items():
1368
  if k.startswith(("new_k.", "new_v.")):
1369
  if attn is not None and k[6:] == attn: # the paged layer's rows are in the paged cache
 
1378
  self._last["cx"][k[3:]] = v[:, :n].copy()
1379
  st = self.state["conv." + k[3:]]
1380
  st[:] = np.concatenate([st, v[:, :n]], 1)[:, -st.shape[1]:]
1381
+ elif k.startswith("ssm_rows."): # (the request's own buffer: read by truncate() before the
1382
+ if n > 1 and self._last is not None and self._last["start"] == cur: # next block runs it)
1383
+ self._last["rows"]["ssm." + k[9:]] = v
1384
  elif k.startswith("ssm_new."):
1385
+ name = "ssm." + k[8:]
1386
+ if name in pp: # written into the other buffer: it is the state now
1387
+ e = self._pp[name]
1388
+ e[1] = 1 - e[1]
1389
+ self.state[name] = e[0][e[1]].data
1390
+ else:
1391
+ self.state[name][:] = v
1392
  elif k == "logits":
1393
  logits = v[0, :n].copy()
1394
+ dead = ~logits.any(axis=1)
1395
+ if dead.any() and "carry.h" in feed:
1396
+ logits[dead] = self._head_again(req, feed, n)[dead]
1397
  if not np.isfinite(logits).all(): # else argmax picks id 0 (<pad>) forever, silently
1398
  raise FloatingPointError(f"the {S}-token graph returned NaN/inf logits (a numeric problem "
1399
  "on this NPU / driver; try ONW_SIZES=16 or the CPU device)")
 
1401
  logits[:, self.banned] = -np.inf
1402
  return nxt, route, logits
1403
 
1404
+ def _head_again(self, req, feed, n):
1405
+ """Logits [n, V] for a head segment's rows that came back all zero: on the NPU (seen on 4000 and 3720) the final
1406
+ RMSNorm's sum of squares overflows fp16 on rows with large activations in multi-token blocks (a whole row of
1407
+ zeros - sampled from, a uniform draw over the vocabulary). The norm does not care about scale, so the head runs
1408
+ again on the hidden states divided by their max |h|."""
1409
+ h = np.asarray(feed["carry.h"], np.float32)
1410
+ h = h / np.maximum(np.abs(h).max(-1, keepdims=True), 1e-2)
1411
+ f2 = dict(feed)
1412
+ f2["carry.h"] = np.ascontiguousarray(h, self._dts[req][0]["carry.h"])
1413
+ out = req.infer(f2, share_inputs=True, share_outputs=True)
1414
+ if not self._dead_note:
1415
+ print("[head] logit rows of all zeros (fp16 overflow in the final norm) computed again, scaled", flush=True)
1416
+ self._dead_note = True
1417
+ return np.array(out["logits"][0, :n])
1418
+
1419
  def _predict(self, m, carry):
1420
  """Hand the prediction for this segment's router to a helper thread (the NPU starts at once)."""
1421
  L = m.get("router_layer")
 
1447
  with self._mutex:
1448
  return self._prefill_layers(ids, embeds, pos, want_logits)
1449
 
1450
+ def _step(self, ids, embeds=None, pos=None, want_logits=True, size=None):
1451
  """ids: 1-D token ids (n <= largest S) -> logits [n, V] (f16); appends n tokens to the state.
1452
  embeds [n, H]: use these rows instead of the token embeddings (image features); pos [3, n]: explicit
1453
  MRoPE positions (default: slot + delta on all rows). want_logits=False skips the LM head segment (prompt
1454
+ blocks whose logits are not needed) and returns None. size: this block size (default: the smallest that
1455
+ holds the block)."""
1456
+ S, n, x, common = self._block_inputs(ids, embeds, pos, S=size)
1457
  self._tok += 1
1458
  # what truncate() needs to undo part of a multi-token (verification) block
1459
+ self._last = {"start": self.cur, "cx": {}, "rows": {},
1460
+ "prev": {k: v.copy() for k, v in self.state.items()
1461
+ if not k.startswith("past_") and k not in self._pp} if n > 1 else None,
1462
+ "pp": {k: e[1] for k, e in self._pp.items()} if n > 1 else None}
1463
  carry, route, logits = {"inputs_embeds": x}, None, None
1464
  for i, (req, m, names) in enumerate(self.segs[S]):
1465
+ if m.get("head") and self.mtp is not None and "carry.h" in carry:
1466
+ self.last_h = np.asarray(carry["carry.h"][0, :n], np.float32) # (what the MTP layer drafts from)
1467
  if m.get("head") and not want_logits:
1468
  continue
1469
  new, route, lg = self._run_segment(req, m, names, carry, common, route, S, n, self.cur)
onw/server.py CHANGED
@@ -606,7 +606,8 @@ def main(argv=None):
606
  image_px=v.get("image_px") if v else None,
607
  think=pipe.think_style is not None, max_context=pipe.max_context,
608
  always_thinks=pipe.think_style == "harmony", # (gpt-oss, llm-jp: the thinking counts against max_tokens)
609
- note=f"文脈は約{pipe.max_context}トークン", context=pipe.max_context, pld=pipe.pld)
 
610
  try:
611
  with open(os.path.join(a.engine, "config.json"), encoding="utf8") as f:
612
  arch = json.load(f).get("model_type") or ""
@@ -636,4 +637,6 @@ def main(argv=None):
636
 
637
 
638
  if __name__ == "__main__":
639
- main()
 
 
 
606
  image_px=v.get("image_px") if v else None,
607
  think=pipe.think_style is not None, max_context=pipe.max_context,
608
  always_thinks=pipe.think_style == "harmony", # (gpt-oss, llm-jp: the thinking counts against max_tokens)
609
+ note=f"文脈は約{pipe.max_context}トークン", context=pipe.max_context, pld=pipe.pld,
610
+ mtp=getattr(pipe.model, "mtp", None) is not None)
611
  try:
612
  with open(os.path.join(a.engine, "config.json"), encoding="utf8") as f:
613
  arch = json.load(f).get("model_type") or ""
 
637
 
638
 
639
  if __name__ == "__main__":
640
+ # (python -m onw.server: run the module that onw.compat put its routes on - this one is a second copy of it)
641
+ from onw.server import main as _main
642
+ _main()
onw/web/check.html CHANGED
@@ -117,7 +117,8 @@ fetch("/api/info").then(r => r.json()).then(i => {
117
  INFO = i;
118
  const rows = [[T("モデル名"), i.model], [T("デバイス"), Object.values(i.devices || {}).join(", ")],
119
  [T("コンテキスト長"), T("{0} トークン", i.context || i.max_context)], [T("画像入力"), i.vision ? T("対応") : T("なし")],
120
- [T("先読み検証(PLD)"), i.pld ? T("有効") : T("無効")], [T("思考モード"), i.think ? T("対応") : T("なし")]];
 
121
  $("info").innerHTML = rows.map(([k, v]) => `<tr><td>${k}</td><td>${v}</td></tr>`).join("");
122
  $("base").textContent = location.origin + "/v1"; $("mid").textContent = i.model;
123
  if (i.rep_hint) { $("rephint").textContent = T("{0} 以上推奨(1 では同じ言葉を繰り返すことがあります)", i.rep_hint); $("rephint").style.display = ""; }
 
117
  INFO = i;
118
  const rows = [[T("モデル名"), i.model], [T("デバイス"), Object.values(i.devices || {}).join(", ")],
119
  [T("コンテキスト長"), T("{0} トークン", i.context || i.max_context)], [T("画像入力"), i.vision ? T("対応") : T("なし")],
120
+ [T("先読み検証(PLD)"), i.pld && !i.mtp ? T("有効") : T("無効")], [T("MTP 先読み"), i.mtp ? T("有効") : T("なし")],
121
+ [T("思考モード"), i.think ? T("対応") : T("なし")]];
122
  $("info").innerHTML = rows.map(([k, v]) => `<tr><td>${k}</td><td>${v}</td></tr>`).join("");
123
  $("base").textContent = location.origin + "/v1"; $("mid").textContent = i.model;
124
  if (i.rep_hint) { $("rephint").textContent = T("{0} 以上推奨(1 では同じ言葉を繰り返すことがあります)", i.rep_hint); $("rephint").style.display = ""; }
onw/web/manage.html CHANGED
@@ -240,7 +240,7 @@
240
  </div>
241
  <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>
242
  <span class="desc">Qwen3.6 系の MoE 用。上限を超えるエキスパートは SSD に置いたまま必要なときだけ読みます(動きますが遅くなります)。</span></div>
243
- <div class="opt"><span class="k">64 トークン処理</span><select id="f-big_blocks" style="width:220px"><option value="auto">auto(RAM 24 GB 以上でオン)</option><option value="on">on</option><option value="off">off</option></select>
244
  <span class="desc">長いプロンプトの処理が速くなる代わりに、そのぶんメモリを使います(Gemma)。</span></div>
245
  </div>
246
 
 
240
  </div>
241
  <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>
242
  <span class="desc">Qwen3.6 系の MoE 用。上限を超えるエキスパートは SSD に置いたまま必要なときだけ読みます(動きますが遅くなります)。</span></div>
243
+ <div class="opt"><span class="k">64 トークン処理</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>
244
  <span class="desc">長いプロンプトの処理が速くなる代わりに、そのぶんメモリを使います(Gemma)。</span></div>
245
  </div>
246