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 +23 -0
- CHANGELOG_en.md +26 -0
- MANUAL.md +1 -1
- MANUAL_en.md +1 -1
- README.md +15 -48
- README_en.md +16 -47
- TECHNICAL.md +56 -0
- TECHNICAL_en.md +59 -0
- onw/__init__.py +1 -1
- onw/app.py +13 -9
- onw/chat.py +176 -10
- onw/i18n.json +6 -4
- onw/ir.py +28 -0
- onw/kvcache.py +31 -7
- onw/models/qwen3_5_draft_vocab.npy +3 -0
- onw/models/qwen3_5_moe.py +69 -3
- onw/probe.py +3 -2
- onw/runtime.py +324 -44
- onw/server.py +5 -2
- onw/web/check.html +2 -1
- onw/web/manage.html +1 -1
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 トークン処理 | 長いプロンプトの処理が速くな
|
| 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,
|
| 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 で
|
| 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 で
|
| 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 では通常版より生成が約
|
| 108 |
-
約
|
| 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 トークン版)は
|
| 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 | - (
|
| 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 | - (
|
| 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 ~
|
| 95 |
-
prompts ~
|
| 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 (
|
|
|
|
| 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.
|
|
|
|
| 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.
|
| 17 |
-
"Lunar Lake 以降専用の軽量・高速版(重み NF4。通常版よりメモリ約
|
| 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.
|
| 24 |
-
"Lunar Lake 以降専用の軽量・高速版(重み NF4。通常版よりメモリ約
|
| 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 |
-
|
|
|
|
| 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": (
|
| 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 |
-
|
|
|
|
|
|
|
| 505 |
return False
|
| 506 |
try:
|
| 507 |
-
|
| 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
|
| 37 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 553 |
-
|
|
|
|
|
|
|
|
|
|
| 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。通常版よりメモリ約
|
| 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
|
| 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。通常版よりメモリ約
|
| 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 |
-
|
| 206 |
-
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
req = self._graph(L, R, C)
|
| 209 |
req.set_tensor("k", b["k"])
|
| 210 |
req.set_tensor("v", b["v"])
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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"]),
|
| 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 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 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
|
| 532 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 590 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 591 |
c = self._ctx
|
| 592 |
-
core, d, device, config,
|
| 593 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1116 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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()
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
| 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("
|
|
|
|
| 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:
|
| 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 |
|