ryugyosoft commited on
Commit
2a04c3c
·
verified ·
1 Parent(s): 60559a3

onw 3.0.0: onw NPU kit (Windows and Linux) - weightless graphs, shared weights, compiler fixes; fused graphs on by default from Lunar Lake on, up to 32K positions

Browse files
.gitattributes CHANGED
@@ -45,3 +45,7 @@ images/onw-models.png filter=lfs diff=lfs merge=lfs -text
45
  images/onw-settings-speed-en.png filter=lfs diff=lfs merge=lfs -text
46
  images/onw-settings-speed.png filter=lfs diff=lfs merge=lfs -text
47
  images/onw-server.png filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
45
  images/onw-settings-speed-en.png filter=lfs diff=lfs merge=lfs -text
46
  images/onw-settings-speed.png filter=lfs diff=lfs merge=lfs -text
47
  images/onw-server.png filter=lfs diff=lfs merge=lfs -text
48
+ onw/npukit/openvino_intel_npu_compiler.dll filter=lfs diff=lfs merge=lfs -text
49
+ onw/npukit/openvino_intel_npu_compiler_loader.dll filter=lfs diff=lfs merge=lfs -text
50
+ onw/npukit/openvino_intel_npu_plugin.dll filter=lfs diff=lfs merge=lfs -text
51
+ onw/npukit/openvino_intel_npu_vm_runtime.dll filter=lfs diff=lfs merge=lfs -text
CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@
5
  日付は日本時間です。各版は Hugging Face の `ryugyosoft/onw` にタグ(`v0.4.2` 以降)付きで残っています。
6
  onw ウィンドウは、新しい版があるとこのファイルから変更点を表示します(`## v…` の見出しで区切っています)。
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  ## v2.1.0 (2026-10-18)
9
  - **Lunar Lake 以降専用の NF4 版を 3 つ追加:Granite 4.2 3B/8B、llm-jp-4.1-8B-thinking**(ryugyosoft/granite-4.2-3b-nf4-onw、granite-4.2-8b-nf4-onw、llm-jp-4.1-8b-thinking-nf4-onw)。重みはチャネル単位の NF4 を基本に、誤差の出やすいアテンションの入力(q/k/v_proj)と down_proj だけをチャネル単位の INT8 にしました。
10
  - Panther Lake(Core Ultra X7 358H):Granite 4.2 8B は生成 5.4 → 約 9 tok/s(INT4 版・2.0.3 との比較)、約 900 トークンのプロンプト約 160 tok/s。llm-jp-4.1-8B は生成約 9.9 tok/s・プロンプト約 170 tok/s、Granite 4.2 3B は生成約 14.3 tok/s・プロンプト約 235 tok/s。1 トークンあたりの電力量(パッケージ全体)は 8B で約 1.3 J、3B で約 0.7 J。
 
5
  日付は日本時間です。各版は Hugging Face の `ryugyosoft/onw` にタグ(`v0.4.2` 以降)付きで残っています。
6
  onw ウィンドウは、新しい版があるとこのファイルから変更点を表示します(`## v…` の見出しで区切っています)。
7
 
8
+ ## v3.0.0 (2026-10-19)
9
+ - **onw 独自の NPU キット(Windows・Linux)を同梱:NPU のコンパイラーとプラグインを、Intel の公開ソースから onw 用に作り直したもの**(`onw/npukit`、Apache 2.0、変更点は `onw/npukit/patches`)。onw が入れる OpenVINO 2026.5.0 のときだけ使い、インストール済みの OpenVINO には手を加えません(`ONW_NPUKIT=0` で OpenVINO 自身のものに戻せます)。これにより次のことができるようになりました。
10
+ - **重みを持たないコンパイル済みグラフ**:キャッシュにはグラフだけを置き、重みはロードのたびにモデルのファイルから NPU 上で組み立てます。キャッシュがモデル本体の 1/10 以下になります(Granite 4.2 8B NF4:10.5 → 0.8 GB、llm-jp-4.1-8B NF4:9.2 → 0.65 GB、Granite 4.2 3B NF4:4.2 → 0.5 GB)。OpenVINO 2026.5 のコンパイラーでは NF4 の重みでこれができませんでした。
11
+ - **同じ重みを NPU 上で 1 つだけ持つ**:グラフの種類(ブロックサイズ、文脈の長さごとのグラフ)がいくつあっても、重みは 1 組です。
12
+ - コンパイラーの修正:Qwen3.5 系 NF4 の 4 トークンの検証グラフ(MTP 先読み)、2 ビットの重み(Bonsai)を 2 行以上で計算すると 0 になる不具合、コンパイルの高速化(Panther Lake で Ternary Bonsai 2 9B の初回のロード約 7.5 分、生成約 17 tok/s など)。
13
+ - **Lunar Lake 以降の PC(Windows・Ubuntu)で、融合グラフを既定で使うように**(2.1.0 では試験的・既定オフ)。区間ごとに NPU を 1 回呼ぶだけにして、生成もプロンプトも速くなります。重みを共有するので、メモリは増えず、むしろ減ります。Panther Lake(Core Ultra X7 358H)、2.1.0 の既定との比較:
14
+ - Granite 4.2 8B NF4:生成 約 9 → 約 13 tok/s、約 900 トークンのプロンプト 約 160 → 約 400 tok/s、専用メモリ 11.4 → 約 1.6 GB(ピーク 12.5 → 約 9.8 GB)、1 トークンあたりの電力量 約 1.3 → 約 0.9 J。
15
+ - llm-jp-4.1-8B NF4:生成 約 9.9 → 約 14 tok/s、プロンプト 約 170 → 約 440 tok/s、専用メモリ 11.0 → 約 2.4 GB。
16
+ - Lunar Lake(Ubuntu、16 GB)の Granite 4.2 8B NF4:生成 11〜13 tok/s、約 900 トークンのプロンプト約 385 tok/s、専用メモリ約 2 GB(ピーク 3.3 GB)、キャッシュ 1.8 GB、初回のロード 3.5 分(残りの融合グラフは裏で約 14 分)。
17
+ - Granite 4.2 3B NF4:生成 約 14.3 → 約 23 tok/s、プロンプト 約 235 → 約 730 tok/s、専用メモリ 5.1 → 約 1.4 GB。
18
+ - **長い会話も速く**:融合グラフは文脈の長さまで 8K・16K・32K 位置の種類も作ります(2.1.0 は 4K 位置まで、その先は従来の経路)。Granite 4.2 3B NF4、約 7.3K/15K/22K トークンのプロンプト(従来の経路との比較):生成 4.6 → 13.6/4.0 → 8.8/2.6 → 4.9 tok/s、プロンプト 94 → 170/62 → 146/43 → 74 tok/s。
19
+ - 文脈の長さごとのグラフ(1K〜32K 位置)をすべて読み込んだままにするので、会話の途中で止まらず、同じ質問には同じ答えが返ります。初回のロードは 2.1.0 とほぼ同じで(8B で約 10〜12 分)、残りのグラフは回答しながら裏でコンパイルします。
20
+ - 省メモリモードでは、今までどおり使う長さのグラフだけを読み込みます。
21
+ - Qwen3.5 系・Ornith・gemma-4(NPUW で動くモデル)もキャッシュが小さくなります(Qwen3.5-9B NF4:約 0.3 GB)。Qwen3.5-9B NF4 の生成は MTP 先読み付きで約 14〜19 tok/s。
22
+ - **Ubuntu でもキットを使います**(`onw/npukit/linux`、32 MB の圧縮ファイルを最初の起動で展開)。Lunar Lake(16 GB)の Qwen3.5-9B NF4:初回のロード約 2.2 分、生成 15.5〜20.6 tok/s、プロンプト約 240 tok/s、キャッシュ 0.26 GB、専用メモリ約 1.1 GB。Granite 4.2 3B INT4 は初回のロード 4.7 → 2.7 分(答え・速さは 2.1.0 と同じ)。
23
+ - INT4 版のモデル(Granite・llm-jp など区間に分けて動くもの)は、重みを持たないグラフにできない(重みが分離されない)ので、Lunar Lake 以降でも重みを持つグラフ・融合グラフなしの 2.1.0 と同じ動作です。Lunar Lake 以降では NF4 版を勧めます。
24
+ - Meteor Lake・Arrow Lake(NPU 3720)は、2.1.0 と同じ動作です(重みを持たないグラフに対応していないため)。
25
+ - OpenVINO は 2026.5.0b1 に固定しました(キットがこの版専用のため)��
26
+
27
  ## v2.1.0 (2026-10-18)
28
  - **Lunar Lake 以降専用の NF4 版を 3 つ追加:Granite 4.2 3B/8B、llm-jp-4.1-8B-thinking**(ryugyosoft/granite-4.2-3b-nf4-onw、granite-4.2-8b-nf4-onw、llm-jp-4.1-8b-thinking-nf4-onw)。重みはチャネル単位の NF4 を基本に、誤差の出やすいアテンションの入力(q/k/v_proj)と down_proj だけをチャネル単位の INT8 にしました。
29
  - Panther Lake(Core Ultra X7 358H):Granite 4.2 8B は生成 5.4 → 約 9 tok/s(INT4 版・2.0.3 との比較)、約 900 トークンのプロンプト約 160 tok/s。llm-jp-4.1-8B は生成約 9.9 tok/s・プロンプト約 170 tok/s、Granite 4.2 3B は生成約 14.3 tok/s・プロンプト約 235 tok/s。1 トークンあたりの電力量(パッケージ全体)は 8B で約 1.3 J、3B で約 0.7 J。
CHANGELOG_en.md CHANGED
@@ -4,6 +4,25 @@
4
 
5
  Dates are JST. Every version is on Hugging Face `ryugyosoft/onw`, tagged from `v0.4.2` on.
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  ## v2.1.0 (2026-10-18)
8
  - **Three NF4 builds for Lunar Lake and later: Granite 4.2 3B / 8B and llm-jp-4.1-8B-thinking** (ryugyosoft/granite-4.2-3b-nf4-onw, granite-4.2-8b-nf4-onw, llm-jp-4.1-8b-thinking-nf4-onw). The weights are channel-wise NF4, except the attention inputs (q/k/v_proj) and down_proj, which are channel-wise INT8 (where 4 bits cost the most accuracy).
9
  - Panther Lake (Core Ultra X7 358H): Granite 4.2 8B decodes 5.4 -> ~9 tok/s (against the INT4 build on 2.0.3) and reads a ~900-token prompt at ~160 tok/s; llm-jp-4.1-8B ~9.9 tok/s / ~170 tok/s; Granite 4.2 3B ~14.3 tok/s / ~235 tok/s. Energy per token (whole package): ~1.3 J for the 8B, ~0.7 J for the 3B.
 
4
 
5
  Dates are JST. Every version is on Hugging Face `ryugyosoft/onw`, tagged from `v0.4.2` on.
6
 
7
+ ## v3.0.0 (2026-10-19)
8
+ - **onw's own NPU kit (Windows and Linux): the NPU compiler and the NPU plugin, rebuilt for onw from Intel's open sources** (`onw/npukit`, Apache 2.0, changes in `onw/npukit/patches`). Used only with the OpenVINO 2026.5.0 that onw installs; nothing of the installed OpenVINO is modified (`ONW_NPUKIT=0` goes back to OpenVINO's own). What it brings:
9
+ - **Compiled graphs without weights**: the cache holds the graphs only; the weights are rebuilt on the NPU from the model's own files at every load. The cache shrinks to a tenth of the model or less (Granite 4.2 8B NF4: 10.5 -> 0.8 GB, llm-jp-4.1-8B NF4: 9.2 -> 0.65 GB, Granite 4.2 3B NF4: 4.2 -> 0.5 GB). OpenVINO 2026.5's compiler cannot do this with NF4 weights.
10
+ - **One copy of identical weights on the NPU**: however many graphs (block sizes, context-length variants), the weights are held once.
11
+ - Compiler fixes: the 4-token verification graphs of the Qwen3.5 NF4 models (MTP drafts), 2-bit weights (Bonsai) computing zeros with 2+ rows, faster compiles (Panther Lake: Ternary Bonsai 2 9B loads the first time in ~7.5 min and answers at ~17 tok/s, ...).
12
+ - **Fused graphs on by default on PCs from Lunar Lake on (Windows and Ubuntu)** (experimental and off in 2.1.0): one NPU call per segment, faster answers and prompts. The weights are shared, so memory does not grow - it shrinks. Panther Lake (Core Ultra X7 358H), against 2.1.0's default:
13
+ - Granite 4.2 8B NF4: answers ~9 -> ~13 tok/s, a ~900-token prompt ~160 -> ~400 tok/s, private memory 11.4 -> ~1.6 GB (peak 12.5 -> ~9.8 GB), energy per token ~1.3 -> ~0.9 J.
14
+ - llm-jp-4.1-8B NF4: answers ~9.9 -> ~14 tok/s, prompt ~170 -> ~440 tok/s, private memory 11.0 -> ~2.4 GB.
15
+ - Lunar Lake (Ubuntu, 16 GB), Granite 4.2 8B NF4: answers 11-13 tok/s, a ~900-token prompt ~385 tok/s, private memory ~2 GB (peak 3.3 GB), cache 1.8 GB, first load 3.5 min (the remaining fused graphs ~14 min in the background).
16
+ - Granite 4.2 3B NF4: answers ~14.3 -> ~23 tok/s, prompt ~235 -> ~730 tok/s, private memory 5.1 -> ~1.4 GB.
17
+ - **Long chats are fast too**: the fused graphs go on up to the context, with 8K / 16K / 32K-position variants (2.1.0: up to 4K positions, the paged path beyond). Granite 4.2 3B NF4, prompts of ~7.3K / 15K / 22K tokens (against the paged path): answers 4.6 -> 13.6 / 4.0 -> 8.8 / 2.6 -> 4.9 tok/s, prompts 94 -> 170 / 62 -> 146 / 43 -> 74 tok/s.
18
+ - Every context-length variant (1K to 32K positions) stays loaded: no pauses mid-chat, and the same question gets the same answer. The first load takes about as long as with 2.1.0 (8B: ~10-12 min); the remaining variants compile in the background while you chat.
19
+ - The memory-saving mode keeps loading only the variant in use, as before.
20
+ - The Qwen3.5 family, Ornith and gemma-4 (models that run through NPUW) get small caches too (Qwen3.5-9B NF4: ~0.3 GB). Qwen3.5-9B NF4 answers at ~14-19 tok/s with MTP drafts.
21
+ - **The kit on Ubuntu too** (`onw/npukit/linux`, a 32 MB archive unpacked at the first start). Lunar Lake (16 GB), Qwen3.5-9B NF4: first load ~2.2 min, answers 15.5-20.6 tok/s, prompts ~240 tok/s, cache 0.26 GB, private memory ~1.1 GB. Granite 4.2 3B INT4 loads the first time in 2.7 instead of 4.7 min (same answers and speed as 2.1.0).
22
+ - The INT4 builds (Granite, llm-jp and the other models run in paged parts) cannot have graphs without weights (their weights are not separated), so from Lunar Lake on too they run as in 2.1.0: graphs with their weights, no fused graphs. From Lunar Lake on, use the NF4 builds.
23
+ - Meteor Lake / Arrow Lake (NPU 3720) behave as in 2.1.0 (no graphs without weights there).
24
+ - OpenVINO is pinned to 2026.5.0b1 (the kit is built for exactly this release).
25
+
26
  ## v2.1.0 (2026-10-18)
27
  - **Three NF4 builds for Lunar Lake and later: Granite 4.2 3B / 8B and llm-jp-4.1-8B-thinking** (ryugyosoft/granite-4.2-3b-nf4-onw, granite-4.2-8b-nf4-onw, llm-jp-4.1-8b-thinking-nf4-onw). The weights are channel-wise NF4, except the attention inputs (q/k/v_proj) and down_proj, which are channel-wise INT8 (where 4 bits cost the most accuracy).
28
  - Panther Lake (Core Ultra X7 358H): Granite 4.2 8B decodes 5.4 -> ~9 tok/s (against the INT4 build on 2.0.3) and reads a ~900-token prompt at ~160 tok/s; llm-jp-4.1-8B ~9.9 tok/s / ~170 tok/s; Granite 4.2 3B ~14.3 tok/s / ~235 tok/s. Energy per token (whole package): ~1.3 J for the 8B, ~0.7 J for the 3B.
README.md CHANGED
@@ -102,10 +102,10 @@ Python や git の知識は要りません。Python が入っていなければ
102
  | [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 |
103
  | [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 |
104
  | [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 |
105
- | [ryugyosoft/granite-4.2-3b-nf4-onw](https://huggingface.co/ryugyosoft/granite-4.2-3b-nf4-onw) | 3B の NF4 版(重みはチャネル単位 NF4、q/k/v・down_proj は INT8。**Lunar Lake 以降専用**) | テキスト | 切替 | —(Panther Lake で約 14.3 tok/s) | 2.6 GB | 約 5.3 GB |
106
- | [ryugyosoft/granite-4.2-8b-nf4-onw](https://huggingface.co/ryugyosoft/granite-4.2-8b-nf4-onw) | 8B の NF4 版(同上。**Lunar Lake 以降専用**) | テキスト | 切替 | —(Panther Lake で約 9 tok/s) | 6.2 GB | 約 12.5 GB |
107
  | [ryugyosoft/llm-jp-4.1-8b-thinking-onw](https://huggingface.co/ryugyosoft/llm-jp-4.1-8b-thinking-onw) | `llama`(NII LLM-jp の日本語中心の思考型 8B、harmony 形式) | テキスト | **常に** | 4.2〜4.7 tok/s | 4.6 GB | 約 10 GB |
108
- | [ryugyosoft/llm-jp-4.1-8b-thinking-nf4-onw](https://huggingface.co/ryugyosoft/llm-jp-4.1-8b-thinking-nf4-onw) | 同上、NF4 版(q/k/v・down_proj は INT8。**Lunar Lake 以降専用**) | テキスト | **常に** | —(Panther Lake で約 9.9 tok/s) | 6.1 GB | 約 11.6 GB |
109
  | [ryugyosoft/llm-jp-4.1-8b-thinking-int8-onw](https://huggingface.co/ryugyosoft/llm-jp-4.1-8b-thinking-int8-onw) | 同上、重みはチャネル単位 INT8(bf16 に近い精度) | テキスト | **常に** | 4.1 tok/s | 7.7 GB | 約 15 GB(節約モードで待機中 約 8 GB) |
110
  | [ryugyosoft/llm-jp-4.1-32b-a3b-thinking-onw](https://huggingface.co/ryugyosoft/llm-jp-4.1-32b-a3b-thinking-onw) | `qwen3_moe`(llm-jp の MoE:128 個から 8 個、32B のうち 1 トークン約 3B、harmony 形式) | テキスト | **常に** | 5.8 tok/s | 16 GB | 約 17 GB(SSD から読めば 16 GB の PC でも。Lunar Lake 16 GB で 3.4 tok/s) |
111
  | [ryugyosoft/llm-jp-4.1-32b-a3b-thinking-int8-onw](https://huggingface.co/ryugyosoft/llm-jp-4.1-32b-a3b-thinking-int8-onw) | 同上、エキスパート・アテンション INT8(bf16 に近い精度) | テキスト | **常に** | 4.6 tok/s | 30 GB | 約 32 GB |
@@ -200,7 +200,8 @@ onw list # 対応アーキ
200
  | **コンテキスト長**(`--context`、既定 32K、モデルの最大まで) | サーバーのオプション/設定画面 | 不要(KV キャッシュの上限が変わるだけ) |
201
  | 64 トークン処理(dense モデルのプロンプトを 64 トークンずつ。auto はオンで、16 トークン用のグラフの代わりに読み込む。off で 16 トークンずつ、`ONW_S64=1` で両方) | `ONW_S64=1/0`/設定画面 | 初回のみ |
202
  | MTP 先読み(Qwen3.5 系 NF4 版。auto は Lunar Lake 以降でオン) | `ONW_MTP=1/0` | 初回のみ |
203
- | 融合グラフ(試験的、既定オフ。dense モデルの区間を 1 回の呼び出しで。回答用、Lunar Lake 以降はプロンプト用も。1K・2K・4K 位置の 3 種類を裏でコンパイルし、キャッシュがモデルの約 6〜7 倍になる) | `ONW_FUSED=1`(有効)、`ONW_FUSED_PROMPT=0`(回答だけ) | 初回のみ |
 
204
  | エキスパートのメモリ(Qwen3.6 系。上限を超える分は SSD から読む) | `ONW_EXPERT_GB=5`/設定画面 | 不要 |
205
  | NPUW 重み共有(試験的。auto は Lunar Lake 以降でオン、MoE モデル(LFM2・Qwen3.6)と区間の多いモデル(Granite・llm-jp)は対象外:重みを 1 コピーにしてメモリとコンパイル時間を削減) | `ONW_NPUW=1/0`/設定画面 | 初回のみ |
206
  | メモリ節約モード(1 トークン用と 16 トークン用で重みを共有できない dense モデル:NPU 3720 ではすべて、Lunar Lake 以降は Granite・llm-jp と NPUW オフのとき。普段は 16 トークン用のグラフだけを持ち、回答を書く間だけ 1 トークン用と入れ替える。待機中のメモリ約 4〜6 割減、回答の出だし数秒は遅め、先読み検証なし) | `ONW_LAZY16=1`/設定画面(効くモデルがあるときだけ表示) | 初回のみ |
@@ -318,25 +319,15 @@ KV キャッシュは INT8 で、会話が実際に使った長さの分だけ
318
  (Windows 11、ドライバー 32.0.100.5540、2026 年 10 月 6 日。1 パーツは 4 トークン用グラフの最初のパーツ。命令の大きさは
319
  コンパイル結果の ELF セクションの大きさで、命令の数そのものではありません。)
320
 
321
- ### 既知の問題(onw 2.1.0、2026 年 10 月 18 日)
322
-
323
- - **融合グラフ(試験的、`ONW_FUSED=1`)はキャッシュが大きい**:区間ごとに NPU を 1 回呼ぶだけにする融合グラフは、Panther Lake で
324
- 生成が約 1.2〜1.6 倍になりますが、1K・2K・4K 位置用の 3 種類 × ブロックサイズごとのグラフがそれぞれ重みの写しを持つので、
325
- キャッシュがモデルの約 6〜7 倍になります(Granite 4.2 8B NF4 で約 42 GB、既定の経路では約 10.5 GB)。初回は裏でのコンパイルも長く、
326
- 会話が 1K・2K・4K 位置を越えるたびに数秒止まり、回答の言い回しが既定の経路と少し変わることがあります。重みを共有できるようになるまで既定ではオフです。
327
- - **Panther Lake で、dense モデルの初回のロードが長い**。2.0.3 で Granite 4.2 3B/8B と llm-jp-4.1-8B(INT4)は大きく短くなりました
328
- (Panther Lake の小型機 Core Ultra X7 358H で、Granite 4.2 8B は約 2 時間 45 分 → 約 23 分、llm-jp-4.1-8B は約 50 分 → 約 19 分、Granite 4.2 3B は約 10 分)。
329
- Qwen3.5-9B は INT4 で約 15 分、NF4 で約 8 分のままです(2 回目以降はキャッシュから数十秒)。ロードを途中で止めたり PC が
330
- 再起動したりしても、コンパイルの済んだ部分はキャッシュに残ります。
331
- - 原因:OpenVINO 2026.5 に同梱の NPU コンパイラー(8.4。ナイトリー版の 8.5 も同じ)は、1 トークン用(生成用)のグラフの
332
- コンパイル結果を書き出す段階が遅くなっています。1 トークン用のグラフでは、INT4 の行列積が量子化のグループごとの小さな
333
- 行列積に分解され(2 トークン以上なら分解されません)、その小さな定数を書き出す処理が 8.3 より約 2.7 倍遅くなっています。
334
- Granite 4.2 8B の部品 1 つ(NPU 5010 向け、Core Ultra 9 285HX で計測)は、1 トークン用で 97 秒、2 トークン用で 4 秒でした。
335
- - 2.0.3 の対処:NPU 4000 以降(Lunar Lake/Panther Lake。Arrow Lake などの NPU 3720 は変わりません)では、上の 3 モデルの生成に 2 トークン用のグラフを使います。
336
- モデルの更新(数百バイトのファイル追加)が必要で、アプリが更新を案内します。生成は約 1 割遅くなります(Granite 4.2 8B で
337
- 6.0 → 5.4 tok/s)。環境変数 `ONW_DECODE2=0` で元の 1 トークン用に戻せます。
338
- - 試験機能:環境変数 `ONW_NPU_COMPILER=driver` で、OpenVINO 同梱のコンパイラーではなくドライバー内のコンパイラーを使います。
339
- Qwen3.5-9B(INT4)は正しく動きましたが、NF4 版の 4 トークン用グラフのコンパイルに失敗するので既定にはしていません。
340
  - **Panther Lake の小型機では、MTP 先読みの生成が Lunar Lake より遅い**:Qwen3.5-9B(NF4)+ MTP の生成は、Panther Lake の
341
  小型機で 13.6〜14.3 tok/s(Lunar Lake は約 19 tok/s)でした。MTP は CPU 側の処理が多く、電力の制限で定格まで落ちる小型機の
342
  CPU が効いていると見て調査中です。
 
102
  | [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 |
103
  | [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 |
104
  | [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 |
105
+ | [ryugyosoft/granite-4.2-3b-nf4-onw](https://huggingface.co/ryugyosoft/granite-4.2-3b-nf4-onw) | 3B の NF4 版(重みはチャネル単位 NF4、q/k/v・down_proj は INT8。**Lunar Lake 以降専用**) | テキスト | 切替 | —(Panther Lake で約 23 tok/s) | 2.6 GB | 約 4.8 GB |
106
+ | [ryugyosoft/granite-4.2-8b-nf4-onw](https://huggingface.co/ryugyosoft/granite-4.2-8b-nf4-onw) | 8B の NF4 版(同上。**Lunar Lake 以降専用**) | テキスト | 切替 | —(Panther Lake で約 13 tok/s) | 6.2 GB | 約 9.8 GB |
107
  | [ryugyosoft/llm-jp-4.1-8b-thinking-onw](https://huggingface.co/ryugyosoft/llm-jp-4.1-8b-thinking-onw) | `llama`(NII LLM-jp の日本語中心の思考型 8B、harmony 形式) | テキスト | **常に** | 4.2〜4.7 tok/s | 4.6 GB | 約 10 GB |
108
+ | [ryugyosoft/llm-jp-4.1-8b-thinking-nf4-onw](https://huggingface.co/ryugyosoft/llm-jp-4.1-8b-thinking-nf4-onw) | 同上、NF4 版(q/k/v・down_proj は INT8。**Lunar Lake 以降専用**) | テキスト | **常に** | —(Panther Lake で約 14 tok/s) | 6.1 GB | 約 9.7 GB |
109
  | [ryugyosoft/llm-jp-4.1-8b-thinking-int8-onw](https://huggingface.co/ryugyosoft/llm-jp-4.1-8b-thinking-int8-onw) | 同上、重みはチャネル単位 INT8(bf16 に近い精度) | テキスト | **常に** | 4.1 tok/s | 7.7 GB | 約 15 GB(節約モードで待機中 約 8 GB) |
110
  | [ryugyosoft/llm-jp-4.1-32b-a3b-thinking-onw](https://huggingface.co/ryugyosoft/llm-jp-4.1-32b-a3b-thinking-onw) | `qwen3_moe`(llm-jp の MoE:128 個から 8 個、32B のうち 1 トークン約 3B、harmony 形式) | テキスト | **常に** | 5.8 tok/s | 16 GB | 約 17 GB(SSD から読めば 16 GB の PC でも。Lunar Lake 16 GB で 3.4 tok/s) |
111
  | [ryugyosoft/llm-jp-4.1-32b-a3b-thinking-int8-onw](https://huggingface.co/ryugyosoft/llm-jp-4.1-32b-a3b-thinking-int8-onw) | 同上、エキスパート・アテンション INT8(bf16 に近い精度) | テキスト | **常に** | 4.6 tok/s | 30 GB | 約 32 GB |
 
200
  | **コンテキスト長**(`--context`、既定 32K、モデルの最大まで) | サーバーのオプション/設定画面 | 不要(KV キャッシュの上限が変わるだけ) |
201
  | 64 トークン処理(dense モデルのプロンプトを 64 トークンずつ。auto はオンで、16 トークン用のグラフの代わりに読み込む。off で 16 トークンずつ、`ONW_S64=1` で両方) | `ONW_S64=1/0`/設定画面 | 初回のみ |
202
  | MTP 先読み(Qwen3.5 系 NF4 版。auto は Lunar Lake 以降でオン) | `ONW_MTP=1/0` | 初回のみ |
203
+ | 融合グラフ(dense モデルの区間を 1 回の呼び出しで。回答用と、Lunar Lake 以降はプロンプト用も。onw の NPU キットが使える Lunar Lake 以降の PC では既定でオン) | `ONW_FUSED=0`(無効)/`1`(キットなしでも有効。キャッシュがモデルの約 6〜7 倍になる)、`ONW_FUSED_PROMPT=0`(回答だけ) | 初回のみ |
204
+ | onw の NPU キット(Windows・Linux。重みを持たないグラフと重みの共有、コンパイラーの修正) | `ONW_NPUKIT=0`(OpenVINO 自身の NPU プラグインを使う) | 起動時 |
205
  | エキスパートのメモリ(Qwen3.6 系。上限を超える分は SSD から読む) | `ONW_EXPERT_GB=5`/設定画面 | 不要 |
206
  | NPUW 重み共有(試験的。auto は Lunar Lake 以降でオン、MoE モデル(LFM2・Qwen3.6)と区間の多いモデル(Granite・llm-jp)は対象外:重みを 1 コピーにしてメモリとコンパイル時間を削減) | `ONW_NPUW=1/0`/設定画面 | 初回のみ |
207
  | メモリ節約モード(1 トークン用と 16 トークン用で重みを共有できない dense モデル:NPU 3720 ではすべて、Lunar Lake 以降は Granite・llm-jp と NPUW オフのとき。普段は 16 トークン用のグラフだけを持ち、回答を書く間だけ 1 トークン用と入れ替える。待機中のメモリ約 4〜6 割減、回答の出だし数秒は遅め、先読み検証なし) | `ONW_LAZY16=1`/設定画面(効くモデルがあるときだけ表示) | 初回のみ |
 
319
  (Windows 11、ドライバー 32.0.100.5540、2026 年 10 月 6 日。1 パーツは 4 トークン用グラフの最初のパーツ。命令の大きさは
320
  コンパイル結果の ELF セクションの大きさで、命令の数そのものではありません。)
321
 
322
+ ### 既知の問題(onw 3.0.0、2026 年 10 月 19 日)
323
+
324
+ - **INT4 版のモデルと Meteor Lake・Arrow Lake(NPU 3720)は 2.1.0 と同じです**:INT4 の重みは重みを持たないグラフにできず、NPU 3720 は
325
+ それに対応していないので、キャッシュとメモリは 2.1.0 と同じで、融合グラフも使いません。Lunar Lake 以降では NF4 版を勧めます。
326
+ - **長い会話では生成が遅くなります**:文脈が長いほど毎回読む KV キャッシュが増えるためです(Panther Lake の Granite 4.2 3B NF4:
327
+ 約 7.3K トークン�� 13.6 tok/s、約 15K で 8.8 tok/s、約 22K で 4.9 tok/s。従来の経路の約 2〜3 倍です)。
328
+ - **初回のロード(Panther Lake の小型機 Core Ultra X7 358H、3.0)**:Granite 4.2 8B NF4 約 12 分、llm-jp-4.1-8B NF4 約 10 分、
329
+ Granite 4.2 3B NF4 約 6.5 分、Qwen3.5-9B NF4 約 7.5 分。残りの融合グラフは、回答しながら裏でコンパイルします(4K 位置までで 7〜10 分、32K 位置まで全部で Granite 4.2 3B NF4 は約 28 分)。
330
+ 2 回目以降はキャッシュから数十秒です。ロードを途中で止めたり PC が再起動したりしても、コンパイルの済んだ部分は残ります。
 
 
 
 
 
 
 
 
 
 
331
  - **Panther Lake の小型機では、MTP 先読みの生成が Lunar Lake より遅い**:Qwen3.5-9B(NF4)+ MTP の生成は、Panther Lake の
332
  小型機で 13.6〜14.3 tok/s(Lunar Lake は約 19 tok/s)でした。MTP は CPU 側の処理が多く、電力の制限で定格まで落ちる小型機の
333
  CPU が効いていると見て調査中です。
README_en.md CHANGED
@@ -89,10 +89,10 @@ troubleshooting), is the **[user guide](MANUAL_en.md)**.
89
  | [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 |
90
  | [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 |
91
  | [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 |
92
- | [ryugyosoft/granite-4.2-3b-nf4-onw](https://huggingface.co/ryugyosoft/granite-4.2-3b-nf4-onw) | the 3B as NF4 (channel-wise NF4, q/k/v and down_proj INT8; **Lunar Lake and newer only**) | text | switchable | — (~14.3 tok/s on Panther Lake) | 2.6 GB | ~5.3 GB |
93
- | [ryugyosoft/granite-4.2-8b-nf4-onw](https://huggingface.co/ryugyosoft/granite-4.2-8b-nf4-onw) | the 8B as NF4 (the same; **Lunar Lake and newer only**) | text | switchable | — (~9 tok/s on Panther Lake) | 6.2 GB | ~12.5 GB |
94
  | [ryugyosoft/llm-jp-4.1-8b-thinking-onw](https://huggingface.co/ryugyosoft/llm-jp-4.1-8b-thinking-onw) | `llama` (NII LLM-jp's Japanese-centred 8B thinking model, harmony format) | text | **always** | 4.2-4.7 tok/s | 4.6 GB | ~10 GB |
95
- | [ryugyosoft/llm-jp-4.1-8b-thinking-nf4-onw](https://huggingface.co/ryugyosoft/llm-jp-4.1-8b-thinking-nf4-onw) | the same as NF4 (q/k/v and down_proj INT8; **Lunar Lake and newer only**) | text | **always** | — (~9.9 tok/s on Panther Lake) | 6.1 GB | ~11.6 GB |
96
  | [ryugyosoft/llm-jp-4.1-8b-thinking-int8-onw](https://huggingface.co/ryugyosoft/llm-jp-4.1-8b-thinking-int8-onw) | the same with channel-wise INT8 weights (close to bf16) | text | **always** | 4.1 tok/s | 7.7 GB | ~15 GB (~8 GB idle in memory-saving mode) |
97
  | [ryugyosoft/llm-jp-4.1-32b-a3b-thinking-onw](https://huggingface.co/ryugyosoft/llm-jp-4.1-32b-a3b-thinking-onw) | `qwen3_moe` (llm-jp's MoE: 8 of 128 experts, ~3B of 32B per token, harmony format) | text | **always** | 5.8 tok/s | 16 GB | ~17 GB (16 GB PCs with the experts streamed from the SSD: 3.4 tok/s on a 16 GB Lunar Lake) |
98
  | [ryugyosoft/llm-jp-4.1-32b-a3b-thinking-int8-onw](https://huggingface.co/ryugyosoft/llm-jp-4.1-32b-a3b-thinking-int8-onw) | the same, INT8 experts and attention (close to bf16) | text | **always** | 4.6 tok/s | 30 GB | ~32 GB |
@@ -189,7 +189,8 @@ What needs what:
189
  | **context length** (`--context`, 32K by default, up to the model's maximum) | server option / settings | no (it only bounds the KV cache) |
190
  | 64-token prompt blocks (dense models read prompts 64 tokens at a time; auto = on, loaded in place of the 16-token graphs; off = 16 at a time, `ONW_S64=1` = both) | `ONW_S64=1/0` / settings | once, the first time |
191
  | MTP draft tokens (the NF4 Qwen3.5-family builds; auto = on from Lunar Lake) | `ONW_MTP=1/0` | once, the first time |
192
- | fused graphs (experimental, off by default: a dense model's segment in one call; answers, and prompts from Lunar Lake on; 1K / 2K / 4K-position variants compiled in the background, the cache grows to ~6-7x the model) | `ONW_FUSED=1` (on), `ONW_FUSED_PROMPT=0` (answers only) | once, the first time |
 
193
  | expert memory (Qwen3.6 models; the rest streams from the SSD) | `ONW_EXPERT_GB=5` / settings | no |
194
  | 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 |
195
  | 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 |
@@ -306,27 +307,17 @@ How it was measured, and caveats:
306
  (Windows 11, driver 32.0.100.5540, 2026-10-06. One part = the first part of a 4-token graph. Code sizes are ELF section sizes of
307
  the compiled file, not instruction counts.)
308
 
309
- ### Known issues (onw 2.1.0, 2026-10-18)
310
-
311
- - **The fused graphs (experimental, `ONW_FUSED=1`) take a lot of cache**: one NPU call per segment makes decoding ~1.2-1.6x faster
312
- on Panther Lake, but the graphs for 1K, 2K and 4K positions x each block size all hold their own copy of the weights, so the
313
- cache grows to ~6-7x the model (Granite 4.2 8B NF4: ~42 GB, ~10.5 GB on the default path). The first background compile is
314
- long, a chat passing 1K / 2K / 4K positions pauses for a few seconds, and the wording can differ a little from the default path.
315
- Off by default until the weights can be shared.
316
- - **On Panther Lake the first load of a dense model takes long**. 2.0.3 cuts it a lot for Granite 4.2 3B / 8B and
317
- llm-jp-4.1-8B (INT4): on a small Panther Lake PC (Core Ultra X7 358H) Granite 4.2 8B went from ~2 h 45 min to ~23 min, llm-jp-4.1-8B from ~50 to ~19 min, and
318
- Granite 4.2 3B takes ~10 min. Qwen3.5-9B still takes ~15 min (INT4) / ~8 min (NF4); later loads come from the cache in well
319
- under a minute. If a load is stopped or the PC restarts midway, the parts already compiled stay in the cache.
320
- - Cause: the NPU compiler that comes with OpenVINO 2026.5 (8.4; the nightly 8.5 is the same) is slow at writing out the
321
- compiled 1-token (decode) graphs. In a 1-token graph each INT4 matmul is split into one small matmul per quantization group
322
- (not with 2 or more tokens), and writing out those small constants is ~2.7x slower than in 8.3. One Granite 4.2 8B part
323
- (for the NPU 5010, timed on a Core Ultra 9 285HX) takes 97 s as a 1-token graph and 4 s as a 2-token graph.
324
- - The 2.0.3 fix: on the NPU 4000 and later (Lunar Lake / Panther Lake; the NPU 3720 of Arrow Lake etc. is unchanged) these three models decode on 2-token
325
- graphs. This needs a model update (a few hundred bytes of new files); the app offers it. Decoding gets ~10% slower
326
- (Granite 4.2 8B 6.0 -> 5.4 tok/s). `ONW_DECODE2=0` goes back to the 1-token graphs.
327
- - Experimental: `ONW_NPU_COMPILER=driver` uses the compiler inside the NPU driver instead of the one that comes with
328
- OpenVINO. Qwen3.5-9B (INT4) runs correctly with it, but it fails to compile the 4-token graphs of the NF4 builds, so it is
329
- not the default.
330
  - **On small Panther Lake PCs MTP decoding is slower than on Lunar Lake**: Qwen3.5-9B (NF4) + MTP decodes at 13.6-14.3 tok/s
331
  there (Lunar Lake ~19 tok/s). MTP does a lot of CPU-side work, and the small PC's CPU drops to its base clock under its power
332
  limit; being looked into.
 
89
  | [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 |
90
  | [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 |
91
  | [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 |
92
+ | [ryugyosoft/granite-4.2-3b-nf4-onw](https://huggingface.co/ryugyosoft/granite-4.2-3b-nf4-onw) | the 3B as NF4 (channel-wise NF4, q/k/v and down_proj INT8; **Lunar Lake and newer only**) | text | switchable | — (~23 tok/s on Panther Lake) | 2.6 GB | ~4.8 GB |
93
+ | [ryugyosoft/granite-4.2-8b-nf4-onw](https://huggingface.co/ryugyosoft/granite-4.2-8b-nf4-onw) | the 8B as NF4 (the same; **Lunar Lake and newer only**) | text | switchable | — (~13 tok/s on Panther Lake) | 6.2 GB | ~9.8 GB |
94
  | [ryugyosoft/llm-jp-4.1-8b-thinking-onw](https://huggingface.co/ryugyosoft/llm-jp-4.1-8b-thinking-onw) | `llama` (NII LLM-jp's Japanese-centred 8B thinking model, harmony format) | text | **always** | 4.2-4.7 tok/s | 4.6 GB | ~10 GB |
95
+ | [ryugyosoft/llm-jp-4.1-8b-thinking-nf4-onw](https://huggingface.co/ryugyosoft/llm-jp-4.1-8b-thinking-nf4-onw) | the same as NF4 (q/k/v and down_proj INT8; **Lunar Lake and newer only**) | text | **always** | — (~14 tok/s on Panther Lake) | 6.1 GB | ~9.7 GB |
96
  | [ryugyosoft/llm-jp-4.1-8b-thinking-int8-onw](https://huggingface.co/ryugyosoft/llm-jp-4.1-8b-thinking-int8-onw) | the same with channel-wise INT8 weights (close to bf16) | text | **always** | 4.1 tok/s | 7.7 GB | ~15 GB (~8 GB idle in memory-saving mode) |
97
  | [ryugyosoft/llm-jp-4.1-32b-a3b-thinking-onw](https://huggingface.co/ryugyosoft/llm-jp-4.1-32b-a3b-thinking-onw) | `qwen3_moe` (llm-jp's MoE: 8 of 128 experts, ~3B of 32B per token, harmony format) | text | **always** | 5.8 tok/s | 16 GB | ~17 GB (16 GB PCs with the experts streamed from the SSD: 3.4 tok/s on a 16 GB Lunar Lake) |
98
  | [ryugyosoft/llm-jp-4.1-32b-a3b-thinking-int8-onw](https://huggingface.co/ryugyosoft/llm-jp-4.1-32b-a3b-thinking-int8-onw) | the same, INT8 experts and attention (close to bf16) | text | **always** | 4.6 tok/s | 30 GB | ~32 GB |
 
189
  | **context length** (`--context`, 32K by default, up to the model's maximum) | server option / settings | no (it only bounds the KV cache) |
190
  | 64-token prompt blocks (dense models read prompts 64 tokens at a time; auto = on, loaded in place of the 16-token graphs; off = 16 at a time, `ONW_S64=1` = both) | `ONW_S64=1/0` / settings | once, the first time |
191
  | MTP draft tokens (the NF4 Qwen3.5-family builds; auto = on from Lunar Lake) | `ONW_MTP=1/0` | once, the first time |
192
+ | fused graphs (a dense model's segment in one call; answers, and prompts from Lunar Lake on; on by default on PCs from Lunar Lake on with onw's NPU kit) | `ONW_FUSED=0` (off) / `1` (on without the kit too - the cache then grows to ~6-7x the model), `ONW_FUSED_PROMPT=0` (answers only) | once, the first time |
193
+ | onw's NPU kit (Windows and Linux: graphs without weights, shared weights, compiler fixes) | `ONW_NPUKIT=0` (use OpenVINO's own NPU plugin) | at start |
194
  | expert memory (Qwen3.6 models; the rest streams from the SSD) | `ONW_EXPERT_GB=5` / settings | no |
195
  | 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 |
196
  | 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 |
 
307
  (Windows 11, driver 32.0.100.5540, 2026-10-06. One part = the first part of a 4-token graph. Code sizes are ELF section sizes of
308
  the compiled file, not instruction counts.)
309
 
310
+ ### Known issues (onw 3.0.0, 2026-10-19)
311
+
312
+ - **The INT4 builds and Meteor Lake / Arrow Lake (NPU 3720) are as in 2.1.0**: INT4 weights cannot go into graphs without weights,
313
+ and the NPU 3720 has no such graphs, so cache and memory are as in 2.1.0 and no fused graphs are used. From Lunar Lake on, use
314
+ the NF4 builds.
315
+ - **Answers slow down in long chats**: the longer the context, the more KV cache every step reads (Panther Lake, Granite 4.2 3B
316
+ NF4: 13.6 tok/s at ~7.3K tokens, 8.8 at ~15K, 4.9 at ~22K - about 2-3x the paged path).
317
+ - **First loads (a small Panther Lake PC, Core Ultra X7 358H, 3.0)**: Granite 4.2 8B NF4 ~12 min, llm-jp-4.1-8B NF4 ~10 min,
318
+ Granite 4.2 3B NF4 ~6.5 min, Qwen3.5-9B NF4 ~7.5 min. The remaining fused graphs compile in the background while you chat
319
+ (~7-10 min up to 4K positions; all of them up to 32K positions ~28 min for Granite 4.2 3B NF4). Later loads come from the cache in well under a minute; if a load is stopped or the PC restarts midway, the
320
+ parts already compiled stay.
 
 
 
 
 
 
 
 
 
 
321
  - **On small Panther Lake PCs MTP decoding is slower than on Lunar Lake**: Qwen3.5-9B (NF4) + MTP decodes at 13.6-14.3 tok/s
322
  there (Lunar Lake ~19 tok/s). MTP does a lot of CPU-side work, and the small PC's CPU drops to its base clock under its power
323
  limit; being looked into.
TECHNICAL.md CHANGED
@@ -412,6 +412,52 @@ dense モデルのプロンプトは 16 トークンのブロックで処理し
412
  - 種類の切り替え:会話が 1K・2K 位置を超えるとき、4K を超えて従来の経路に戻るとき、長い会話の後に短い会話に戻るとき、グラフを読み直します(Panther Lake の Granite 4.2 8B で 1 サイズ 6〜10 秒、NPU 3720 の 3B で約 3 秒)。この時間はプロンプトの処理時間に入ります。
413
  - **NF4 版(形式 C)**:Lunar Lake 以降向けの Granite 4.2 3B/8B、llm-jp-4.1-8B は、重みをチャネル単位の NF4 にし、q/k/v_proj と down_proj だけチャネル単位の INT8 にしました。いくつかの組み合わせ(全部 NF4、q/k/v だけ INT8、グループ 256 の NF4)を教師強制の精度で比べ、精度と速さの釣り合いでこれを選びました(512 トークンで 1 位の一致率/KL:Granite 4.2 8B 88.5%/0.097、INT4 版 85.5%/0.132)。グラフは 2 トークン用と 64 トークン用だけです(NPU 4000 以降は 2 トークン用で回答します)。
414
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  ## 1 ステップのホスト側の処理(onw 1.4)
416
 
417
  MTP の 1 ブロックのうち、NPU 以外(Python・numpy)が約 47 ms ありました。生成結果を 1 ビットも変えずに、次のことで約 32 ms に
 
412
  - 種類の切り替え:会話が 1K・2K 位置を超えるとき、4K を超えて従来の経路に戻るとき、長い会話の後に短い会話に戻るとき、グラフを読み直します(Panther Lake の Granite 4.2 8B で 1 サイズ 6〜10 秒、NPU 3720 の 3B で約 3 秒)。この時間はプロンプトの処理時間に入ります。
413
  - **NF4 版(形式 C)**:Lunar Lake 以降向けの Granite 4.2 3B/8B、llm-jp-4.1-8B は、重みをチャネル単位の NF4 にし、q/k/v_proj と down_proj だけチャネル単位の INT8 にしました。いくつかの組み合わせ(全部 NF4、q/k/v だけ INT8、グループ 256 の NF4)を教師強制の精度で比べ、精度と速さの釣り合いでこれを選びました(512 トークンで 1 位の一致率/KL:Granite 4.2 8B 88.5%/0.097、INT4 版 85.5%/0.132)。グラフは 2 トークン用と 64 トークン用だけです(NPU 4000 以降は 2 トークン用で回答します)。
414
 
415
+ ## onw の NPU キット:重みを持たないグラフと重みの共有(onw 3.0、Windows・Linux)
416
+
417
+ onw 3.0 は、Intel が公開している NPU コンパイラー(npu_compiler)と OpenVINO の NPU プラグインのソースに手を入れて作り直したものを
418
+ `onw/npukit` に同梱しています(Apache 2.0、変更点は `onw/npukit/patches`)。プラグインは onw が入れる OpenVINO 2026.5.0b1
419
+ (`2026.5.0-23160-6db412d0a27`)と同じコミットから作ってあり、onw は `ov.Core(onw/npukit/plugins.xml)` で「NPU」をこのプラグインに
420
+ 向けます。インストール済みの OpenVINO のファイルは変えません。版が違うときは使わず、OpenVINO 自身の NPU プラグインに戻ります。
421
+ Linux 用(Ubuntu 24.04 で作った .so)は `onw/npukit/linux/npukit-linux.tar.xz`(32 MB)にまとめてあり、最初の起動で同じフォルダーに
422
+ 展開します。Linux の OpenVINO にはプラグイン側のコンパイラーが入っていないので、キットのない Linux では重みを持たないグラフを作れません
423
+ (ドライバー内蔵のコンパイラーを使うため)。NPU 3720(Meteor Lake・Arrow Lake)では使いません(重みを持たないグラフに対応しておらず、
424
+ OpenVINO 自身のコンパイラーのほうが約 7% 速いグラフを作るため。`ONW_NPUKIT=1` で強制)。
425
+
426
+ - **重みを持たないグラフ**:`CACHE_MODE=OPTIMIZE_SIZE` と `ENABLE_WEIGHTLESS` でコンパイルすると、コンパイラーはグラフを
427
+ 「重みを NPU 用の並びに組み立てる初期化スケジュール」と「本体」に分け、重みを含めずに書き出します。ロード時に同じ ov.Model を
428
+ `MODEL_PTR` で渡すと、モデルのファイル(.bin の写像)から重みを読み、NPU 上で組み立てて本体の入力につなぎます。組み立て済みの
429
+ 重みを入力として渡しても、重みを含めたグラフと同じ速さで、計算結果もビット単位で同じでした。
430
+ - OpenVINO 2026.5 のコンパイラー(8.4)は NF4 の重みでこれに失敗します(「ui4 → quantile の変換は未対応」)。キットの
431
+ コンパイラーでは、NF4(quantile 型と、それを格納型にした量子化型)を格納型 ui4 のまま読み替えるようにし、4 ビット値の
432
+ クラスタ分割・並べ替えがバイト境界をまたがないようにしました(初期化スケジュールの中だけ)。
433
+ - 2 ビットの重み(Bonsai)は重みが分離されず(グラフの大きさがほぼ変わらない)、コンパイルが約 7 倍遅くなるので、使いません。
434
+ - INT4 の重みも分離されません(Granite 4.2 3B INT4 の 1 トークンの部品で 63.5 対 63.6 MB)。2 行以上のグラフでは、NPU 4000 以降の
435
+ コンパイラーが INT4 を表引き(4 ビットの値をそのまま表の番号にする)に変える処理と重ならず失敗し、直してもコンパイルが 20〜35 倍
436
+ 遅くなるだけなので、INT4 のモデル(NPUW を使わない区間分割のもの)では使いません。融合グラフも、種類ごとに重みを持つことに
437
+ なるので既定でオフです(2.1.0 と同じ動作。Lunar Lake の 16 GB ではメモリーが足りなくなりました)。INT8 は分離されます
438
+ (llm-jp-4.1-8B INT8 の部品で 2.5 対 216 MB)。Lunar Lake 以降では NF4 版を勧めます。
439
+ - **重みの共有**:重みを持たないグラフでも、グラフごとに NPU 上へ重みを組み立てるので、融合グラフの長さ違い(1K〜32K 位置)や
440
+ ブロックサイズの数だけ重みが増えます(8B NF4 で 1 種類あたり約 4.8 GB)。キットのプラグインは、初期化スケジュールを走らせた
441
+ あと、出力した重みのバッファを大きさ・標本ハッシュ・全バイト比較で既存のものと照合し、すべて一致したら本体をそちらにつないで
442
+ 新しいバッファを捨てます(`ONW_WS_SHARE=0` で無効)。区切り方の違うグラフどうし(区間を分けた従来の経路と融合グラフ)でも共有
443
+ できるよう、初期化スケジュールの出力を重みごとに分けます(コンパイラーの `ONW_WS_NO_CONCAT`。OpenVINO は既定で 1 本にまとめます)。
444
+ - Panther Lake の llm-jp-4.1-8B NF4:融合グラフ 6 種類を同時に読み込んで +5.7 GB(共有なしなら約 29 GB)。
445
+ - 共有したまま、ある種類のグラフを捨てて別の種類を読み込むと、NPU ドライバーの中で解放済みの領域への書き込みが起きることが
446
+ あるので、3.0 は融合グラフの全種類と従来の経路のグラフを読み込んだままにします(`ONW_FUSED_KEEP`。省メモリモードでは従来どおり
447
+ 使う長さのグラフだけ)。全種類を持つので、融合グラフは文脈の長さまで 8K・16K・32K 位置の種類も作ります(2.1.0 は 4K まで)。
448
+ 回答もプロンプトも、そのときの位置を覆う一番短い種類で計算し、位置が進むと次の種類に切り替えます(長い種類ほど毎回読む
449
+ KV キャッシュの範囲が広く、遅くなるため)。
450
+ - **コンパイラーのその他の修正**:
451
+ - Qwen3.5 系 NF4 の 4 トークンのグラフ(MTP 先読みの検証用)が `'async.await' has non 'return' consumer` で失敗していたのを修正
452
+ (関数の結果の手前に形だけ変える操作が残る場合)。これがないと MTP が使えません。
453
+ - 2 ビットのゼロ点付き重み(Bonsai)を 2 行以上で計算すると 0 になる不具合(2.x で報告済み)の修正。
454
+ - コンパイル時間:並列コンパイルが Windows のヒープのロックで直列になっていた(分散情報のマップが要素 1 つでも約 45 KB を確保)のを
455
+ 直し、定数の畳み込みと縦方向の融合の探索の結果を使い回すようにしました。出力はビット単位で同じです(Qwen3.5-9B の 64 トークンの
456
+ 部品 52.6 → 34 秒など)。
457
+
458
+ 結果(Panther Lake、2.1.0 の既定との比較)は CHANGELOG の v3.0.0 を見てください。キャッシュはモデル本体の 1/10 以下、専用メモリは
459
+ 数分の一になり、生成・プロンプトとも速くなります。
460
+
461
  ## 1 ステップのホスト側の処理(onw 1.4)
462
 
463
  MTP の 1 ブロックのうち、NPU 以外(Python・numpy)が約 47 ms ありました。生成結果を 1 ビットも変えずに、次のことで約 32 ms に
TECHNICAL_en.md CHANGED
@@ -435,6 +435,52 @@ The paged KV cache (onw 0.10) cuts each segment at its attention layers. A dense
435
  - Switching variants: when a chat passes 1K / 2K positions, passes 4K (back to the previous path), or a short chat follows a long one, graphs are read again (Granite 4.2 8B on Panther Lake: 6-10 s per block size; Granite 4.2 3B on the NPU 3720 ~3 s). That time shows up in the prompt time.
436
  - **The NF4 builds (format C)**: Granite 4.2 3B / 8B and llm-jp-4.1-8B for Lunar Lake and later have channel-wise NF4 weights with only q/k/v_proj and down_proj as channel-wise INT8. Several mixes (all NF4, only q/k/v INT8, group-256 NF4) were compared on teacher-forced accuracy, and this one was the best balance of accuracy and speed (top-1 agreement / KL on 512 tokens: Granite 4.2 8B 88.5% / 0.097, the INT4 build 85.5% / 0.132). They carry 2- and 64-token graphs only (NPU 4000+ answers on the 2-token ones).
437
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  ## Host-side work per step (onw 1.4)
439
 
440
  Of an MTP block, ~47 ms was Python / numpy. Without changing a single output bit, it is now ~32 ms (the same tokens as
 
435
  - Switching variants: when a chat passes 1K / 2K positions, passes 4K (back to the previous path), or a short chat follows a long one, graphs are read again (Granite 4.2 8B on Panther Lake: 6-10 s per block size; Granite 4.2 3B on the NPU 3720 ~3 s). That time shows up in the prompt time.
436
  - **The NF4 builds (format C)**: Granite 4.2 3B / 8B and llm-jp-4.1-8B for Lunar Lake and later have channel-wise NF4 weights with only q/k/v_proj and down_proj as channel-wise INT8. Several mixes (all NF4, only q/k/v INT8, group-256 NF4) were compared on teacher-forced accuracy, and this one was the best balance of accuracy and speed (top-1 agreement / KL on 512 tokens: Granite 4.2 8B 88.5% / 0.097, the INT4 build 85.5% / 0.132). They carry 2- and 64-token graphs only (NPU 4000+ answers on the 2-token ones).
437
 
438
+ ## onw's NPU kit: graphs without weights and shared weights (onw 3.0, Windows and Linux)
439
+
440
+ onw 3.0 ships in `onw/npukit` an NPU compiler and an NPU plugin rebuilt from Intel's open sources (npu_compiler and OpenVINO's NPU
441
+ plugin; Apache 2.0, changes in `onw/npukit/patches`). The plugin is built from the very commit of the OpenVINO 2026.5.0b1 that onw
442
+ installs (`2026.5.0-23160-6db412d0a27`); onw points the "NPU" device at it with `ov.Core(onw/npukit/plugins.xml)`, so no file of the
443
+ installed OpenVINO changes. With another OpenVINO build it is not used and OpenVINO's own NPU plugin serves as before. The Linux
444
+ build (.so files made on Ubuntu 24.04) comes packed as `onw/npukit/linux/npukit-linux.tar.xz` (32 MB) and is unpacked next to it at
445
+ the first start; Linux OpenVINO has no plugin-side compiler, so without the kit no graphs without weights can be made there (the
446
+ driver's own compiler is used). Not used on the NPU 3720 (Meteor / Arrow Lake): no graphs without weights there, and OpenVINO's own
447
+ compiler makes ~7% faster graphs for it (`ONW_NPUKIT=1` forces it).
448
+
449
+ - **Graphs without weights**: compiled with `CACHE_MODE=OPTIMIZE_SIZE` and `ENABLE_WEIGHTLESS`, a graph is split into an init
450
+ schedule (which lays the weights out for the NPU) and the main one, and written without its weights. At load, the same ov.Model
451
+ given as `MODEL_PTR` supplies the weights from the model's files (the mapped .bin); the NPU rebuilds them and they become the main
452
+ schedule's inputs. Passed as inputs, the rebuilt weights run as fast as weights inside the graph, with bit-identical results.
453
+ - OpenVINO 2026.5's compiler (8.4) fails this with NF4 weights ("ui4 -> quantile convert not supported"). The kit's compiler
454
+ reinterprets NF4 (the quantile type and quantized types stored as quantile) through its ui4 storage, and keeps cluster splits and
455
+ permutes of 4-bit values on byte boundaries (inside init schedules only).
456
+ - 2-bit weights (Bonsai) are not separated (the graph keeps its size) and compile ~7x slower, so they are left as they are.
457
+ - Nor are INT4 weights (a Granite 4.2 3B INT4 1-token part: 63.5 vs 63.6 MB). With 2+ rows the NPU 4000+ compiler's rewrite of INT4
458
+ into a lookup table (the 4-bit value used as the table index) clashes with it and fails; fixed, it only compiles 20-35x slower -
459
+ so INT4 models (the paged ones, without NPUW) do not use it, and the fused graphs stay off by default for them (each variant
460
+ would hold the weights again; the 16 GB Lunar Lake ran out of memory): the same as 2.1.0. INT8 is separated (an llm-jp-4.1-8B
461
+ INT8 part: 2.5 vs 216 MB). From Lunar Lake on, the NF4 models are the ones to use.
462
+ - **Shared weights**: graphs without weights still rebuild their weights on the NPU each, so every fused-graph length (1K to 32K
463
+ positions) and block size adds a copy (~4.8 GB each for an 8B NF4). After an init schedule has run, the kit's plugin matches its
464
+ output buffers against the existing ones (size, a sampled hash, then all bytes); when all match, the main schedule is bound to
465
+ those and the new buffer is released (`ONW_WS_SHARE=0` turns it off). So that graphs cut differently (the paged parts and the fused
466
+ graphs) can share, the init outputs are kept per weight (the compiler's `ONW_WS_NO_CONCAT`; OpenVINO concatenates them into one).
467
+ - Panther Lake, llm-jp-4.1-8B NF4: six fused variants loaded at once take +5.7 GB (~29 GB without sharing).
468
+ - Dropping one variant and loading another while their weights are shared can make the NPU driver write into released memory, so
469
+ 3.0 keeps every fused variant and the paged graphs loaded (`ONW_FUSED_KEEP`; the memory-saving mode keeps loading only the length
470
+ in use). With every variant kept, the fused graphs go on up to the context: 8K / 16K / 32K positions too (2.1.0: up to 4K). Answers
471
+ and prompts run in the shortest variant covering the current position and move to the next one as it grows (a longer variant
472
+ reads a longer stretch of the KV cache every step).
473
+ - **Other compiler fixes**:
474
+ - The Qwen3.5 NF4 4-token graphs (MTP verification) failed with `'async.await' has non 'return' consumer` (a shape-only op left in
475
+ front of the function result) - without them no MTP.
476
+ - 2-bit weights with zero points (Bonsai) computing zeros with 2+ rows.
477
+ - Compile time: parallel compiles serialised on the Windows heap lock (a distribution map allocated ~45 KB even for one entry);
478
+ constant folding and the vertical-fusion search now reuse their results. Bit-identical output (a Qwen3.5-9B 64-token part
479
+ 52.6 -> 34 s, ...).
480
+
481
+ For the results (Panther Lake against 2.1.0's default) see v3.0.0 in the CHANGELOG: caches a tenth of the model or less, a fraction
482
+ of the private memory, faster answers and prompts.
483
+
484
  ## Host-side work per step (onw 1.4)
485
 
486
  Of an MTP block, ~47 ms was Python / numpy. Without changing a single output bit, it is now ~32 ms (the same tokens as
install.ps1 CHANGED
@@ -25,6 +25,7 @@ $info = Get-WithRetry "https://huggingface.co/api/models/$repo" $null
25
  foreach ($s in $info.siblings) {
26
  $f = $s.rfilename
27
  if ($f -eq ".gitattributes") { continue }
 
28
  $out = Join-Path $dest ($f -replace "/", "\")
29
  New-Item -ItemType Directory -Force -Path (Split-Path $out) | Out-Null
30
  Get-WithRetry "https://huggingface.co/$repo/resolve/main/$f" $out
 
25
  foreach ($s in $info.siblings) {
26
  $f = $s.rfilename
27
  if ($f -eq ".gitattributes") { continue }
28
+ if ($f.StartsWith("onw/npukit/linux/")) { continue } # (the NPU kit for Linux)
29
  $out = Join-Path $dest ($f -replace "/", "\")
30
  New-Item -ItemType Directory -Force -Path (Split-Path $out) | Out-Null
31
  Get-WithRetry "https://huggingface.co/$repo/resolve/main/$f" $out
install.sh CHANGED
@@ -28,6 +28,7 @@ def fetch(url, tries=4): # the hub redirects to a CDN; a re
28
 
29
  info = json.loads(fetch(f"https://huggingface.co/api/models/{repo}"))
30
  files = [s["rfilename"] for s in info["siblings"] if s["rfilename"] != ".gitattributes"]
 
31
  for f in files:
32
  out = os.path.join(dest, f)
33
  os.makedirs(os.path.dirname(out), exist_ok=True)
 
28
 
29
  info = json.loads(fetch(f"https://huggingface.co/api/models/{repo}"))
30
  files = [s["rfilename"] for s in info["siblings"] if s["rfilename"] != ".gitattributes"]
31
+ files = [f for f in files if not (f.startswith("onw/npukit/") and f.endswith(".dll"))] # (the NPU kit for Windows)
32
  for f in files:
33
  out = os.path.join(dest, f)
34
  os.makedirs(os.path.dirname(out), exist_ok=True)
onw/__init__.py CHANGED
@@ -2,4 +2,4 @@
2
 
3
  A small engine that runs LLMs entirely on the Intel NPU: static segment graphs + host-managed state.
4
  """
5
- __version__ = "2.1.0"
 
2
 
3
  A small engine that runs LLMs entirely on the Intel NPU: static segment graphs + host-managed state.
4
  """
5
+ __version__ = "3.0.0"
onw/app.py CHANGED
@@ -32,15 +32,15 @@ CATALOG = [ # repo, title, takes images, download GB, decode speed (NPU 3720),
32
  "エージェント・ツール呼び出し向けの軽量モデル(Qwen3.5-4B ベース)"),
33
  ("ryugyosoft/granite-4.2-3b-onw", "Granite 4.2 3B", False, 2.1, "7.7 tok/s",
34
  "IBM の軽量モデル。思考とツール呼び出しに対応(日本語はやや弱め)"),
35
- ("ryugyosoft/granite-4.2-3b-nf4-onw", "Granite 4.2 3B (NF4)", False, 2.6, "14 tok/s",
36
  "Lunar Lake 以降専用の高速版(重み NF4、アテンションの入力と down_proj は INT8。INT4 版より高精度)"),
37
  ("ryugyosoft/granite-4.2-8b-onw", "Granite 4.2 8B", False, 5.0, "3.9 tok/s",
38
  "IBM の 8B。思考とツール呼び出し・コーディング向け(メモリ 32 GB 以上推奨)"),
39
- ("ryugyosoft/granite-4.2-8b-nf4-onw", "Granite 4.2 8B (NF4)", False, 6.2, "9 tok/s",
40
  "Lunar Lake 以降専用の高速版(重み NF4、アテンションの入力と down_proj は INT8。INT4 版より高精度)"),
41
  ("ryugyosoft/llm-jp-4.1-8b-thinking-onw", "llm-jp-4.1-8B-thinking", False, 4.6, "4.5 tok/s",
42
  "国立情報学研究所の日本語モデル。考えてから答えます(思考モードで思考を表示)"),
43
- ("ryugyosoft/llm-jp-4.1-8b-thinking-nf4-onw", "llm-jp-4.1-8B-thinking (NF4)", False, 6.1, "9.9 tok/s",
44
  "Lunar Lake 以降専用の高速版(重み NF4、アテンションの入力と down_proj は INT8。INT4 版より高精度)"),
45
  ("ryugyosoft/llm-jp-4.1-8b-thinking-int8-onw", "llm-jp-4.1-8B-thinking (INT8)", False, 7.7, "4.1 tok/s",
46
  "llm-jp の高精度版(重み INT8。メモリ 24 GB 以上向け、16 GB の PC はメモリ節約モードで)"),
@@ -358,11 +358,30 @@ def context_of(repo, cfg):
358
  return min(want, top) if top else want
359
 
360
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  def memory_full(repo, cfg, ram):
362
  """GB this model needs with the whole expert bank in memory (context length, 64-token blocks), or None."""
363
  if repo not in MEMORY:
364
  return None
365
- base, s64, kv = MEMORY[repo]
366
  return base + (s64 if big_blocks_on(cfg, ram) else 0.0) + kv * max(context_of(repo, cfg) - 1024, 0) / 1024 / 1024
367
 
368
 
@@ -423,8 +442,9 @@ def npu_arch():
423
  if NPU_ARCH is None:
424
  try:
425
  import openvino as ov
 
426
  from .runtime import npu_arch as arch
427
- NPU_ARCH = arch(ov.Core())
428
  except Exception:
429
  NPU_ARCH = 0
430
  return NPU_ARCH
 
32
  "エージェント・ツール呼び出し向けの軽量モデル(Qwen3.5-4B ベース)"),
33
  ("ryugyosoft/granite-4.2-3b-onw", "Granite 4.2 3B", False, 2.1, "7.7 tok/s",
34
  "IBM の軽量モデル。思考とツール呼び出しに対応(日本語はやや弱め)"),
35
+ ("ryugyosoft/granite-4.2-3b-nf4-onw", "Granite 4.2 3B (NF4)", False, 2.6, "23 tok/s",
36
  "Lunar Lake 以降専用の高速版(重み NF4、アテンションの入力と down_proj は INT8。INT4 版より高精度)"),
37
  ("ryugyosoft/granite-4.2-8b-onw", "Granite 4.2 8B", False, 5.0, "3.9 tok/s",
38
  "IBM の 8B。思考とツール呼び出し・コーディング向け(メモリ 32 GB 以上推奨)"),
39
+ ("ryugyosoft/granite-4.2-8b-nf4-onw", "Granite 4.2 8B (NF4)", False, 6.2, "13 tok/s",
40
  "Lunar Lake 以降専用の高速版(重み NF4、アテンションの入力と down_proj は INT8。INT4 版より高精度)"),
41
  ("ryugyosoft/llm-jp-4.1-8b-thinking-onw", "llm-jp-4.1-8B-thinking", False, 4.6, "4.5 tok/s",
42
  "国立情報学研究所の日本語モデル。考えてから答えます(思考モードで思考を表示)"),
43
+ ("ryugyosoft/llm-jp-4.1-8b-thinking-nf4-onw", "llm-jp-4.1-8B-thinking (NF4)", False, 6.1, "14 tok/s",
44
  "Lunar Lake 以降専用の高速版(重み NF4、アテンションの入力と down_proj は INT8。INT4 版より高精度)"),
45
  ("ryugyosoft/llm-jp-4.1-8b-thinking-int8-onw", "llm-jp-4.1-8B-thinking (INT8)", False, 7.7, "4.1 tok/s",
46
  "llm-jp の高精度版(重み INT8。メモリ 24 GB 以上向け、16 GB の PC はメモリ節約モードで)"),
 
358
  return min(want, top) if top else want
359
 
360
 
361
+ # [3.0] with onw's NPU kit (Windows and Linux, NPU 4000+): weightless graphs and one copy of the weights for every graph - peak
362
+ # working set measured on Panther Lake with every fused variant loaded (onw 3.0)
363
+ MEMORY_KIT = {
364
+ "ryugyosoft/granite-4.2-3b-nf4-onw": (4.8, 0.0, 80),
365
+ "ryugyosoft/granite-4.2-8b-nf4-onw": (9.8, 0.0, 80),
366
+ "ryugyosoft/llm-jp-4.1-8b-thinking-nf4-onw": (9.7, 0.0, 64),
367
+ "ryugyosoft/Qwen3.5-9B-nf4-onw": (6.0, 0.0, 16),
368
+ "ryugyosoft/Ornith-1.5-9B-nf4-onw": (6.0, 0.0, 16),
369
+ }
370
+
371
+
372
+ def _kit():
373
+ try:
374
+ from . import npukit
375
+ return npukit.active() and npu_arch() >= 4000
376
+ except Exception:
377
+ return False
378
+
379
+
380
  def memory_full(repo, cfg, ram):
381
  """GB this model needs with the whole expert bank in memory (context length, 64-token blocks), or None."""
382
  if repo not in MEMORY:
383
  return None
384
+ base, s64, kv = MEMORY_KIT[repo] if repo in MEMORY_KIT and _kit() else MEMORY[repo]
385
  return base + (s64 if big_blocks_on(cfg, ram) else 0.0) + kv * max(context_of(repo, cfg) - 1024, 0) / 1024 / 1024
386
 
387
 
 
442
  if NPU_ARCH is None:
443
  try:
444
  import openvino as ov
445
+ from . import npukit
446
  from .runtime import npu_arch as arch
447
+ NPU_ARCH = arch(npukit.core())
448
  except Exception:
449
  NPU_ARCH = 0
450
  return NPU_ARCH
onw/chat.py CHANGED
@@ -5,6 +5,7 @@ import re
5
  import hashlib, json, os, time
6
  import numpy as np
7
  import openvino as ov
 
8
  from transformers import AutoTokenizer
9
 
10
  from .runtime import Cancelled, SegmentedModel, all_sizes, grouped_wanted
@@ -112,7 +113,7 @@ class ChatEngine:
112
  _cancel = None # should_stop of the request whose prompt is being fed
113
 
114
  def __init__(self, engine_dir, device=None, pld=True, cache_dir=None, context=None):
115
- core = ov.Core()
116
  device = device or ("NPU" if npu_available(core) else "CPU")
117
  cfg = {"CACHE_DIR": cache_dir or os.path.join(engine_dir, "npu_cache")} if device == "NPU" else \
118
  {"INFERENCE_PRECISION_HINT": "f32"}
@@ -787,9 +788,14 @@ class ChatEngine:
787
  # [2.0.4] the fused graphs long enough for this prompt and its answer, set before the prompt runs (its last
788
  # block of 1-2 tokens goes to the answer graphs: a switch there would load them twice)
789
  sizes = self.model._fused_sizes
 
 
 
 
790
  if len(sizes) > 1 and n - reuse > 4:
791
- self.model.fused_prepare(n, max(sizes))
792
- self.model.fused_prepare(n + min(max_new_tokens, 1024), min(sizes))
 
793
  try:
794
  if B > reuse:
795
  self._prefill(lids, reuse, B, embeds, pos, need_logits=False)
@@ -816,8 +822,9 @@ class ChatEngine:
816
  self.model.swap_to(1, drop=16)
817
  elif swap: # memory-saving mode: the 1-token graphs for the answer
818
  self.model.start_size(1, need_gb=self.swap_gb + 1.0, then_drop=16)
819
- if not swap: # [2.0.4] fused decode graphs long enough for most answers
820
- self.model.fused_prepare(self.model.cur + min(max_new_tokens, 1024))
 
821
  # exact only for plain greedy; not while block sizes are swapped (the verification blocks go away mid-answer)
822
  use_pld = self.pld and plain_greedy and self.model.S > 1 and not swap and budget is None
823
  # MTP drafts (Qwen3.5 dense): every sampling setting (exact), the thinking budget included
 
5
  import hashlib, json, os, time
6
  import numpy as np
7
  import openvino as ov
8
+ from . import npukit
9
  from transformers import AutoTokenizer
10
 
11
  from .runtime import Cancelled, SegmentedModel, all_sizes, grouped_wanted
 
113
  _cancel = None # should_stop of the request whose prompt is being fed
114
 
115
  def __init__(self, engine_dir, device=None, pld=True, cache_dir=None, context=None):
116
+ core = npukit.core()
117
  device = device or ("NPU" if npu_available(core) else "CPU")
118
  cfg = {"CACHE_DIR": cache_dir or os.path.join(engine_dir, "npu_cache")} if device == "NPU" else \
119
  {"INFERENCE_PRECISION_HINT": "f32"}
 
788
  # [2.0.4] the fused graphs long enough for this prompt and its answer, set before the prompt runs (its last
789
  # block of 1-2 tokens goes to the answer graphs: a switch there would load them twice)
790
  sizes = self.model._fused_sizes
791
+ # ([3.0] every variant kept loaded: the shortest one for now - a longer one takes over at no cost once the
792
+ # prompt / the answer passes it (_step); a variant longer than needed reads its whole cache every block:
793
+ # a 21.8K-token prompt all in the 32K graphs ran at 30 tok/s)
794
+ keep = self.model.fused_keep_all()
795
  if len(sizes) > 1 and n - reuse > 4:
796
+ self.model.fused_prepare(reuse + max(sizes) if keep else n, max(sizes))
797
+ ahead = 64 if keep else min(max_new_tokens, 1024)
798
+ self.model.fused_prepare(n + ahead, min(sizes))
799
  try:
800
  if B > reuse:
801
  self._prefill(lids, reuse, B, embeds, pos, need_logits=False)
 
822
  self.model.swap_to(1, drop=16)
823
  elif swap: # memory-saving mode: the 1-token graphs for the answer
824
  self.model.start_size(1, need_gb=self.swap_gb + 1.0, then_drop=16)
825
+ if not swap and getattr(self.model, "_fused_sizes", None): # [2.0.4] fused decode graphs for most answers
826
+ ahead = 64 if self.model.fused_keep_all() else min(max_new_tokens, 1024)
827
+ self.model.fused_prepare(self.model.cur + ahead)
828
  # exact only for plain greedy; not while block sizes are swapped (the verification blocks go away mid-answer)
829
  use_pld = self.pld and plain_greedy and self.model.S > 1 and not swap and budget is None
830
  # MTP drafts (Qwen3.5 dense): every sampling setting (exact), the thinking budget included
onw/fused.py CHANGED
@@ -196,7 +196,21 @@ def wanted(m):
196
  [2.1.0] off by default - every variant's blob holds its own copy of the weights (Granite 4.2 8B NF4: ~42 GB of
197
  cache against ~11 GB for the paged graphs); ONW_FUSED=1: on; ONW_FUSED_PROMPT=0: answers only)."""
198
  import os
199
- if os.environ.get("ONW_FUSED", "0") != "1" or m._ctx["device"] != "NPU" or not m.paged or m.kv is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  return None
201
  if m.E or m.predict_k or m.window or m.ring or not m.segs:
202
  return None
@@ -244,7 +258,8 @@ class FusedDecode:
244
  cfg = {k: v for k, v in c["config"].items() if k != "CACHE_DIR"}
245
  cache = c["config"].get("CACHE_DIR")
246
  npuw = cfg.get("NPU_USE_NPUW") == "YES"
247
- self.plan, self.common, producer, bins = [], {}, {}, {} # (bins: the mapped weight files, alive while building)
 
248
  self.mask_kv_t = mk(ov.Type.f16, [1, self.Ct])
249
  self.mask_self_t = mk(ov.Type.f16, [S, S])
250
  t0, built = time.time(), 0
@@ -286,24 +301,30 @@ class FusedDecode:
286
  log(f"[fused] {name} ({self.Ct} positions, NPUW): ready in {time.time() - t:.0f}s", flush=True)
287
  built += 1
288
  elif cache:
289
- bp = blob_path(cache, f"fused-{name[:-4]}-B{B}", [VERSION, c["tried"].key, _signature(model),
290
- sorted(cfg.items()), self.Cs])
 
291
  lk = _lock(bp) if bp and cm is None else None
292
  if lk is not None:
293
  lk.acquire() # (the background may be compiling this graph: wait for it)
294
  try:
295
- if bp and os.path.exists(bp):
296
- USED_BLOBS.add(bp)
 
 
 
297
  if not compile_only:
298
  try:
299
- cm = import_blob(core, bp, device, cfg)
300
  except (RuntimeError, OSError):
301
  cm = None
302
- if cm is None and compile_only:
303
- del model
304
- continue
 
305
  if cm is None and not npuw:
306
- cm = self._compile(core, model, device, cfg, bp, f"{name} ({self.Ct} positions)", compile_only, log)
 
307
  built += 1
308
  finally:
309
  if lk is not None:
@@ -352,16 +373,39 @@ class FusedDecode:
352
  f"({built} compiled) in {time.time() - t0:.0f}s", flush=True)
353
  self._mask_pos = None
354
 
 
 
355
  @staticmethod
356
- def _compile(core, model, device, cfg, bp, name, compile_only, log):
357
  """Compile one fused graph; with a blob path: written there, and (unless compile_only) imported back as the
358
  driver's copy only. compile_only: no executor on the device - creating one next to running inference lost the
359
- NPU on Panther Lake (ZE_RESULT_ERROR_DEVICE_LOST). Returns the compiled model, or None (compile_only)."""
 
 
 
 
 
360
  import os, io, time
361
  from .runtime import import_blob, USED_BLOBS
 
 
362
  t = time.time()
363
- cm = core.compile_model(model, device, {**cfg, "NPU_CREATE_EXECUTOR": "0"} if compile_only else cfg)
364
- log(f"[fused] {name}: compiled in {time.time() - t:.0f}s{' (background)' if compile_only else ''}", flush=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  if not bp:
366
  return None if compile_only else cm
367
  os.makedirs(os.path.dirname(bp), exist_ok=True)
@@ -372,7 +416,7 @@ class FusedDecode:
372
  os.replace(f"{bp}.{os.getpid()}.tmp", bp)
373
  del buf, cm
374
  USED_BLOBS.add(bp)
375
- return None if compile_only else import_blob(core, bp, device, cfg)
376
 
377
  def step(self, ids, want_logits=True, embeds=None):
378
  m, S, n = self.m, self.S, len(ids)
 
196
  [2.1.0] off by default - every variant's blob holds its own copy of the weights (Granite 4.2 8B NF4: ~42 GB of
197
  cache against ~11 GB for the paged graphs); ONW_FUSED=1: on; ONW_FUSED_PROMPT=0: answers only)."""
198
  import os
199
+ # [3.0] on by default with onw's NPU kit (weightless blobs: the variants cost no disk, their weights are shared)
200
+ # (not on the NPU 3720: no weightless blobs there - every variant would hold its own weights)
201
+ from . import npukit
202
+ from .runtime import npu_arch
203
+ if m._ctx["device"] != "NPU" or not m.paged or m.kv is None:
204
+ return None
205
+ # (nor for 2-bit weights: they stay inside the blobs, so the fused graphs would hold a second copy - Ternary Bonsai 2
206
+ # 9B: 7.1 GB private instead of ~3.5)
207
+ # (nor when this model's graphs keep their weights - INT4 (runtime.int4_weights), or a part the compiler could not
208
+ # build without them: every variant would carry the weights again; Lunar Lake 16 GB, Granite 4.2 3B INT4 with the
209
+ # fused graphs ran out of memory)
210
+ from .runtime import _WEIGHTLESS
211
+ on = (npukit.active() and npu_arch(m._ctx["core"]) >= 4000 and m.meta.get("needs") != "u2"
212
+ and not _WEIGHTLESS.get("skip_model") and _WEIGHTLESS["ok"] is not False and not _WEIGHTLESS.get("full"))
213
+ if os.environ.get("ONW_FUSED", "1" if on else "0") != "1":
214
  return None
215
  if m.E or m.predict_k or m.window or m.ring or not m.segs:
216
  return None
 
258
  cfg = {k: v for k, v in c["config"].items() if k != "CACHE_DIR"}
259
  cache = c["config"].get("CACHE_DIR")
260
  npuw = cfg.get("NPU_USE_NPUW") == "YES"
261
+ self.plan, self.common, producer, bins = [], {}, {}, {}
262
+ bp = bpw = None # (bins: the mapped weight files, alive while building)
263
  self.mask_kv_t = mk(ov.Type.f16, [1, self.Ct])
264
  self.mask_self_t = mk(ov.Type.f16, [S, S])
265
  t0, built = time.time(), 0
 
301
  log(f"[fused] {name} ({self.Ct} positions, NPUW): ready in {time.time() - t:.0f}s", flush=True)
302
  built += 1
303
  elif cache:
304
+ key = [VERSION, c["tried"].key, _signature(model), sorted(cfg.items()), self.Cs]
305
+ bp = blob_path(cache, f"fused-{name[:-4]}-B{B}", key)
306
+ bpw = blob_path(cache, f"fused-{name[:-4]}-B{B}-wl", key)
307
  lk = _lock(bp) if bp and cm is None else None
308
  if lk is not None:
309
  lk.acquire() # (the background may be compiling this graph: wait for it)
310
  try:
311
+ # a weightless blob (the weights come from this model's constants at import) first, then a full one
312
+ for path, wl in ((bpw, True), (bp, False)) if bp else ():
313
+ if not os.path.exists(path):
314
+ continue
315
+ USED_BLOBS.add(path)
316
  if not compile_only:
317
  try:
318
+ cm = import_blob(core, path, device, {**cfg, "MODEL_PTR": model} if wl else cfg)
319
  except (RuntimeError, OSError):
320
  cm = None
321
+ break
322
+ if cm is None and compile_only and bp and (os.path.exists(bpw) or os.path.exists(bp)):
323
+ del model
324
+ continue
325
  if cm is None and not npuw:
326
+ cm = self._compile(core, model, device, cfg, bp, bpw, f"{name} ({self.Ct} positions)",
327
+ compile_only, log)
328
  built += 1
329
  finally:
330
  if lk is not None:
 
373
  f"({built} compiled) in {time.time() - t0:.0f}s", flush=True)
374
  self._mask_pos = None
375
 
376
+ _weightless = None # (False once a weightless compile failed in this process)
377
+
378
  @staticmethod
379
+ def _compile(core, model, device, cfg, bp, bpw, name, compile_only, log):
380
  """Compile one fused graph; with a blob path: written there, and (unless compile_only) imported back as the
381
  driver's copy only. compile_only: no executor on the device - creating one next to running inference lost the
382
+ NPU on Panther Lake (ZE_RESULT_ERROR_DEVICE_LOST). Returns the compiled model, or None (compile_only).
383
+ Weightless first (bpw): the blob keeps no weights (CACHE_MODE=OPTIMIZE_SIZE + ENABLE_WEIGHTLESS; the import
384
+ rebuilds them on the NPU from this model's constants, MODEL_PTR) - a fused graph holds a whole segment's
385
+ weights, ~1.2 GB each for an 8B model, so every variant would add a copy of the model to the cache otherwise
386
+ (~19 MB weightless). Needs a compiler that supports it for the weight format (onw's own build: NF4 too; pip
387
+ OpenVINO 2026.5: not NF4; NPU 3720: no) - else the full blob as before. ONW_FUSED_WEIGHTLESS=0: off."""
388
  import os, io, time
389
  from .runtime import import_blob, USED_BLOBS
390
+ base = {**cfg, "NPU_CREATE_EXECUTOR": "0"} if compile_only else cfg
391
+ cm, wl = None, False
392
  t = time.time()
393
+ from .runtime import weightless_wanted
394
+ if bpw and FusedDecode._weightless is not False and os.environ.get("ONW_FUSED_WEIGHTLESS", "1") != "0" and weightless_wanted():
395
+ try:
396
+ cm = core.compile_model(model, device, {**base, "CACHE_MODE": "OPTIMIZE_SIZE",
397
+ "ENABLE_WEIGHTLESS": True, "NPU_COMPILER_TYPE": "PLUGIN"})
398
+ wl = FusedDecode._weightless = True
399
+ except RuntimeError as e:
400
+ FusedDecode._weightless = False
401
+ log(f"[fused] weightless blobs not supported here ({' '.join(str(e).split())[-120:]}): full blobs",
402
+ flush=True)
403
+ if cm is None:
404
+ cm = core.compile_model(model, device, base)
405
+ log(f"[fused] {name}: compiled{' weightless' if wl else ''} in {time.time() - t:.0f}s"
406
+ f"{' (background)' if compile_only else ''}", flush=True)
407
+ if wl:
408
+ bp = bpw
409
  if not bp:
410
  return None if compile_only else cm
411
  os.makedirs(os.path.dirname(bp), exist_ok=True)
 
416
  os.replace(f"{bp}.{os.getpid()}.tmp", bp)
417
  del buf, cm
418
  USED_BLOBS.add(bp)
419
+ return None if compile_only else import_blob(core, bp, device, {**cfg, "MODEL_PTR": model} if wl else cfg)
420
 
421
  def step(self, ids, want_logits=True, embeds=None):
422
  m, S, n = self.m, self.S, len(ids)
onw/manager.py CHANGED
@@ -1,685 +1,686 @@
1
- """The onw manager: model list, downloads, server control and settings behind a small local HTTP API, used by the
2
- window (onw/web/manage.html in a webview or a browser). It runs inside the tray process, so downloads keep going when
3
- the window is closed; `onw window` without a tray runs its own copy.
4
-
5
- GET /manage.html the page
6
- GET /api/state everything the page shows (polled once a second)
7
- POST /api/<action> load / unload / restart / download / cancel_download / delete / clear_cache /
8
- folders / settings / open
9
-
10
- Bound to 127.0.0.1 only; every /api call needs the token from <config>/manager.json (X-Onw-Token header or ?token=).
11
- """
12
- import json, os, secrets, shutil, subprocess, sys, threading, time, webbrowser
13
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14
- from urllib.parse import parse_qs, urlparse
15
-
16
- from . import app, cachever, __version__
17
- from .cachever import rmtree
18
- from . import i18n
19
- from .i18n import T
20
-
21
- WEB = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
22
- INFO = lambda: os.path.join(app.config_dir(), "manager.json")
23
- SPEED = {c[0]: c[4] for c in app.CATALOG}
24
- NOTE = {c[0]: c[5] for c in app.CATALOG}
25
- LAST_SEEN = [time.time()] # when the page last asked for the state (a browser-only window keeps us alive)
26
-
27
-
28
- def _files(d):
29
- """{path: (size, mtime)} under a model folder, without the compile cache."""
30
- out = {}
31
- for root, dirs, files in os.walk(d):
32
- dirs[:] = [x for x in dirs if x != "npu_cache"]
33
- for f in files:
34
- p = os.path.join(root, f)
35
- try:
36
- st = os.stat(p)
37
- out[p] = (st.st_size, st.st_mtime_ns)
38
- except OSError:
39
- pass
40
- return out
41
-
42
-
43
- def _to_fetch(siblings, dest):
44
- """Bytes a download into dest still has to fetch: files missing there, or older than the repo's (the hub's own
45
- record of what it downloaded; without it, a size mismatch)."""
46
- try:
47
- from pathlib import Path
48
- from huggingface_hub._local_folder import read_download_metadata
49
- except ImportError:
50
- read_download_metadata = None
51
- n = 0
52
- for s in siblings:
53
- p = os.path.join(dest, s.rfilename)
54
- if not os.path.exists(p):
55
- n += s.size or 0
56
- continue
57
- want = s.lfs.sha256 if s.lfs else s.blob_id
58
- try:
59
- meta = read_download_metadata(Path(dest), s.rfilename) if read_download_metadata else None
60
- except Exception:
61
- meta = None
62
- stale = (meta.etag != want) if meta is not None and want else os.path.getsize(p) != (s.size or 0)
63
- n += (s.size or 0) if stale else 0
64
- return n
65
-
66
-
67
- def _installed_version():
68
- """The version of the onw files on disk now (this tray process may still run an older one: files replaced by the
69
- installer or by hand while it ran - the window warns, as settings it does not know would be dropped)."""
70
- import re
71
- try:
72
- s = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "__init__.py"), encoding="utf8").read()
73
- return re.search(r'__version__\s*=\s*"([^"]+)"', s).group(1)
74
- except (OSError, AttributeError):
75
- return __version__
76
-
77
-
78
- def find_manager():
79
- """{port, token} of a running manager (the tray), or None."""
80
- try:
81
- d = json.load(open(INFO()))
82
- if app._alive(d["pid"], d.get("created")): # (a file left by a crash may name a reused pid)
83
- return d
84
- except (OSError, ValueError, KeyError):
85
- pass
86
- return None
87
-
88
-
89
- class Manager:
90
- def __init__(self):
91
- self.lock = threading.Lock()
92
- self.dl = None # {"repo", "proc", "dest", "total", "frac", "text", "error", "done", "t0"}
93
- self.npu = None
94
- self.ram = app.total_ram_gb()
95
- self.busy = "" # a message about a long action (loading / deleting) for the page
96
- self.busy_err = False
97
- self.upd = {"latest": None, "tag": None, "notes": "", "checked": None, "state": "idle", "msg": "",
98
- "checking": False, "error": ""}
99
- self.on_updated = None # set by the tray: restart into the new version
100
- self.on_update_found = None # set by the tray: notification
101
- threading.Thread(target=self._update_loop, daemon=True).start()
102
- threading.Thread(target=self._npu_info, daemon=True).start()
103
-
104
- def _npu_info(self):
105
- try:
106
- import openvino as ov
107
- from .runtime import npu_info, npu_arch
108
- core = ov.Core()
109
- app.NPU_ARCH = npu_arch(core)
110
- self.npu = npu_info(core)
111
- except Exception:
112
- self.npu = "NPU: unknown"
113
-
114
- # ---------------------------------------------------------------- state
115
- def state(self):
116
- LAST_SEEN[0] = time.time()
117
- cfg = app.load_config()
118
- inst = app.installed(cfg)
119
- app.note_max_contexts(inst)
120
- info = app.server_info()
121
- status = app.server_status(cfg, info=info)
122
- save = app.models_dir(cfg)
123
- self._poll_download()
124
- dl = self.dl
125
- models = []
126
- for repo, title, image, size, _, _ in app.catalog():
127
- path = inst.get(repo)
128
- mem = app.memory_estimate(repo, cfg, self.ram)
129
- stream = app.expert_budget(repo, cfg, self.ram)
130
- m = {"key": repo, "stream_gb": stream, "title": title, "catalog": True, "path": path, "image": image, "size_gb": size,
131
- "mem_gb": mem, "fits": app.fits(mem, self.ram),
132
- "speed": SPEED.get(repo, "") if stream is None else T("遅め(SSD)"),
133
- "note": (T(NOTE.get(repo, "").split("(")[0]) + T("・エキスパートは SSD から読み込み(キャッシュ {0} GB)", f"{stream:g}"))
134
- if stream else T(NOTE.get(repo, "")), "in_save_dir": bool(path) and os.path.dirname(path) == save,
135
- "deletable": bool(path) and app.downloaded_by_onw(path), "cache_gb": self._cache_gb(path) if path else 0}
136
- if app.mem_saver_on(repo, cfg):
137
- m["note"] += T("・メモリ節約モード(回答の出だしは遅め)")
138
- if path and cachever.stale(path):
139
- m["note"] += T("・onw の更新で、次のロードでは NPU 向けの準備をやり直すことがあります(数分)")
140
- m["think"] = app.think_kind(repo, path)
141
- m["state"] = self._state_of(repo, path, info, status, dl)
142
- if m["state"] == "ready" and app.update_interrupted(path):
143
- m["state"], m["note"] = "outdated", T("更新が途中で止まっています(「更新」で残りを取り直します。それまでは正しく動かないことがあります)")
144
- elif m["state"] == "ready" and app.outdated(repo, path):
145
- m["state"], m["note"] = "outdated", T("新しい版があります(「更新」で変わったファイルだけ取り直します)")
146
- if dl and dl["repo"] == repo and not dl.get("done"):
147
- m["progress"] = dl["frac"]
148
- m["note"] = T("ダウンロード中 {0}・ウィンドウを閉じても続きます", dl["text"])
149
- elif dl and dl.get("done") == repo and dl.get("error"):
150
- m["note"] = T("ダウンロードに失敗: {0}", dl["error"])
151
- models.append(m)
152
- for key, path in inst.items():
153
- if key in app.CATALOG_REPOS:
154
- continue
155
- models.append({"key": key, "title": key, "catalog": False, "path": path, "image": None, "size_gb": None,
156
- "mem_gb": None, "fits": True, "speed": "", "note": T("自分で置いたモデル・{0}", path),
157
- "in_save_dir": os.path.dirname(path) == save, "deletable": app.downloaded_by_onw(path),
158
- "cache_gb": self._cache_gb(path), "state": self._state_of(key, path, info, status, None),
159
- "think": app.think_kind(key, path)})
160
- if cachever.stale(path):
161
- models[-1]["note"] += T("・onw の更新で、次のロードでは NPU 向けの準備をやり直すことがあります(数分)")
162
- if app.update_interrupted(path): # (not in the catalog: no "update" to finish it with)
163
- models[-1]["note"] += T("・ダウンロードが途中で止まっています(正しく動かないことがあります)")
164
- downloading = dl["dest"] if dl and not dl.get("done") else None
165
- for name, path in app.partial_downloads(cfg).items(): # stopped part-way: listed so they can be removed
166
- if downloading and os.path.normcase(path) == os.path.normcase(downloading):
167
- continue
168
- models.append({"key": "partial:" + name, "title": name, "catalog": False, "path": path, "image": None,
169
- "size_gb": None, "mem_gb": None, "fits": True, "speed": "", "partial": True,
170
- "note": T("途中で止まったダウンロード・{0}", path), "in_save_dir": True, "deletable": True,
171
- "cache_gb": 0, "state": "partial"})
172
- return {"version": __version__, "installed_version": _installed_version(), "npu": self.npu or "", "ram_gb": self.ram,
173
- "config": cfg, "api_url": app.api_url(cfg, info),
174
- "models_dir_missing": app.models_dir_missing(cfg),
175
- # the memory-saving switch is shown only when a model it applies to is in the folders
176
- "saver_models": [app.model_title(k) for k, p in inst.items() if app.saver_applies(p, cfg)],
177
- "server": {"status": status, "model": info and info.get("model"), "path": info and info.get("path"),
178
- "progress": app.load_progress() if status == "loading" else None,
179
- "recompile": app.recompile_reason() if status == "loading" else None,
180
- "error": app.failure_reason() if status == "failed" else "",
181
- "title": app.model_title(info["model"]) if info else None, "port": info and info.get("port"),
182
- "speed": SPEED.get(info["model"], "") if info else "",
183
- # (the setting at most the model's longest: the setting itself applies from the next start)
184
- "context": app.context_of(info["model"], cfg) if info else None,
185
- "max_context": app.MAX_CONTEXT.get(info["model"]) if info else None,
186
- "mem_gb": app.memory_estimate(info["model"], cfg, self.ram) if info else None},
187
- "save_dir": save, "folders": app.model_folders(cfg, existing=False)[1:], "models": models,
188
- # (a start that failed from any path - this window, the tray, an update - until the next start / stop)
189
- "busy": self.busy or (T("ロードできません: {0}", app.LAST_ERROR[0]) if app.LAST_ERROR[0] else ""),
190
- "busy_err": self.busy_err or bool(not self.busy and app.LAST_ERROR[0]),
191
- "log": os.path.join(app.config_dir(), "server.log"), "store_python": app.store_python(),
192
- "deps_error": self._deps_error(), "usage": self._usage(info),
193
- "update": {**self.upd, "can_update": self._can_update(), "current": __version__}}
194
-
195
- @staticmethod
196
- def _usage(info):
197
- """Memory (onw's server, the whole machine) and the NPU's load for the server tab (onw.usage)."""
198
- try:
199
- from . import usage
200
- return usage.sample(info["pid"] if info else None)
201
- except Exception:
202
- return None
203
-
204
- @staticmethod
205
- def _deps_error():
206
- """The packages of this version could not be installed (pip failed in the update's restart): its error."""
207
- from . import deps, update
208
- d = update.install_dir()
209
- return deps.failed(d) if d and not deps.current(d) else None
210
-
211
- @staticmethod
212
- def _state_of(key, path, info, status, dl):
213
- if info and info.get("model") == key:
214
- return "live" if status == "running" else ("loading" if status == "loading" else "failed")
215
- if dl and dl["repo"] == key and not dl.get("done"): # (a finished download is just there: ready)
216
- return "downloading"
217
- if path:
218
- return "ready"
219
- return "missing"
220
-
221
- @staticmethod
222
- def _cache_gb(path):
223
- c = os.path.join(path, "npu_cache")
224
- if not os.path.isdir(c):
225
- return 0
226
- n = 0
227
- for r, _, fs in os.walk(c):
228
- for f in fs:
229
- try:
230
- n += os.path.getsize(os.path.join(r, f))
231
- except OSError:
232
- pass
233
- return round(n / 2**30, 1)
234
-
235
- # ---------------------------------------------------------------- downloads (a child process per download)
236
- def download(self, repo):
237
- if repo not in app.CATALOG_REPOS:
238
- return {"error": T("そのモデルはありません")}
239
- if repo in app.NF4_ONLY and app.npu_arch() < 4000: # (NF4 and 2-bit weights)
240
- return {"error": T("このモデルは Lunar Lake 以降の NPU 専用です")}
241
- info = app.server_info()
242
- if info and info.get("model") == repo: # updating it: its files are open in the server
243
- return {"error": T("このモデルはロード中です。先にアンロードしてください。")}
244
- if self.upd["state"] == "updating":
245
- return {"error": T("onw の更新中です。終わってからダウンロードしてください")}
246
- with self.lock:
247
- if self.dl and self.dl["proc"].poll() is None:
248
- return {"error": T("別のダウンロードが進行中です")}
249
- dest = os.path.join(app.models_dir(app.load_config()), repo.split("/")[-1])
250
- try: # (the network call outside the lock: cancel stays instant)
251
- from huggingface_hub import HfApi
252
- total = _to_fetch(HfApi().model_info(repo, files_metadata=True).siblings, dest) or 1
253
- except Exception as e:
254
- return {"error": T("Hugging Face に接続できません: {0}", e)}
255
- with self.lock:
256
- if self.dl and self.dl["proc"].poll() is None:
257
- return {"error": T("別のダウンロードが進行中です")}
258
- log = os.path.join(app.config_dir(), "download.log")
259
- before = _files(dest) # (before the child writes anything)
260
- kw = {"creationflags": subprocess.CREATE_NO_WINDOW} if os.name == "nt" else {}
261
- with open(log, "w", encoding="utf8") as out: # a file, not a pipe: a chatty download never blocks on it
262
- proc = subprocess.Popen([sys.executable, "-m", "onw.dl", repo, dest], stdout=out, stderr=subprocess.STDOUT,
263
- env={**os.environ, "PYTHONUTF8": "1"}, **kw)
264
- self.dl = {"repo": repo, "proc": proc, "dest": dest, "total": total, "frac": 0.0, "text": T("準備中…"),
265
- "error": None, "done": None, "t0": time.time(), "log": log, "before": before}
266
- app.ACTIVE_DOWNLOAD[0] = dest # (an update: not loaded while its files are replaced)
267
- return {}
268
-
269
- def cancel_download(self):
270
- with self.lock:
271
- if self.dl and self.dl["proc"].poll() is None:
272
- self.dl["proc"].terminate()
273
- try:
274
- self.dl["proc"].wait(10)
275
- except subprocess.TimeoutExpired:
276
- self.dl["proc"].kill()
277
- try:
278
- self.dl["proc"].wait(5)
279
- except subprocess.TimeoutExpired:
280
- pass
281
- self._update_stopped(self.dl)
282
- self.dl = None
283
- app.ACTIVE_DOWNLOAD[0] = None
284
- return {}
285
-
286
- @staticmethod
287
- def _update_stopped(dl):
288
- """An update of a complete model cancelled or failed: when it replaced no file yet, the model is just as it
289
- was - the marker goes (the hub's partial files stay in .cache for the next try). Otherwise the folder stays
290
- marked: listed as an update to finish (app.update_interrupted), never as a partial download to delete."""
291
- d, before = dl["dest"], dl["before"]
292
- mark = os.path.join(d, ".downloading")
293
- if not app.downloaded_by_onw(d) or mark in before: # (a first download / one stopped before this try)
294
- return
295
- skip = (os.path.join(d, ".cache") + os.sep, mark)
296
- mine = lambda files: {p: st for p, st in files.items() if not p.startswith(skip)}
297
- if mine(_files(d)) == mine(before):
298
- try:
299
- os.remove(mark)
300
- except OSError:
301
- pass
302
-
303
- def _poll_download(self):
304
- dl = self.dl
305
- if not dl or dl.get("done"):
306
- return
307
- before = dl["before"] # only what this download wrote (an update keeps the rest)
308
- n = sum(st[0] for p, st in _files(dl["dest"]).items() if before.get(p) != st)
309
- dl["frac"] = min(n / dl["total"], 0.99)
310
- dl["text"] = f"{n / 2**30:.1f} / {dl['total'] / 2**30:.1f} GB ({dl['frac'] * 100:.0f}%)"
311
- rc = dl["proc"].poll()
312
- if rc is not None:
313
- dl["done"] = dl["repo"]
314
- app.ACTIVE_DOWNLOAD[0] = None
315
- if rc != 0:
316
- try:
317
- out = open(dl["log"], encoding="utf8", errors="replace").read()[-4000:]
318
- except OSError:
319
- out = ""
320
- dl["error"] = " ".join(out.split()[-30:]) or f"exit code {rc}"
321
- self._update_stopped(dl)
322
- else:
323
- dl["frac"] = 1.0
324
- with app._CFG_LOCK:
325
- if not app.load_config().get("model"):
326
- app.update_config(model=dl["repo"])
327
-
328
- # ---------------------------------------------------------------- server
329
- def load(self, key):
330
- if key not in app.installed(app.load_config()):
331
- return {"error": T("そのモデルはありません")}
332
- with self.lock: # (a second click while the first load is still starting)
333
- if self.busy and not self.busy_err:
334
- return {"error": T("ロードの準備中です。少しお待ちください")}
335
- self.busy, self.busy_err = T("ロードしています…"), False
336
- try:
337
- cfg = app.update_config(model=key)
338
- except Exception:
339
- self.busy = ""
340
- raise
341
-
342
- def work():
343
- try:
344
- app.start_server(cfg)
345
- except Exception: # (the reason: app.LAST_ERROR, shown by state())
346
- pass
347
- self.busy, self.busy_err = "", False
348
- threading.Thread(target=work, daemon=True).start()
349
- return {}
350
-
351
- def unload(self):
352
- threading.Thread(target=app.stop_server, daemon=True).start()
353
- return {}
354
-
355
- def restart(self):
356
- info = app.server_info()
357
- return self.load(info["model"]) if info else {}
358
-
359
- # ---------------------------------------------------------------- models
360
- def delete(self, key):
361
- cfg = app.load_config()
362
- if key.startswith("partial:"): # a download that stopped part-way
363
- p = app.partial_downloads(cfg).get(key[8:])
364
- if not p or (self.dl and not self.dl.get("done") and os.path.normcase(self.dl["dest"]) == os.path.normcase(p)):
365
- return {"error": T("そのモデルはありません")}
366
- rmtree(p)
367
- return {}
368
- inst = app.installed(cfg)
369
- info = app.server_info()
370
- if key not in inst:
371
- return {"error": T("そのモデルはありません")}
372
- if info and info.get("model") == key:
373
- return {"error": T("このモデルはロ���ド中です。先にアンロードしてください。")}
374
- if not app.downloaded_by_onw(inst[key]):
375
- return {"error": T("onw がダウンロードしたモデルではないので、ここからは削除しません。")}
376
- rmtree(inst[key]) # (the compile cache inside: read-only files)
377
- return {}
378
-
379
- def clear_cache(self, key):
380
- cfg = app.load_config()
381
- inst = app.installed(cfg)
382
- info = app.server_info()
383
- if key not in inst:
384
- return {"error": T("そのモデルはありません")}
385
- if info and info.get("model") == key:
386
- return {"error": T("このモデルはロード中です。先にアンロードしてください。")}
387
- rmtree(os.path.join(inst[key], "npu_cache"))
388
- return {}
389
-
390
- def folders(self, save_dir=None, add=None, remove=None):
391
- with app._CFG_LOCK:
392
- return self._folders(save_dir, add, remove)
393
-
394
- def _folders(self, save_dir=None, add=None, remove=None):
395
- cfg = app.load_config()
396
- nc = os.path.normcase
397
- if save_dir:
398
- old, new = app.models_dir(cfg), os.path.abspath(save_dir)
399
- try:
400
- os.makedirs(new, exist_ok=True)
401
- except OSError as e:
402
- return {"error": T("そのフォルダは使えません: {0}", e)}
403
- extra = [x for x in cfg.get("model_folders") or [] if nc(x) != nc(new)]
404
- if nc(old) != nc(new) and app.installed({**cfg, "model_folders": []}):
405
- extra.append(old) # keep finding the models already downloaded to the old place
406
- cfg.update(models_dir=new, model_folders=extra)
407
- if add:
408
- d = os.path.abspath(add)
409
- if not os.path.isdir(d):
410
- return {"error": T("フォルダが見つかりません: {0}", d)}
411
- if nc(d) not in map(nc, app.model_folders(cfg)):
412
- cfg["model_folders"] = (cfg.get("model_folders") or []) + [d]
413
- if remove:
414
- cfg["model_folders"] = [x for x in cfg.get("model_folders") or [] if nc(x) != nc(os.path.abspath(remove))]
415
- app.save_config(cfg)
416
- return {}
417
-
418
- def settings(self, new):
419
- with app._CFG_LOCK:
420
- return self._settings(new)
421
-
422
- def model_prefs(self, key, prefs):
423
- """A model's defaults for the API and the chat page (the server reads them per request: no restart):
424
- think off | low | medium | high, sampling greedy | recommended | custom and the custom values."""
425
- if not key:
426
- return {"error": T("モデルが選ばれていません")}
427
- p = {}
428
- if prefs.get("think") in ("off", "low", "medium", "high"):
429
- p["think"] = prefs["think"]
430
- if prefs.get("sampling") in ("greedy", "recommended", "custom"):
431
- p["sampling"] = prefs["sampling"]
432
- try:
433
- for k, cast, lo, hi in (("temperature", float, 0, 2), ("top_p", float, 0.01, 1), ("top_k", int, 0, 1000),
434
- ("presence_penalty", float, 0, 2)):
435
- if prefs.get(k) not in (None, ""):
436
- p[k] = min(max(cast(float(prefs[k])), lo), hi)
437
- except (TypeError, ValueError):
438
- return {"error": T("生成設定の値が不正です")}
439
- with app._CFG_LOCK:
440
- cfg = app.load_config()
441
- allp = dict(cfg.get("model_prefs") or {})
442
- allp[key] = p
443
- cfg["model_prefs"] = allp
444
- app.save_config(cfg)
445
- return {"ok": True}
446
-
447
- def _settings(self, new):
448
- cfg = app.load_config()
449
- try:
450
- port, ctx = int(new.get("port", cfg["port"])), int(new.get("context", cfg["context"]))
451
- if not (1 <= port <= 65535) or ctx < 256:
452
- raise ValueError
453
- except (TypeError, ValueError):
454
- return {"error": T("ポートかコンテキスト長の値が不正です")}
455
- from .i18n import LANGS
456
- if new.get("lang", "auto") not in ("auto", *LANGS):
457
- new = {k: v for k, v in new.items() if k != "lang"}
458
- old = dict(cfg)
459
- for k in ("lan", "api_key", "device", "pld", "npu_turbo", "big_blocks", "npuw", "expert_gb", "autostart_server", "login_start",
460
- "update_check", "lang", "npuw_moe", "npuw_async", "cache_size", "mem_saver", "mem_saver_strict", "think_default"):
461
- if k in new:
462
- cfg[k] = new[k]
463
- if new.get("api_sampling") in ("greedy", "recommended", "custom"):
464
- cfg["api_sampling"] = new["api_sampling"]
465
- try:
466
- for k, cast, lo, hi in (("api_temperature", float, 0, 2), ("api_top_p", float, 0.01, 1), ("api_top_k", int, 0, 1000),
467
- ("api_presence", float, 0, 2)):
468
- if k in new:
469
- cfg[k] = min(max(cast(float(new[k])), lo), hi)
470
- except (TypeError, ValueError):
471
- return {"error": T("生成設定の値が不正です")}
472
- if "api_max_tokens" in new: # (0: up to the end of the context)
473
- try:
474
- cfg["api_max_tokens"] = max(0, int(new["api_max_tokens"]))
475
- except (TypeError, ValueError):
476
- return {"error": T("生成設定の値が不正です")}
477
- if ctx != cfg["context"]:
478
- cfg["context_chosen"] = True # (the old 1024 default moves up; a choice stays)
479
- cfg["port"], cfg["context"] = port, ctx
480
- server_keys = ("lan", "api_key", "device", "pld", "npu_turbo", "api_max_tokens", "big_blocks", "npuw", "expert_gb", "port", "context",
481
- "npuw_moe", "npuw_async", "cache_size", "mem_saver", "mem_saver_strict", "think_default",
482
- "api_sampling", "api_temperature", "api_top_p", "api_top_k", "api_presence")
483
- app.save_config(cfg)
484
- i18n.current(cfg) # messages switch language right away
485
- try:
486
- app.set_login_start(bool(cfg["login_start"]))
487
- except OSError:
488
- pass
489
- changed = any(str(old.get(k)) != str(cfg.get(k)) for k in server_keys)
490
- return {"restart_needed": changed and app.server_info() is not None}
491
-
492
- # ---------------------------------------------------------------- updates of onw itself
493
- @staticmethod
494
- def _can_update():
495
- from . import update
496
- return update.can_update()
497
-
498
- def _update_loop(self):
499
- time.sleep(30) # let the tray and the server start first
500
- while True:
501
- if app.load_config().get("update_check", True):
502
- self.check_update()
503
- time.sleep(24 * 3600)
504
-
505
- def start_check(self):
506
- """The window's "Check now": checks in the background and answers at once; the page follows
507
- upd["checking"] / upd["error"] (a slow connection must not leave the button without an answer)."""
508
- if not self.upd["checking"]:
509
- threading.Thread(target=self.check_update, daemon=True).start()
510
- return {"checking": True}
511
-
512
- def check_update(self):
513
- self.upd.update(checking=True, error="")
514
- try:
515
- r = self._check_update()
516
- except Exception as e:
517
- r = {"error": T("更新を確認できませんでした: {0}", e)}
518
- self.upd.update(checking=False, error=r.get("error", ""))
519
- return r
520
-
521
- def _check_update(self):
522
- from . import update
523
- found = update.latest()
524
- self.upd["checked"] = time.time()
525
- if found is None:
526
- return {"error": T("更新を確認できませんでした(オフライン?)")}
527
- v, tag = found
528
- if update.newer(v):
529
- fresh = self.upd["latest"] != v
530
- self.upd.update(latest=v, tag=tag, notes=update.notes(lang=i18n.current()))
531
- if fresh and self.on_update_found:
532
- try:
533
- self.on_update_found(v)
534
- except Exception:
535
- pass
536
- else:
537
- self.upd.update(latest=None, tag=None, notes="")
538
- return {"latest": self.upd["latest"]}
539
-
540
- def run_update(self):
541
- from . import update
542
- if not self.upd["tag"]:
543
- return {"error": T("新しい版はありません")}
544
- if not update.can_update():
545
- return {"error": T("このフォルダの onw は 1 行インストーラーで入れたものではないので、自動更新できません")}
546
- if self.upd["state"] == "updating":
547
- return {}
548
- if self.dl and self.dl["proc"].poll() is None: # the restart would orphan the download
549
- return {"error": T("ダウンロードが終わってから更新してください")}
550
- self.upd.update(state="updating", msg=T("準備中…"))
551
-
552
- def work():
553
- try:
554
- v = update.apply(self.upd["tag"], progress=lambda t: self.upd.update(msg=t))
555
- self.upd.update(state="done", msg=T("v{0} に更新しました。再起動します…", v))
556
- if self.on_updated:
557
- self.on_updated()
558
- else:
559
- self.upd["msg"] = T("v{0} に更新しました。onw を起動し直してください", v)
560
- except Exception as e:
561
- self.upd.update(state="error", msg=T("更新できませんでした: {0}", e))
562
- threading.Thread(target=work, daemon=True).start()
563
- return {}
564
-
565
- def open(self, what, key=None):
566
- cfg = app.load_config()
567
- if what == "log":
568
- app.open_path(os.path.join(app.config_dir(), "server.log"))
569
- elif what == "folder":
570
- p = app.installed(cfg).get(key) if key else None
571
- app.open_path(p or app.models_dir(cfg))
572
- elif what in ("chat", "check"):
573
- webbrowser.open(f"http://127.0.0.1:{app.server_port(cfg)}/{what}")
574
- return {}
575
-
576
-
577
- class _Handler(BaseHTTPRequestHandler):
578
- manager: Manager = None
579
- token: str = ""
580
-
581
- def log_message(self, *a): # quiet
582
- pass
583
-
584
- def _send(self, code, body, ctype="application/json"):
585
- data = body if isinstance(body, bytes) else json.dumps(body, ensure_ascii=False).encode("utf8")
586
- self.send_response(code)
587
- self.send_header("Content-Type", ctype + ("; charset=utf-8" if ctype.startswith("text") else ""))
588
- self.send_header("Content-Length", str(len(data)))
589
- self.send_header("Cache-Control", "no-store")
590
- self.end_headers()
591
- self.wfile.write(data)
592
-
593
- def _authed(self, q):
594
- t = self.headers.get("X-Onw-Token") or (q.get("token") or [""])[0]
595
- return secrets.compare_digest(t, self.token)
596
-
597
- def do_GET(self):
598
- u = urlparse(self.path)
599
- q = parse_qs(u.query)
600
- if u.path in ("/", "/manage.html"):
601
- return self._send(200, i18n.page(os.path.join(WEB, "manage.html")), "text/html")
602
- if u.path == "/api/state":
603
- if not self._authed(q):
604
- return self._send(403, {"error": T("この画面は onw のウィンドウかトレイから開いてください")})
605
- return self._send(200, self.manager.state())
606
- self._send(404, {"error": "not found"})
607
-
608
- def do_POST(self):
609
- u = urlparse(self.path)
610
- if not self._authed(parse_qs(u.query)):
611
- return self._send(403, {"error": T("この画面は onw のウィンドウかトレイから開いてください")})
612
- m = self.manager
613
- act = u.path[len("/api/"):]
614
- try:
615
- n = int(self.headers.get("Content-Length") or 0)
616
- body = json.loads(self.rfile.read(n) or b"{}")
617
- if act == "load":
618
- r = m.load(body["key"])
619
- elif act == "unload":
620
- r = m.unload()
621
- elif act == "restart":
622
- r = m.restart()
623
- elif act == "download":
624
- r = m.download(body["repo"])
625
- elif act == "cancel_download":
626
- r = m.cancel_download()
627
- elif act == "delete":
628
- r = m.delete(body["key"])
629
- elif act == "clear_cache":
630
- r = m.clear_cache(body["key"])
631
- elif act == "folders":
632
- r = m.folders(body.get("save_dir"), body.get("add"), body.get("remove"))
633
- elif act == "settings":
634
- r = m.settings(body)
635
- elif act == "model_prefs":
636
- r = m.model_prefs(body.get("key"), body.get("prefs") or {})
637
- elif act == "update_check":
638
- r = m.start_check()
639
- elif act == "update":
640
- r = m.run_update()
641
- elif act == "open":
642
- r = m.open(body.get("what"), body.get("key"))
643
- else:
644
- return self._send(404, {"error": "not found"})
645
- except Exception as e:
646
- r = {"error": T("エラー: {0}", e)}
647
- self._send(200, r)
648
-
649
-
650
- def _created():
651
- try:
652
- import psutil
653
- return psutil.Process().create_time()
654
- except Exception:
655
- return None
656
-
657
-
658
- def forget(info):
659
- """Remove manager.json if it still describes this process's manager (at exit)."""
660
- try:
661
- if json.load(open(INFO())).get("pid") == info.get("pid"):
662
- os.remove(INFO())
663
- except (OSError, ValueError):
664
- pass
665
-
666
-
667
- def serve(manager=None):
668
- """Start the manager's HTTP server in a daemon thread; returns {port, token}. Writes <config>/manager.json."""
669
- manager = manager or Manager()
670
- handler = type("H", (_Handler,), {"manager": manager, "token": secrets.token_hex(16)})
671
- srv = ThreadingHTTPServer(("127.0.0.1", 0), handler)
672
- threading.Thread(target=srv.serve_forever, daemon=True).start()
673
- info = {"port": srv.server_address[1], "token": handler.token, "pid": os.getpid(), "created": _created()}
674
- with open(INFO() + ".tmp", "w") as f:
675
- json.dump(info, f)
676
- try:
677
- os.chmod(INFO() + ".tmp", 0o600) # (the token) before it is in place
678
- except OSError:
679
- pass
680
- os.replace(INFO() + ".tmp", INFO())
681
- return info
682
-
683
-
684
- def page_url(info, tab="server"):
685
- return f"http://127.0.0.1:{info['port']}/manage.html?token={info['token']}#{tab}"
 
 
1
+ """The onw manager: model list, downloads, server control and settings behind a small local HTTP API, used by the
2
+ window (onw/web/manage.html in a webview or a browser). It runs inside the tray process, so downloads keep going when
3
+ the window is closed; `onw window` without a tray runs its own copy.
4
+
5
+ GET /manage.html the page
6
+ GET /api/state everything the page shows (polled once a second)
7
+ POST /api/<action> load / unload / restart / download / cancel_download / delete / clear_cache /
8
+ folders / settings / open
9
+
10
+ Bound to 127.0.0.1 only; every /api call needs the token from <config>/manager.json (X-Onw-Token header or ?token=).
11
+ """
12
+ import json, os, secrets, shutil, subprocess, sys, threading, time, webbrowser
13
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14
+ from urllib.parse import parse_qs, urlparse
15
+
16
+ from . import app, cachever, __version__
17
+ from .cachever import rmtree
18
+ from . import i18n
19
+ from .i18n import T
20
+
21
+ WEB = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
22
+ INFO = lambda: os.path.join(app.config_dir(), "manager.json")
23
+ SPEED = {c[0]: c[4] for c in app.CATALOG}
24
+ NOTE = {c[0]: c[5] for c in app.CATALOG}
25
+ LAST_SEEN = [time.time()] # when the page last asked for the state (a browser-only window keeps us alive)
26
+
27
+
28
+ def _files(d):
29
+ """{path: (size, mtime)} under a model folder, without the compile cache."""
30
+ out = {}
31
+ for root, dirs, files in os.walk(d):
32
+ dirs[:] = [x for x in dirs if x != "npu_cache"]
33
+ for f in files:
34
+ p = os.path.join(root, f)
35
+ try:
36
+ st = os.stat(p)
37
+ out[p] = (st.st_size, st.st_mtime_ns)
38
+ except OSError:
39
+ pass
40
+ return out
41
+
42
+
43
+ def _to_fetch(siblings, dest):
44
+ """Bytes a download into dest still has to fetch: files missing there, or older than the repo's (the hub's own
45
+ record of what it downloaded; without it, a size mismatch)."""
46
+ try:
47
+ from pathlib import Path
48
+ from huggingface_hub._local_folder import read_download_metadata
49
+ except ImportError:
50
+ read_download_metadata = None
51
+ n = 0
52
+ for s in siblings:
53
+ p = os.path.join(dest, s.rfilename)
54
+ if not os.path.exists(p):
55
+ n += s.size or 0
56
+ continue
57
+ want = s.lfs.sha256 if s.lfs else s.blob_id
58
+ try:
59
+ meta = read_download_metadata(Path(dest), s.rfilename) if read_download_metadata else None
60
+ except Exception:
61
+ meta = None
62
+ stale = (meta.etag != want) if meta is not None and want else os.path.getsize(p) != (s.size or 0)
63
+ n += (s.size or 0) if stale else 0
64
+ return n
65
+
66
+
67
+ def _installed_version():
68
+ """The version of the onw files on disk now (this tray process may still run an older one: files replaced by the
69
+ installer or by hand while it ran - the window warns, as settings it does not know would be dropped)."""
70
+ import re
71
+ try:
72
+ s = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "__init__.py"), encoding="utf8").read()
73
+ return re.search(r'__version__\s*=\s*"([^"]+)"', s).group(1)
74
+ except (OSError, AttributeError):
75
+ return __version__
76
+
77
+
78
+ def find_manager():
79
+ """{port, token} of a running manager (the tray), or None."""
80
+ try:
81
+ d = json.load(open(INFO()))
82
+ if app._alive(d["pid"], d.get("created")): # (a file left by a crash may name a reused pid)
83
+ return d
84
+ except (OSError, ValueError, KeyError):
85
+ pass
86
+ return None
87
+
88
+
89
+ class Manager:
90
+ def __init__(self):
91
+ self.lock = threading.Lock()
92
+ self.dl = None # {"repo", "proc", "dest", "total", "frac", "text", "error", "done", "t0"}
93
+ self.npu = None
94
+ self.ram = app.total_ram_gb()
95
+ self.busy = "" # a message about a long action (loading / deleting) for the page
96
+ self.busy_err = False
97
+ self.upd = {"latest": None, "tag": None, "notes": "", "checked": None, "state": "idle", "msg": "",
98
+ "checking": False, "error": ""}
99
+ self.on_updated = None # set by the tray: restart into the new version
100
+ self.on_update_found = None # set by the tray: notification
101
+ threading.Thread(target=self._update_loop, daemon=True).start()
102
+ threading.Thread(target=self._npu_info, daemon=True).start()
103
+
104
+ def _npu_info(self):
105
+ try:
106
+ import openvino as ov
107
+ from . import npukit
108
+ from .runtime import npu_info, npu_arch
109
+ core = npukit.core()
110
+ app.NPU_ARCH = npu_arch(core)
111
+ self.npu = npu_info(core)
112
+ except Exception:
113
+ self.npu = "NPU: unknown"
114
+
115
+ # ---------------------------------------------------------------- state
116
+ def state(self):
117
+ LAST_SEEN[0] = time.time()
118
+ cfg = app.load_config()
119
+ inst = app.installed(cfg)
120
+ app.note_max_contexts(inst)
121
+ info = app.server_info()
122
+ status = app.server_status(cfg, info=info)
123
+ save = app.models_dir(cfg)
124
+ self._poll_download()
125
+ dl = self.dl
126
+ models = []
127
+ for repo, title, image, size, _, _ in app.catalog():
128
+ path = inst.get(repo)
129
+ mem = app.memory_estimate(repo, cfg, self.ram)
130
+ stream = app.expert_budget(repo, cfg, self.ram)
131
+ m = {"key": repo, "stream_gb": stream, "title": title, "catalog": True, "path": path, "image": image, "size_gb": size,
132
+ "mem_gb": mem, "fits": app.fits(mem, self.ram),
133
+ "speed": SPEED.get(repo, "") if stream is None else T("遅め(SSD)"),
134
+ "note": (T(NOTE.get(repo, "").split("(")[0]) + T("・エキスパートは SSD から読み込み(キャッシュ {0} GB)", f"{stream:g}"))
135
+ if stream else T(NOTE.get(repo, "")), "in_save_dir": bool(path) and os.path.dirname(path) == save,
136
+ "deletable": bool(path) and app.downloaded_by_onw(path), "cache_gb": self._cache_gb(path) if path else 0}
137
+ if app.mem_saver_on(repo, cfg):
138
+ m["note"] += T("・メモリ節約モード(回答の出だしは遅め)")
139
+ if path and cachever.stale(path):
140
+ m["note"] += T("・onw の更新で、次のロードでは NPU 向けの準備をやり直すことがあります(数分)")
141
+ m["think"] = app.think_kind(repo, path)
142
+ m["state"] = self._state_of(repo, path, info, status, dl)
143
+ if m["state"] == "ready" and app.update_interrupted(path):
144
+ m["state"], m["note"] = "outdated", T("更新が途中で止まっています(「更新」で残りを取り直します。それまでは正しく動かないことがあります)")
145
+ elif m["state"] == "ready" and app.outdated(repo, path):
146
+ m["state"], m["note"] = "outdated", T("新しい版があります(「更新」で変わったファイルだけ取り直します)")
147
+ if dl and dl["repo"] == repo and not dl.get("done"):
148
+ m["progress"] = dl["frac"]
149
+ m["note"] = T("ダウンロード中 {0}・ウィンドウを閉じても続きます", dl["text"])
150
+ elif dl and dl.get("done") == repo and dl.get("error"):
151
+ m["note"] = T("ダウンロードに失敗: {0}", dl["error"])
152
+ models.append(m)
153
+ for key, path in inst.items():
154
+ if key in app.CATALOG_REPOS:
155
+ continue
156
+ models.append({"key": key, "title": key, "catalog": False, "path": path, "image": None, "size_gb": None,
157
+ "mem_gb": None, "fits": True, "speed": "", "note": T("自分で置いたモデル・{0}", path),
158
+ "in_save_dir": os.path.dirname(path) == save, "deletable": app.downloaded_by_onw(path),
159
+ "cache_gb": self._cache_gb(path), "state": self._state_of(key, path, info, status, None),
160
+ "think": app.think_kind(key, path)})
161
+ if cachever.stale(path):
162
+ models[-1]["note"] += T("・onw の更新で、次のロードでは NPU 向けの準備をやり直すことがあります(数分)")
163
+ if app.update_interrupted(path): # (not in the catalog: no "update" to finish it with)
164
+ models[-1]["note"] += T("・ダウンロードが途中で止まっています(正しく動かないことがあります)")
165
+ downloading = dl["dest"] if dl and not dl.get("done") else None
166
+ for name, path in app.partial_downloads(cfg).items(): # stopped part-way: listed so they can be removed
167
+ if downloading and os.path.normcase(path) == os.path.normcase(downloading):
168
+ continue
169
+ models.append({"key": "partial:" + name, "title": name, "catalog": False, "path": path, "image": None,
170
+ "size_gb": None, "mem_gb": None, "fits": True, "speed": "", "partial": True,
171
+ "note": T("途中で止まったダウンロード・{0}", path), "in_save_dir": True, "deletable": True,
172
+ "cache_gb": 0, "state": "partial"})
173
+ return {"version": __version__, "installed_version": _installed_version(), "npu": self.npu or "", "ram_gb": self.ram,
174
+ "config": cfg, "api_url": app.api_url(cfg, info),
175
+ "models_dir_missing": app.models_dir_missing(cfg),
176
+ # the memory-saving switch is shown only when a model it applies to is in the folders
177
+ "saver_models": [app.model_title(k) for k, p in inst.items() if app.saver_applies(p, cfg)],
178
+ "server": {"status": status, "model": info and info.get("model"), "path": info and info.get("path"),
179
+ "progress": app.load_progress() if status == "loading" else None,
180
+ "recompile": app.recompile_reason() if status == "loading" else None,
181
+ "error": app.failure_reason() if status == "failed" else "",
182
+ "title": app.model_title(info["model"]) if info else None, "port": info and info.get("port"),
183
+ "speed": SPEED.get(info["model"], "") if info else "",
184
+ # (the setting at most the model's longest: the setting itself applies from the next start)
185
+ "context": app.context_of(info["model"], cfg) if info else None,
186
+ "max_context": app.MAX_CONTEXT.get(info["model"]) if info else None,
187
+ "mem_gb": app.memory_estimate(info["model"], cfg, self.ram) if info else None},
188
+ "save_dir": save, "folders": app.model_folders(cfg, existing=False)[1:], "models": models,
189
+ # (a start that failed from any path - this window, the tray, an update - until the next start / stop)
190
+ "busy": self.busy or (T("ロードできません: {0}", app.LAST_ERROR[0]) if app.LAST_ERROR[0] else ""),
191
+ "busy_err": self.busy_err or bool(not self.busy and app.LAST_ERROR[0]),
192
+ "log": os.path.join(app.config_dir(), "server.log"), "store_python": app.store_python(),
193
+ "deps_error": self._deps_error(), "usage": self._usage(info),
194
+ "update": {**self.upd, "can_update": self._can_update(), "current": __version__}}
195
+
196
+ @staticmethod
197
+ def _usage(info):
198
+ """Memory (onw's server, the whole machine) and the NPU's load for the server tab (onw.usage)."""
199
+ try:
200
+ from . import usage
201
+ return usage.sample(info["pid"] if info else None)
202
+ except Exception:
203
+ return None
204
+
205
+ @staticmethod
206
+ def _deps_error():
207
+ """The packages of this version could not be installed (pip failed in the update's restart): its error."""
208
+ from . import deps, update
209
+ d = update.install_dir()
210
+ return deps.failed(d) if d and not deps.current(d) else None
211
+
212
+ @staticmethod
213
+ def _state_of(key, path, info, status, dl):
214
+ if info and info.get("model") == key:
215
+ return "live" if status == "running" else ("loading" if status == "loading" else "failed")
216
+ if dl and dl["repo"] == key and not dl.get("done"): # (a finished download is just there: ready)
217
+ return "downloading"
218
+ if path:
219
+ return "ready"
220
+ return "missing"
221
+
222
+ @staticmethod
223
+ def _cache_gb(path):
224
+ c = os.path.join(path, "npu_cache")
225
+ if not os.path.isdir(c):
226
+ return 0
227
+ n = 0
228
+ for r, _, fs in os.walk(c):
229
+ for f in fs:
230
+ try:
231
+ n += os.path.getsize(os.path.join(r, f))
232
+ except OSError:
233
+ pass
234
+ return round(n / 2**30, 1)
235
+
236
+ # ---------------------------------------------------------------- downloads (a child process per download)
237
+ def download(self, repo):
238
+ if repo not in app.CATALOG_REPOS:
239
+ return {"error": T("そのモデルはありません")}
240
+ if repo in app.NF4_ONLY and app.npu_arch() < 4000: # (NF4 and 2-bit weights)
241
+ return {"error": T("このモデルは Lunar Lake 以降の NPU 専用です")}
242
+ info = app.server_info()
243
+ if info and info.get("model") == repo: # updating it: its files are open in the server
244
+ return {"error": T("このモデルはロード中です。先にアンロードしてください。")}
245
+ if self.upd["state"] == "updating":
246
+ return {"error": T("onw の更新中です。終わってからダウンロードしてください")}
247
+ with self.lock:
248
+ if self.dl and self.dl["proc"].poll() is None:
249
+ return {"error": T("別のダウンロードが進行中です")}
250
+ dest = os.path.join(app.models_dir(app.load_config()), repo.split("/")[-1])
251
+ try: # (the network call outside the lock: cancel stays instant)
252
+ from huggingface_hub import HfApi
253
+ total = _to_fetch(HfApi().model_info(repo, files_metadata=True).siblings, dest) or 1
254
+ except Exception as e:
255
+ return {"error": T("Hugging Face に接続できません: {0}", e)}
256
+ with self.lock:
257
+ if self.dl and self.dl["proc"].poll() is None:
258
+ return {"error": T("別のダウンロードが進行中です")}
259
+ log = os.path.join(app.config_dir(), "download.log")
260
+ before = _files(dest) # (before the child writes anything)
261
+ kw = {"creationflags": subprocess.CREATE_NO_WINDOW} if os.name == "nt" else {}
262
+ with open(log, "w", encoding="utf8") as out: # a file, not a pipe: a chatty download never blocks on it
263
+ proc = subprocess.Popen([sys.executable, "-m", "onw.dl", repo, dest], stdout=out, stderr=subprocess.STDOUT,
264
+ env={**os.environ, "PYTHONUTF8": "1"}, **kw)
265
+ self.dl = {"repo": repo, "proc": proc, "dest": dest, "total": total, "frac": 0.0, "text": T("準備中…"),
266
+ "error": None, "done": None, "t0": time.time(), "log": log, "before": before}
267
+ app.ACTIVE_DOWNLOAD[0] = dest # (an update: not loaded while its files are replaced)
268
+ return {}
269
+
270
+ def cancel_download(self):
271
+ with self.lock:
272
+ if self.dl and self.dl["proc"].poll() is None:
273
+ self.dl["proc"].terminate()
274
+ try:
275
+ self.dl["proc"].wait(10)
276
+ except subprocess.TimeoutExpired:
277
+ self.dl["proc"].kill()
278
+ try:
279
+ self.dl["proc"].wait(5)
280
+ except subprocess.TimeoutExpired:
281
+ pass
282
+ self._update_stopped(self.dl)
283
+ self.dl = None
284
+ app.ACTIVE_DOWNLOAD[0] = None
285
+ return {}
286
+
287
+ @staticmethod
288
+ def _update_stopped(dl):
289
+ """An update of a complete model cancelled or failed: when it replaced no file yet, the model is just as it
290
+ was - the marker goes (the hub's partial files stay in .cache for the next try). Otherwise the folder stays
291
+ marked: listed as an update to finish (app.update_interrupted), never as a partial download to delete."""
292
+ d, before = dl["dest"], dl["before"]
293
+ mark = os.path.join(d, ".downloading")
294
+ if not app.downloaded_by_onw(d) or mark in before: # (a first download / one stopped before this try)
295
+ return
296
+ skip = (os.path.join(d, ".cache") + os.sep, mark)
297
+ mine = lambda files: {p: st for p, st in files.items() if not p.startswith(skip)}
298
+ if mine(_files(d)) == mine(before):
299
+ try:
300
+ os.remove(mark)
301
+ except OSError:
302
+ pass
303
+
304
+ def _poll_download(self):
305
+ dl = self.dl
306
+ if not dl or dl.get("done"):
307
+ return
308
+ before = dl["before"] # only what this download wrote (an update keeps the rest)
309
+ n = sum(st[0] for p, st in _files(dl["dest"]).items() if before.get(p) != st)
310
+ dl["frac"] = min(n / dl["total"], 0.99)
311
+ dl["text"] = f"{n / 2**30:.1f} / {dl['total'] / 2**30:.1f} GB ({dl['frac'] * 100:.0f}%)"
312
+ rc = dl["proc"].poll()
313
+ if rc is not None:
314
+ dl["done"] = dl["repo"]
315
+ app.ACTIVE_DOWNLOAD[0] = None
316
+ if rc != 0:
317
+ try:
318
+ out = open(dl["log"], encoding="utf8", errors="replace").read()[-4000:]
319
+ except OSError:
320
+ out = ""
321
+ dl["error"] = " ".join(out.split()[-30:]) or f"exit code {rc}"
322
+ self._update_stopped(dl)
323
+ else:
324
+ dl["frac"] = 1.0
325
+ with app._CFG_LOCK:
326
+ if not app.load_config().get("model"):
327
+ app.update_config(model=dl["repo"])
328
+
329
+ # ---------------------------------------------------------------- server
330
+ def load(self, key):
331
+ if key not in app.installed(app.load_config()):
332
+ return {"error": T("そのモデルはありません")}
333
+ with self.lock: # (a second click while the first load is still starting)
334
+ if self.busy and not self.busy_err:
335
+ return {"error": T("ロードの準備中です。少しお待ちください")}
336
+ self.busy, self.busy_err = T("ロードしています…"), False
337
+ try:
338
+ cfg = app.update_config(model=key)
339
+ except Exception:
340
+ self.busy = ""
341
+ raise
342
+
343
+ def work():
344
+ try:
345
+ app.start_server(cfg)
346
+ except Exception: # (the reason: app.LAST_ERROR, shown by state())
347
+ pass
348
+ self.busy, self.busy_err = "", False
349
+ threading.Thread(target=work, daemon=True).start()
350
+ return {}
351
+
352
+ def unload(self):
353
+ threading.Thread(target=app.stop_server, daemon=True).start()
354
+ return {}
355
+
356
+ def restart(self):
357
+ info = app.server_info()
358
+ return self.load(info["model"]) if info else {}
359
+
360
+ # ---------------------------------------------------------------- models
361
+ def delete(self, key):
362
+ cfg = app.load_config()
363
+ if key.startswith("partial:"): # a download that stopped part-way
364
+ p = app.partial_downloads(cfg).get(key[8:])
365
+ if not p or (self.dl and not self.dl.get("done") and os.path.normcase(self.dl["dest"]) == os.path.normcase(p)):
366
+ return {"error": T("そのモデルはありません")}
367
+ rmtree(p)
368
+ return {}
369
+ inst = app.installed(cfg)
370
+ info = app.server_info()
371
+ if key not in inst:
372
+ return {"error": T("そのモデルはありません")}
373
+ if info and info.get("model") == key:
374
+ return {"error": T("このモデルはロード中です。先にアンロードしてください。")}
375
+ if not app.downloaded_by_onw(inst[key]):
376
+ return {"error": T("onw がダウンロードしたモデルではないので、ここからは削除しません。")}
377
+ rmtree(inst[key]) # (the compile cache inside: read-only files)
378
+ return {}
379
+
380
+ def clear_cache(self, key):
381
+ cfg = app.load_config()
382
+ inst = app.installed(cfg)
383
+ info = app.server_info()
384
+ if key not in inst:
385
+ return {"error": T("そのモデルはありません")}
386
+ if info and info.get("model") == key:
387
+ return {"error": T("このモデルはロード中です。先にアンロードしてください。")}
388
+ rmtree(os.path.join(inst[key], "npu_cache"))
389
+ return {}
390
+
391
+ def folders(self, save_dir=None, add=None, remove=None):
392
+ with app._CFG_LOCK:
393
+ return self._folders(save_dir, add, remove)
394
+
395
+ def _folders(self, save_dir=None, add=None, remove=None):
396
+ cfg = app.load_config()
397
+ nc = os.path.normcase
398
+ if save_dir:
399
+ old, new = app.models_dir(cfg), os.path.abspath(save_dir)
400
+ try:
401
+ os.makedirs(new, exist_ok=True)
402
+ except OSError as e:
403
+ return {"error": T("そのフォルダは使えません: {0}", e)}
404
+ extra = [x for x in cfg.get("model_folders") or [] if nc(x) != nc(new)]
405
+ if nc(old) != nc(new) and app.installed({**cfg, "model_folders": []}):
406
+ extra.append(old) # keep finding the models already downloaded to the old place
407
+ cfg.update(models_dir=new, model_folders=extra)
408
+ if add:
409
+ d = os.path.abspath(add)
410
+ if not os.path.isdir(d):
411
+ return {"error": T("フォルダが見つかりません: {0}", d)}
412
+ if nc(d) not in map(nc, app.model_folders(cfg)):
413
+ cfg["model_folders"] = (cfg.get("model_folders") or []) + [d]
414
+ if remove:
415
+ cfg["model_folders"] = [x for x in cfg.get("model_folders") or [] if nc(x) != nc(os.path.abspath(remove))]
416
+ app.save_config(cfg)
417
+ return {}
418
+
419
+ def settings(self, new):
420
+ with app._CFG_LOCK:
421
+ return self._settings(new)
422
+
423
+ def model_prefs(self, key, prefs):
424
+ """A model's defaults for the API and the chat page (the server reads them per request: no restart):
425
+ think off | low | medium | high, sampling greedy | recommended | custom and the custom values."""
426
+ if not key:
427
+ return {"error": T("モデルが選ばれていません")}
428
+ p = {}
429
+ if prefs.get("think") in ("off", "low", "medium", "high"):
430
+ p["think"] = prefs["think"]
431
+ if prefs.get("sampling") in ("greedy", "recommended", "custom"):
432
+ p["sampling"] = prefs["sampling"]
433
+ try:
434
+ for k, cast, lo, hi in (("temperature", float, 0, 2), ("top_p", float, 0.01, 1), ("top_k", int, 0, 1000),
435
+ ("presence_penalty", float, 0, 2)):
436
+ if prefs.get(k) not in (None, ""):
437
+ p[k] = min(max(cast(float(prefs[k])), lo), hi)
438
+ except (TypeError, ValueError):
439
+ return {"error": T("生成設定の値が不正です")}
440
+ with app._CFG_LOCK:
441
+ cfg = app.load_config()
442
+ allp = dict(cfg.get("model_prefs") or {})
443
+ allp[key] = p
444
+ cfg["model_prefs"] = allp
445
+ app.save_config(cfg)
446
+ return {"ok": True}
447
+
448
+ def _settings(self, new):
449
+ cfg = app.load_config()
450
+ try:
451
+ port, ctx = int(new.get("port", cfg["port"])), int(new.get("context", cfg["context"]))
452
+ if not (1 <= port <= 65535) or ctx < 256:
453
+ raise ValueError
454
+ except (TypeError, ValueError):
455
+ return {"error": T("ポートかコンテキスト長の値が不正です")}
456
+ from .i18n import LANGS
457
+ if new.get("lang", "auto") not in ("auto", *LANGS):
458
+ new = {k: v for k, v in new.items() if k != "lang"}
459
+ old = dict(cfg)
460
+ for k in ("lan", "api_key", "device", "pld", "npu_turbo", "big_blocks", "npuw", "expert_gb", "autostart_server", "login_start",
461
+ "update_check", "lang", "npuw_moe", "npuw_async", "cache_size", "mem_saver", "mem_saver_strict", "think_default"):
462
+ if k in new:
463
+ cfg[k] = new[k]
464
+ if new.get("api_sampling") in ("greedy", "recommended", "custom"):
465
+ cfg["api_sampling"] = new["api_sampling"]
466
+ try:
467
+ for k, cast, lo, hi in (("api_temperature", float, 0, 2), ("api_top_p", float, 0.01, 1), ("api_top_k", int, 0, 1000),
468
+ ("api_presence", float, 0, 2)):
469
+ if k in new:
470
+ cfg[k] = min(max(cast(float(new[k])), lo), hi)
471
+ except (TypeError, ValueError):
472
+ return {"error": T("生成設定の値が不正です")}
473
+ if "api_max_tokens" in new: # (0: up to the end of the context)
474
+ try:
475
+ cfg["api_max_tokens"] = max(0, int(new["api_max_tokens"]))
476
+ except (TypeError, ValueError):
477
+ return {"error": T("生成設定の値が不正です")}
478
+ if ctx != cfg["context"]:
479
+ cfg["context_chosen"] = True # (the old 1024 default moves up; a choice stays)
480
+ cfg["port"], cfg["context"] = port, ctx
481
+ server_keys = ("lan", "api_key", "device", "pld", "npu_turbo", "api_max_tokens", "big_blocks", "npuw", "expert_gb", "port", "context",
482
+ "npuw_moe", "npuw_async", "cache_size", "mem_saver", "mem_saver_strict", "think_default",
483
+ "api_sampling", "api_temperature", "api_top_p", "api_top_k", "api_presence")
484
+ app.save_config(cfg)
485
+ i18n.current(cfg) # messages switch language right away
486
+ try:
487
+ app.set_login_start(bool(cfg["login_start"]))
488
+ except OSError:
489
+ pass
490
+ changed = any(str(old.get(k)) != str(cfg.get(k)) for k in server_keys)
491
+ return {"restart_needed": changed and app.server_info() is not None}
492
+
493
+ # ---------------------------------------------------------------- updates of onw itself
494
+ @staticmethod
495
+ def _can_update():
496
+ from . import update
497
+ return update.can_update()
498
+
499
+ def _update_loop(self):
500
+ time.sleep(30) # let the tray and the server start first
501
+ while True:
502
+ if app.load_config().get("update_check", True):
503
+ self.check_update()
504
+ time.sleep(24 * 3600)
505
+
506
+ def start_check(self):
507
+ """The window's "Check now": checks in the background and answers at once; the page follows
508
+ upd["checking"] / upd["error"] (a slow connection must not leave the button without an answer)."""
509
+ if not self.upd["checking"]:
510
+ threading.Thread(target=self.check_update, daemon=True).start()
511
+ return {"checking": True}
512
+
513
+ def check_update(self):
514
+ self.upd.update(checking=True, error="")
515
+ try:
516
+ r = self._check_update()
517
+ except Exception as e:
518
+ r = {"error": T("更新を確認できませんでした: {0}", e)}
519
+ self.upd.update(checking=False, error=r.get("error", ""))
520
+ return r
521
+
522
+ def _check_update(self):
523
+ from . import update
524
+ found = update.latest()
525
+ self.upd["checked"] = time.time()
526
+ if found is None:
527
+ return {"error": T("更新を確認できませんでした(オフライン?)")}
528
+ v, tag = found
529
+ if update.newer(v):
530
+ fresh = self.upd["latest"] != v
531
+ self.upd.update(latest=v, tag=tag, notes=update.notes(lang=i18n.current()))
532
+ if fresh and self.on_update_found:
533
+ try:
534
+ self.on_update_found(v)
535
+ except Exception:
536
+ pass
537
+ else:
538
+ self.upd.update(latest=None, tag=None, notes="")
539
+ return {"latest": self.upd["latest"]}
540
+
541
+ def run_update(self):
542
+ from . import update
543
+ if not self.upd["tag"]:
544
+ return {"error": T("新しい版はありません")}
545
+ if not update.can_update():
546
+ return {"error": T("このフォルダの onw は 1 行インストーラーで入れたものではないので、自動更新できません")}
547
+ if self.upd["state"] == "updating":
548
+ return {}
549
+ if self.dl and self.dl["proc"].poll() is None: # the restart would orphan the download
550
+ return {"error": T("ダウンロードが終わってから更新してください")}
551
+ self.upd.update(state="updating", msg=T("準備中…"))
552
+
553
+ def work():
554
+ try:
555
+ v = update.apply(self.upd["tag"], progress=lambda t: self.upd.update(msg=t))
556
+ self.upd.update(state="done", msg=T("v{0} に更新しました。再起動します…", v))
557
+ if self.on_updated:
558
+ self.on_updated()
559
+ else:
560
+ self.upd["msg"] = T("v{0} に更新しました。onw を起動し直してください", v)
561
+ except Exception as e:
562
+ self.upd.update(state="error", msg=T("更新できませんでした: {0}", e))
563
+ threading.Thread(target=work, daemon=True).start()
564
+ return {}
565
+
566
+ def open(self, what, key=None):
567
+ cfg = app.load_config()
568
+ if what == "log":
569
+ app.open_path(os.path.join(app.config_dir(), "server.log"))
570
+ elif what == "folder":
571
+ p = app.installed(cfg).get(key) if key else None
572
+ app.open_path(p or app.models_dir(cfg))
573
+ elif what in ("chat", "check"):
574
+ webbrowser.open(f"http://127.0.0.1:{app.server_port(cfg)}/{what}")
575
+ return {}
576
+
577
+
578
+ class _Handler(BaseHTTPRequestHandler):
579
+ manager: Manager = None
580
+ token: str = ""
581
+
582
+ def log_message(self, *a): # quiet
583
+ pass
584
+
585
+ def _send(self, code, body, ctype="application/json"):
586
+ data = body if isinstance(body, bytes) else json.dumps(body, ensure_ascii=False).encode("utf8")
587
+ self.send_response(code)
588
+ self.send_header("Content-Type", ctype + ("; charset=utf-8" if ctype.startswith("text") else ""))
589
+ self.send_header("Content-Length", str(len(data)))
590
+ self.send_header("Cache-Control", "no-store")
591
+ self.end_headers()
592
+ self.wfile.write(data)
593
+
594
+ def _authed(self, q):
595
+ t = self.headers.get("X-Onw-Token") or (q.get("token") or [""])[0]
596
+ return secrets.compare_digest(t, self.token)
597
+
598
+ def do_GET(self):
599
+ u = urlparse(self.path)
600
+ q = parse_qs(u.query)
601
+ if u.path in ("/", "/manage.html"):
602
+ return self._send(200, i18n.page(os.path.join(WEB, "manage.html")), "text/html")
603
+ if u.path == "/api/state":
604
+ if not self._authed(q):
605
+ return self._send(403, {"error": T("この画面は onw のウィンドウかトレイから開いてください")})
606
+ return self._send(200, self.manager.state())
607
+ self._send(404, {"error": "not found"})
608
+
609
+ def do_POST(self):
610
+ u = urlparse(self.path)
611
+ if not self._authed(parse_qs(u.query)):
612
+ return self._send(403, {"error": T("この画面は onw のウィンドウかトレイから開いてください")})
613
+ m = self.manager
614
+ act = u.path[len("/api/"):]
615
+ try:
616
+ n = int(self.headers.get("Content-Length") or 0)
617
+ body = json.loads(self.rfile.read(n) or b"{}")
618
+ if act == "load":
619
+ r = m.load(body["key"])
620
+ elif act == "unload":
621
+ r = m.unload()
622
+ elif act == "restart":
623
+ r = m.restart()
624
+ elif act == "download":
625
+ r = m.download(body["repo"])
626
+ elif act == "cancel_download":
627
+ r = m.cancel_download()
628
+ elif act == "delete":
629
+ r = m.delete(body["key"])
630
+ elif act == "clear_cache":
631
+ r = m.clear_cache(body["key"])
632
+ elif act == "folders":
633
+ r = m.folders(body.get("save_dir"), body.get("add"), body.get("remove"))
634
+ elif act == "settings":
635
+ r = m.settings(body)
636
+ elif act == "model_prefs":
637
+ r = m.model_prefs(body.get("key"), body.get("prefs") or {})
638
+ elif act == "update_check":
639
+ r = m.start_check()
640
+ elif act == "update":
641
+ r = m.run_update()
642
+ elif act == "open":
643
+ r = m.open(body.get("what"), body.get("key"))
644
+ else:
645
+ return self._send(404, {"error": "not found"})
646
+ except Exception as e:
647
+ r = {"error": T("エラー: {0}", e)}
648
+ self._send(200, r)
649
+
650
+
651
+ def _created():
652
+ try:
653
+ import psutil
654
+ return psutil.Process().create_time()
655
+ except Exception:
656
+ return None
657
+
658
+
659
+ def forget(info):
660
+ """Remove manager.json if it still describes this process's manager (at exit)."""
661
+ try:
662
+ if json.load(open(INFO())).get("pid") == info.get("pid"):
663
+ os.remove(INFO())
664
+ except (OSError, ValueError):
665
+ pass
666
+
667
+
668
+ def serve(manager=None):
669
+ """Start the manager's HTTP server in a daemon thread; returns {port, token}. Writes <config>/manager.json."""
670
+ manager = manager or Manager()
671
+ handler = type("H", (_Handler,), {"manager": manager, "token": secrets.token_hex(16)})
672
+ srv = ThreadingHTTPServer(("127.0.0.1", 0), handler)
673
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
674
+ info = {"port": srv.server_address[1], "token": handler.token, "pid": os.getpid(), "created": _created()}
675
+ with open(INFO() + ".tmp", "w") as f:
676
+ json.dump(info, f)
677
+ try:
678
+ os.chmod(INFO() + ".tmp", 0o600) # (the token) before it is in place
679
+ except OSError:
680
+ pass
681
+ os.replace(INFO() + ".tmp", INFO())
682
+ return info
683
+
684
+
685
+ def page_url(info, tab="server"):
686
+ return f"http://127.0.0.1:{info['port']}/manage.html?token={info['token']}#{tab}"
onw/models/gemma4.py CHANGED
@@ -1,135 +1,136 @@
1
- """Gemma 4 (google/gemma-4-E4B-it) from the proven NPU graphs of the ryugyosoft/gemma-4-E4B-it-npu repo.
2
-
3
- Gemma 4 needs several NPU-specific graph rewrites (head_dim-512 attention split into 256-wide heads, host-built
4
- sliding / full masks, static vision tower); those graphs already exist and are validated, so this builder imports
5
- them instead of rebuilding from safetensors:
6
- * decoder_S{1,16,64}_T1024.xml -> segment 0 of each block size (I/O renamed to the engine's conventions);
7
- * a new segment 1 per block size: the LM head as a shared INT8 input (one copy for all sizes) + softcapping;
8
- * the token embedding and the 2.7 GB per-layer embedding tables are pulled out of their OpenVINO IRs into
9
- shared.bin and looked up on the host (they are pure table reads) - no NPU memory, no worker process;
10
- * vision_split/vision_npu_280.xml is used as the vision tower.
11
- usage: onw convert <folder of ryugyosoft/gemma-4-E4B-it-npu> OUT_DIR
12
- """
13
- import json, os, shutil
14
- import numpy as np
15
- import openvino as ov
16
- import openvino.opset13 as ops
17
-
18
- from .. import ir
19
- from ..ir import i64, f32c
20
- from . import common
21
-
22
- EMB = "models/gemma-4-E4B-it-int4-ov"
23
-
24
-
25
- def _table(core, xml):
26
- """(int8 table, f16 per-row scale, output multiplier, gather-index subgraph output) from an embedding IR."""
27
- m = core.read_model(xml)
28
- tab = sc = mult = gather = None
29
- for op in m.get_ordered_ops():
30
- t = op.get_type_name()
31
- if t == "Constant" and op.get_output_element_type(0) == ov.Type.i8:
32
- tab = op.get_data().copy()
33
- elif t == "Constant" and op.get_output_element_type(0) == ov.Type.f16 and len(op.get_output_shape(0)) == 2:
34
- sc = op.get_data().reshape(-1).copy()
35
- elif t == "Constant" and op.get_output_element_type(0) == ov.Type.f32 and list(op.get_output_shape(0)) == [1, 1, 1]:
36
- mult = float(op.get_data().reshape(-1)[0])
37
- elif t == "Gather":
38
- gather = op
39
- return m, tab, sc, mult, gather
40
-
41
-
42
- def _id_map(core, m, gather, V):
43
- """The per-layer model remaps some token ids before its gather; evaluate that index for every id once."""
44
- idx = gather.input(1).get_source_output()
45
- sub = ov.Model([ops.result(idx)], m.get_parameters(), "idmap")
46
- r = core.compile_model(sub, "CPU").create_infer_request()
47
- return r.infer([np.arange(V, dtype=np.int64)[None]])[0].reshape(-1).astype(np.int32)
48
-
49
-
50
- def _rename(m):
51
- for p in m.get_parameters():
52
- n = p.get_output_tensor(0).get_any_name()
53
- if n.startswith("past_key_values."):
54
- _, l, kv = n.split(".")
55
- p.get_output_tensor(0).set_names({f"past_{kv[0]}.{l}"})
56
- for r in m.get_results(): # names live on the result's tensor and on its source
57
- tensors = [r.output(0).get_tensor(), r.input(0).get_source_output().get_tensor()]
58
- n = tensors[0].get_any_name()
59
- new = None
60
- if n.startswith("present."):
61
- _, l, kv = n.split(".")
62
- new = f"new_{kv[0]}.{l}"
63
- elif n == "hidden":
64
- new = "next.h"
65
- if new:
66
- for t in tensors:
67
- t.set_names({new})
68
- return m
69
-
70
-
71
- def _head(S, V, H, cap):
72
- """LM head + final logit softcapping cap * tanh(logits / cap). The 1/cap is applied to the hidden state before
73
- the matmul: with it after the matmul the NPU compiler miscomputes multi-row blocks (NPU 3720, S=16: 99% error)."""
74
- h = ops.parameter([1, S, H], ov.Type.f32, name="carry.h")
75
- lg, p = ir.shared_head(ops.multiply(h, f32c(1.0 / cap)), V, H)
76
- lg = ops.multiply(ops.tanh(lg), f32c(cap))
77
- return ir.finish({"logits": lg}, [h] + p, f"gemma4_head_S{S}")
78
-
79
-
80
- def export(src, out, sizes=(1, 16, 64), T=1024, fmt=None, graphs_only=False):
81
- from ..store import SharedWriter
82
- os.makedirs(out, exist_ok=True)
83
- core = ov.Core()
84
- full = json.load(open(os.path.join(src, EMB, "config.json")))
85
- tc = full["text_config"]
86
- V, H = tc["vocab_size"], tc["hidden_size"]
87
- metas = {}
88
- for S in sizes:
89
- xml = os.path.join(src, "models", "lm_static", f"decoder_S{S}_T{T}.xml")
90
- if not os.path.exists(xml):
91
- continue
92
- ov.save_model(_rename(core.read_model(xml)), os.path.join(out, f"seg00_S{S}.xml"))
93
- ov.save_model(_head(S, V, H, tc.get("final_logit_softcapping") or 30.0), os.path.join(out, f"seg01_S{S}.xml"))
94
- for i in (0, 1):
95
- metas[f"seg{i:02d}_S{S}.xml"] = {"index": i, "consume_moe": None, "router_layer": None, "S": S, "slots": 0,
96
- "head": i == 1}
97
- print(f"S={S}: segments saved", flush=True)
98
- if not metas:
99
- import glob
100
- have = sorted(os.path.basename(p) for p in glob.glob(os.path.join(src, "models", "lm_static", "decoder_S*_T*.xml")))
101
- raise SystemExit(f"no decoder graphs for T={T} and sizes {list(sizes)} in {src} (there are: {', '.join(have)}); "
102
- "Gemma 4 is imported from those pre-built graphs")
103
- vx = os.path.join(src, "models", "vision_split", "vision_npu_280.xml")
104
- shutil.copy(vx, os.path.join(out, "vision.xml"))
105
- shutil.copy(vx[:-4] + ".bin", os.path.join(out, "vision.bin"))
106
- pc = json.load(open(os.path.join(src, EMB, "processor_config.json")))
107
- meta = {"arch": "gemma4", "T": T, "sizes": [S for S in sizes if f"seg00_S{S}.xml" in metas], "config": tc,
108
- "segments": metas, "masks": {"sliding_window": tc["sliding_window"]},
109
- "host_inputs": {"per_layer_inputs": {"table": "ple", "shape": [tc["num_hidden_layers"], tc["hidden_size_per_layer_input"]]}},
110
- "vision": {"type": "gemma", "soft_tokens": 280, "image_processor": pc["image_processor"],
111
- "image_token_id": full["image_token_id"], "pad_token_id": tc.get("pad_token_id", 0)},
112
- "npu_vision_fallback": {"NPU_USE_NPUW": "YES", "NPUW_DEVICES": "NPU", "NPUW_FOLD": "YES"}}
113
- for f in ("tokenizer.json", "tokenizer_config.json", "chat_template.jinja", "config.json", "generation_config.json",
114
- "processor_config.json", "preprocessor_config.json"):
115
- if os.path.exists(os.path.join(src, EMB, f)):
116
- shutil.copy(os.path.join(src, EMB, f), os.path.join(out, f))
117
- if graphs_only:
118
- common.write_engine(out, meta, graphs_only)
119
- return
120
- sw = SharedWriter(os.path.join(out, "shared.bin"))
121
- m, tab, sc, mult, _ = _table(core, os.path.join(src, EMB, "openvino_text_embeddings_model.xml"))
122
- sw.add("embed_q", tab, "i8", tab.shape)
123
- sw.add("embed_s", (sc.astype(np.float32) * mult).astype(np.float16), "f16", sc.shape)
124
- sw.alias("head_q", "embed_q") # tied: same int8 rows,
125
- sw.add("head_s", sc.astype(np.float16), "f16", sc.shape) # without the embedding scale
126
- del tab
127
- ple_xml = os.path.join(src, EMB, "openvino_text_embeddings_per_layer_model.xml")
128
- m, tab, sc, mult, gather = _table(core, ple_xml)
129
- sw.add("ple_q", tab, "i8", tab.shape)
130
- sw.add("ple_s", (sc.astype(np.float32) * mult).astype(np.float16), "f16", sc.shape)
131
- idmap = _id_map(core, m, gather, tab.shape[0])
132
- sw.add("ple_map", idmap, "i32", idmap.shape)
133
- sw.close()
134
- print("shared tables saved", flush=True)
135
- common.write_engine(out, meta, graphs_only)
 
 
1
+ """Gemma 4 (google/gemma-4-E4B-it) from the proven NPU graphs of the ryugyosoft/gemma-4-E4B-it-npu repo.
2
+
3
+ Gemma 4 needs several NPU-specific graph rewrites (head_dim-512 attention split into 256-wide heads, host-built
4
+ sliding / full masks, static vision tower); those graphs already exist and are validated, so this builder imports
5
+ them instead of rebuilding from safetensors:
6
+ * decoder_S{1,16,64}_T1024.xml -> segment 0 of each block size (I/O renamed to the engine's conventions);
7
+ * a new segment 1 per block size: the LM head as a shared INT8 input (one copy for all sizes) + softcapping;
8
+ * the token embedding and the 2.7 GB per-layer embedding tables are pulled out of their OpenVINO IRs into
9
+ shared.bin and looked up on the host (they are pure table reads) - no NPU memory, no worker process;
10
+ * vision_split/vision_npu_280.xml is used as the vision tower.
11
+ usage: onw convert <folder of ryugyosoft/gemma-4-E4B-it-npu> OUT_DIR
12
+ """
13
+ import json, os, shutil
14
+ import numpy as np
15
+ import openvino as ov
16
+ from .. import npukit
17
+ import openvino.opset13 as ops
18
+
19
+ from .. import ir
20
+ from ..ir import i64, f32c
21
+ from . import common
22
+
23
+ EMB = "models/gemma-4-E4B-it-int4-ov"
24
+
25
+
26
+ def _table(core, xml):
27
+ """(int8 table, f16 per-row scale, output multiplier, gather-index subgraph output) from an embedding IR."""
28
+ m = core.read_model(xml)
29
+ tab = sc = mult = gather = None
30
+ for op in m.get_ordered_ops():
31
+ t = op.get_type_name()
32
+ if t == "Constant" and op.get_output_element_type(0) == ov.Type.i8:
33
+ tab = op.get_data().copy()
34
+ elif t == "Constant" and op.get_output_element_type(0) == ov.Type.f16 and len(op.get_output_shape(0)) == 2:
35
+ sc = op.get_data().reshape(-1).copy()
36
+ elif t == "Constant" and op.get_output_element_type(0) == ov.Type.f32 and list(op.get_output_shape(0)) == [1, 1, 1]:
37
+ mult = float(op.get_data().reshape(-1)[0])
38
+ elif t == "Gather":
39
+ gather = op
40
+ return m, tab, sc, mult, gather
41
+
42
+
43
+ def _id_map(core, m, gather, V):
44
+ """The per-layer model remaps some token ids before its gather; evaluate that index for every id once."""
45
+ idx = gather.input(1).get_source_output()
46
+ sub = ov.Model([ops.result(idx)], m.get_parameters(), "idmap")
47
+ r = core.compile_model(sub, "CPU").create_infer_request()
48
+ return r.infer([np.arange(V, dtype=np.int64)[None]])[0].reshape(-1).astype(np.int32)
49
+
50
+
51
+ def _rename(m):
52
+ for p in m.get_parameters():
53
+ n = p.get_output_tensor(0).get_any_name()
54
+ if n.startswith("past_key_values."):
55
+ _, l, kv = n.split(".")
56
+ p.get_output_tensor(0).set_names({f"past_{kv[0]}.{l}"})
57
+ for r in m.get_results(): # names live on the result's tensor and on its source
58
+ tensors = [r.output(0).get_tensor(), r.input(0).get_source_output().get_tensor()]
59
+ n = tensors[0].get_any_name()
60
+ new = None
61
+ if n.startswith("present."):
62
+ _, l, kv = n.split(".")
63
+ new = f"new_{kv[0]}.{l}"
64
+ elif n == "hidden":
65
+ new = "next.h"
66
+ if new:
67
+ for t in tensors:
68
+ t.set_names({new})
69
+ return m
70
+
71
+
72
+ def _head(S, V, H, cap):
73
+ """LM head + final logit softcapping cap * tanh(logits / cap). The 1/cap is applied to the hidden state before
74
+ the matmul: with it after the matmul the NPU compiler miscomputes multi-row blocks (NPU 3720, S=16: 99% error)."""
75
+ h = ops.parameter([1, S, H], ov.Type.f32, name="carry.h")
76
+ lg, p = ir.shared_head(ops.multiply(h, f32c(1.0 / cap)), V, H)
77
+ lg = ops.multiply(ops.tanh(lg), f32c(cap))
78
+ return ir.finish({"logits": lg}, [h] + p, f"gemma4_head_S{S}")
79
+
80
+
81
+ def export(src, out, sizes=(1, 16, 64), T=1024, fmt=None, graphs_only=False):
82
+ from ..store import SharedWriter
83
+ os.makedirs(out, exist_ok=True)
84
+ core = npukit.core()
85
+ full = json.load(open(os.path.join(src, EMB, "config.json")))
86
+ tc = full["text_config"]
87
+ V, H = tc["vocab_size"], tc["hidden_size"]
88
+ metas = {}
89
+ for S in sizes:
90
+ xml = os.path.join(src, "models", "lm_static", f"decoder_S{S}_T{T}.xml")
91
+ if not os.path.exists(xml):
92
+ continue
93
+ ov.save_model(_rename(core.read_model(xml)), os.path.join(out, f"seg00_S{S}.xml"))
94
+ ov.save_model(_head(S, V, H, tc.get("final_logit_softcapping") or 30.0), os.path.join(out, f"seg01_S{S}.xml"))
95
+ for i in (0, 1):
96
+ metas[f"seg{i:02d}_S{S}.xml"] = {"index": i, "consume_moe": None, "router_layer": None, "S": S, "slots": 0,
97
+ "head": i == 1}
98
+ print(f"S={S}: segments saved", flush=True)
99
+ if not metas:
100
+ import glob
101
+ have = sorted(os.path.basename(p) for p in glob.glob(os.path.join(src, "models", "lm_static", "decoder_S*_T*.xml")))
102
+ raise SystemExit(f"no decoder graphs for T={T} and sizes {list(sizes)} in {src} (there are: {', '.join(have)}); "
103
+ "Gemma 4 is imported from those pre-built graphs")
104
+ vx = os.path.join(src, "models", "vision_split", "vision_npu_280.xml")
105
+ shutil.copy(vx, os.path.join(out, "vision.xml"))
106
+ shutil.copy(vx[:-4] + ".bin", os.path.join(out, "vision.bin"))
107
+ pc = json.load(open(os.path.join(src, EMB, "processor_config.json")))
108
+ meta = {"arch": "gemma4", "T": T, "sizes": [S for S in sizes if f"seg00_S{S}.xml" in metas], "config": tc,
109
+ "segments": metas, "masks": {"sliding_window": tc["sliding_window"]},
110
+ "host_inputs": {"per_layer_inputs": {"table": "ple", "shape": [tc["num_hidden_layers"], tc["hidden_size_per_layer_input"]]}},
111
+ "vision": {"type": "gemma", "soft_tokens": 280, "image_processor": pc["image_processor"],
112
+ "image_token_id": full["image_token_id"], "pad_token_id": tc.get("pad_token_id", 0)},
113
+ "npu_vision_fallback": {"NPU_USE_NPUW": "YES", "NPUW_DEVICES": "NPU", "NPUW_FOLD": "YES"}}
114
+ for f in ("tokenizer.json", "tokenizer_config.json", "chat_template.jinja", "config.json", "generation_config.json",
115
+ "processor_config.json", "preprocessor_config.json"):
116
+ if os.path.exists(os.path.join(src, EMB, f)):
117
+ shutil.copy(os.path.join(src, EMB, f), os.path.join(out, f))
118
+ if graphs_only:
119
+ common.write_engine(out, meta, graphs_only)
120
+ return
121
+ sw = SharedWriter(os.path.join(out, "shared.bin"))
122
+ m, tab, sc, mult, _ = _table(core, os.path.join(src, EMB, "openvino_text_embeddings_model.xml"))
123
+ sw.add("embed_q", tab, "i8", tab.shape)
124
+ sw.add("embed_s", (sc.astype(np.float32) * mult).astype(np.float16), "f16", sc.shape)
125
+ sw.alias("head_q", "embed_q") # tied: same int8 rows,
126
+ sw.add("head_s", sc.astype(np.float16), "f16", sc.shape) # without the embedding scale
127
+ del tab
128
+ ple_xml = os.path.join(src, EMB, "openvino_text_embeddings_per_layer_model.xml")
129
+ m, tab, sc, mult, gather = _table(core, ple_xml)
130
+ sw.add("ple_q", tab, "i8", tab.shape)
131
+ sw.add("ple_s", (sc.astype(np.float32) * mult).astype(np.float16), "f16", sc.shape)
132
+ idmap = _id_map(core, m, gather, tab.shape[0])
133
+ sw.add("ple_map", idmap, "i32", idmap.shape)
134
+ sw.close()
135
+ print("shared tables saved", flush=True)
136
+ common.write_engine(out, meta, graphs_only)
onw/npukit.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """[3.0] onw's own NPU kit: an NPU compiler and an NPU plugin built for onw from Intel's open sources, for exactly the
2
+ OpenVINO release onw installs (Windows and Linux). The kit is used through a plugins file given to ov.Core - the "NPU" device is
3
+ then served by the kit's plugin, which loads the kit's compiler next to it; nothing of the installed OpenVINO is
4
+ changed, and with any other OpenVINO build onw uses OpenVINO's own NPU plugin as before.
5
+
6
+ What the kit adds (see TECHNICAL.md):
7
+ - weightless blobs (CACHE_MODE=OPTIMIZE_SIZE + ENABLE_WEIGHTLESS): the compiled graphs keep no weights, the cache holds
8
+ the graphs only (NF4 too - OpenVINO 2026.5's compiler cannot); the weights are rebuilt on the NPU at load from the
9
+ model's own files;
10
+ - one copy of identical weights for every graph of a model (paged parts, fused graphs of every length);
11
+ - faster compiles (the strategy search no longer serialises on the Windows heap, constant folding memoised) and
12
+ compiler fixes: 2-bit weights on 2+ rows (Bonsai), 4-token MTP verification blocks (Qwen3.5 NF4).
13
+ ONW_NPUKIT=0 turns it off."""
14
+ import json
15
+ import os
16
+ import tarfile
17
+
18
+ import openvino as ov
19
+
20
+ KIT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "npukit")
21
+ # Linux: the kit comes packed (npukit/linux/npukit-linux.tar.xz, ~1/4 of its size - installs that update from an older
22
+ # onw download every file once) and is unpacked next to it at the first start
23
+ LINUX = os.path.join(KIT, "linux")
24
+ _STATE = {"ok": None, "why": ""}
25
+
26
+
27
+ def _info():
28
+ try:
29
+ with open(os.path.join(KIT, "kit.json"), encoding="utf8") as f:
30
+ return json.load(f)
31
+ except (OSError, ValueError):
32
+ return None
33
+
34
+
35
+ def active():
36
+ """True when the kit serves the NPU in this process (checked once)."""
37
+ if _STATE["ok"] is None:
38
+ for n in os.listdir(KIT) if os.path.isdir(KIT) else []:
39
+ if ".dll.old-" in n: # (set aside by an update while a process had them loaded)
40
+ try:
41
+ os.remove(os.path.join(KIT, n))
42
+ except OSError:
43
+ pass
44
+ info = _info()
45
+ if os.environ.get("ONW_NPUKIT", "1") == "0":
46
+ _STATE.update(ok=False, why="off (ONW_NPUKIT=0)")
47
+ elif info is None:
48
+ _STATE.update(ok=False, why="not installed")
49
+ elif ov.__version__ != info.get("openvino"):
50
+ _STATE.update(ok=False, why=f"built for OpenVINO {info.get('openvino')}, not {ov.__version__}")
51
+ elif os.environ.get("ONW_NPUKIT") != "1" and _arch() < 4000:
52
+ # NPU 3720 (Meteor / Arrow Lake): nothing to gain - no graphs without weights there, and OpenVINO's own
53
+ # compiler builds ~7% faster graphs for it (Granite 4.2 3B INT4: 6.4 vs 6.0 tok/s, same answers)
54
+ _STATE.update(ok=False, why="not used on this NPU (ONW_NPUKIT=1 forces it)")
55
+ elif not _unpacked(info):
56
+ _STATE.update(ok=False, why="not installed")
57
+ else:
58
+ if os.name == "nt":
59
+ # the kit's plugin needs OpenVINO's own libraries (openvino.dll, TBB) from the installed package
60
+ os.add_dll_directory(os.path.join(os.path.dirname(ov.__file__), "libs"))
61
+ _STATE.update(ok=True, why=info.get("version", ""))
62
+ return _STATE["ok"]
63
+
64
+
65
+ def _dir():
66
+ return KIT if os.name == "nt" else LINUX
67
+
68
+
69
+ def _unpacked(info):
70
+ """All of the kit's files are there (Linux: unpacked from its archive when needed)."""
71
+ files = info.get("files" if os.name == "nt" else "files_linux", [])
72
+ if not files:
73
+ return False
74
+ arc = os.path.join(LINUX, info.get("archive_linux", "-"))
75
+ stamp = os.path.join(LINUX, "unpacked.txt")
76
+ if os.name != "nt" and os.path.isfile(arc):
77
+ mark = f"{os.path.getsize(arc)} {info.get('version', '')}"
78
+ try:
79
+ with open(stamp, encoding="utf8") as f:
80
+ done = f.read().strip() == mark
81
+ except OSError:
82
+ done = False
83
+ if done and all(os.path.exists(os.path.join(LINUX, f)) for f in files):
84
+ return True
85
+ try:
86
+ with tarfile.open(arc) as t:
87
+ for m in t.getmembers():
88
+ if m.isfile() and os.path.basename(m.name) == m.name and m.name in files:
89
+ tmp = os.path.join(LINUX, m.name + ".part")
90
+ with t.extractfile(m) as src, open(tmp, "wb") as dst:
91
+ while True:
92
+ b = src.read(1 << 22)
93
+ if not b:
94
+ break
95
+ dst.write(b)
96
+ os.replace(tmp, os.path.join(LINUX, m.name))
97
+ with open(stamp, "w", encoding="utf8") as f:
98
+ f.write(mark + "\n")
99
+ except (OSError, tarfile.TarError):
100
+ return False
101
+ return all(os.path.exists(os.path.join(_dir(), f)) for f in files)
102
+
103
+
104
+ def _arch():
105
+ """NPU generation from OpenVINO's own plugin (3720, 4000, 5010, ...), 0 when there is no NPU."""
106
+ try:
107
+ a = str(ov.Core().get_property("NPU", "DEVICE_ARCHITECTURE"))
108
+ return int("".join(c for c in a if c.isdigit()) or 0)
109
+ except Exception:
110
+ return 0
111
+
112
+
113
+ def why():
114
+ active()
115
+ return _STATE["why"]
116
+
117
+
118
+ def core():
119
+ """An ov.Core whose NPU is the kit's when the kit is usable (else OpenVINO's own)."""
120
+ if active():
121
+ return ov.Core(os.path.join(_dir(), "plugins.xml"))
122
+ return ov.Core()
onw/npukit/LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
onw/npukit/NOTICE.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ onw NPU kit (onw 3.0)
2
+
3
+ This folder holds binaries built by the onw project from open-source code of Intel Corporation, licensed under the
4
+ Apache License 2.0 (LICENSE). They are modified versions; the modifications are in patches/.
5
+
6
+ - openvino_intel_npu_compiler.dll: Intel NPU compiler (https://github.com/openvinotoolkit/npu_compiler) at commit
7
+ 20e7912fbc230725e1ca7394762348d4f480aa65 (submodule thirdparty/vpucostmodel at 4b5da8484d7a5c8cb097486c1248c4d2a753ae4b),
8
+ built as the compiler-in-driver library (BUILD_COMPILER_FOR_DRIVER=ON, Release) against OpenVINO
9
+ d8047fb380b27a9d5827cb3f22aec7781ae5ac82. Changes: patches/npu_compiler-20e7912.patch, patches/vpucostmodel.patch.
10
+ - openvino_intel_npu_plugin.dll: the NPU plugin of OpenVINO (https://github.com/openvinotoolkit/openvino) at commit
11
+ 6db412d0a2766d88936477bb97bd37acf2d9d767 - the commit of the OpenVINO 2026.5.0 release onw installs.
12
+ Changes: patches/openvino-6db412d-npu_plugin.patch.
13
+ - openvino_intel_npu_compiler_loader.dll, openvino_intel_npu_vm_runtime.dll: unchanged copies from the OpenVINO 2026.5.0
14
+ wheel (https://pypi.org/project/openvino/), needed next to the plugin.
15
+
16
+ Copyright (C) 2018-2026 Intel Corporation (the original code). The binaries are provided as is, without warranty.
onw/npukit/kit.json ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "3.0.0 (npu_compiler 8.3 + onw fixes, NPU plugin 2026.5.0 6db412d + weight sharing)",
3
+ "openvino": "2026.5.0-23160-6db412d0a27",
4
+ "files": [
5
+ "openvino_intel_npu_plugin.dll",
6
+ "openvino_intel_npu_compiler.dll",
7
+ "openvino_intel_npu_compiler_loader.dll",
8
+ "openvino_intel_npu_vm_runtime.dll",
9
+ "plugins.xml"
10
+ ],
11
+ "files_linux": [
12
+ "libopenvino_intel_npu_plugin.so",
13
+ "libopenvino_intel_npu_compiler.so",
14
+ "libopenvino_intel_npu_compiler_loader.so",
15
+ "libopenvino_intel_npu_vm_runtime.so",
16
+ "plugins.xml"
17
+ ],
18
+ "archive_linux": "npukit-linux.tar.xz"
19
+ }
onw/npukit/linux/npukit-linux.tar.xz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:46fa6b160305b720e56d0d4a44d76a344b06cf7ca886b2a26a447bfb338ba857
3
+ size 32282368
onw/npukit/openvino_intel_npu_compiler.dll ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ce1113abf245d6ada1fff920eb1373dc136e1190dc0558bc97d0b6c9ae27ed22
3
+ size 83966464
onw/npukit/openvino_intel_npu_compiler_loader.dll ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:15494682f53e465e38c7d655a108f04681fb2af7a1dce1738d2e9212aafe5a14
3
+ size 529680
onw/npukit/openvino_intel_npu_plugin.dll ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:91cf9ebe2bd9ef7a3e3e451b63e7497abe465bfa2396edb52b35e8eb1d4db2ce
3
+ size 7992832
onw/npukit/openvino_intel_npu_vm_runtime.dll ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:39ece78739633565efd60858f63f2a307397b6ef168019c8abe4a24f7b345c88
3
+ size 559376
onw/npukit/patches/npu_compiler-20e7912.patch ADDED
@@ -0,0 +1,1455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ diff --git a/src/vpux_compiler/include/vpux/compiler/core/dma_cost_model_utils.hpp b/src/vpux_compiler/include/vpux/compiler/core/dma_cost_model_utils.hpp
2
+ index 3bbd048..c7d8799 100644
3
+ --- a/src/vpux_compiler/include/vpux/compiler/core/dma_cost_model_utils.hpp
4
+ +++ b/src/vpux_compiler/include/vpux/compiler/core/dma_cost_model_utils.hpp
5
+ @@ -34,10 +34,10 @@ double getStrideDMACorrectionThresholdByArch(config::ArchKind arch);
6
+ bool applyStrideDMACorrectionForTile(vpux::NDTypeInterface tileType, bool isStridedDMA, uint32_t& cost,
7
+ config::ArchKind arch, bool isFullSearchVersion = false);
8
+ bool correctStrideDMACostOnAllTiles(
9
+ - ArrayRef<std::vector<std::pair<vpux::NDTypeInterface, llvm::DenseMap<mlir::Type, VPU::DistributionInfo>>>>
10
+ + ArrayRef<std::vector<std::pair<vpux::NDTypeInterface, llvm::SmallDenseMap<mlir::Type, VPU::DistributionInfo, 4>>>>
11
+ tilesTypes,
12
+ const std::function<vpux::NDTypeInterface(
13
+ - ArrayRef<std::pair<vpux::NDTypeInterface, llvm::DenseMap<mlir::Type, VPU::DistributionInfo>>>)>&
14
+ + ArrayRef<std::pair<vpux::NDTypeInterface, llvm::SmallDenseMap<mlir::Type, VPU::DistributionInfo, 4>>>)>&
15
+ tileTypeGetter,
16
+ SmallVector<uint32_t>& dmaCost, bool isStridedDMA, config::ArchKind arch);
17
+
18
+ diff --git a/src/vpux_compiler/include/vpux/compiler/dialect/VPU/utils/distributed_tensor_utils.hpp b/src/vpux_compiler/include/vpux/compiler/dialect/VPU/utils/distributed_tensor_utils.hpp
19
+ index 3943fd7..c15b6aa 100644
20
+ --- a/src/vpux_compiler/include/vpux/compiler/dialect/VPU/utils/distributed_tensor_utils.hpp
21
+ +++ b/src/vpux_compiler/include/vpux/compiler/dialect/VPU/utils/distributed_tensor_utils.hpp
22
+ @@ -46,7 +46,30 @@ const SmallVector<int64_t> DISTRIBUTED_N_ALIGNMENT = SmallVector<int64_t>{16, 1,
23
+ const SmallVector<int64_t> DISTRIBUTED_DW_ACT_C_ALIGNMENT = SmallVector<int64_t>{1, 32, 1, 1};
24
+ const SmallVector<int64_t> DISTRIBUTED_DW_WT_C_ALIGNMENT = SmallVector<int64_t>{32, 1, 1, 1};
25
+
26
+ -using TensorDistributionMap = llvm::DenseMap<mlir::Type, VPU::DistributionInfo>;
27
+ +// onw: a plain DenseMap allocates 64 buckets of DistributionInfo (~45 KB) even for one entry - above the Windows
28
+ +// low-fragmentation-heap limit, so every map went through the locked heap path and the parallel compile threads
29
+ +// serialised on it (strategy assignment ~3x slower than single-threaded). Up to 3 entries now live inline.
30
+ +using TensorDistributionMap = llvm::SmallDenseMap<mlir::Type, VPU::DistributionInfo, 4>;
31
+ +
32
+ +} // namespace VPU
33
+ +} // namespace vpux
34
+ +
35
+ +// onw: the inline buckets make these element types too big for SmallVector's default inline count; keep one inline.
36
+ +template <>
37
+ +struct llvm::CalculateSmallVectorDefaultInlinedElements<vpux::VPU::TensorDistributionMap> {
38
+ + static constexpr size_t value = 1;
39
+ +};
40
+ +template <>
41
+ +struct llvm::CalculateSmallVectorDefaultInlinedElements<std::pair<vpux::NDTypeInterface, vpux::VPU::TensorDistributionMap>> {
42
+ + static constexpr size_t value = 1;
43
+ +};
44
+ +template <>
45
+ +struct llvm::CalculateSmallVectorDefaultInlinedElements<std::pair<mlir::Type, vpux::VPU::TensorDistributionMap>> {
46
+ + static constexpr size_t value = 1;
47
+ +};
48
+ +
49
+ +namespace vpux {
50
+ +namespace VPU {
51
+
52
+ VPU::DistributionInfoAttr updateSliceLikeOpsAlignment(mlir::MLIRContext* ctx, vpux::ShapeRef inShape,
53
+ vpux::ShapeRef sliceShape,
54
+ diff --git a/src/vpux_compiler/include/vpux/compiler/dialect/VPU/utils/multi_cluster_strategy_utils.hpp b/src/vpux_compiler/include/vpux/compiler/dialect/VPU/utils/multi_cluster_strategy_utils.hpp
55
+ index 8895bf9..1e0b4d3 100644
56
+ --- a/src/vpux_compiler/include/vpux/compiler/dialect/VPU/utils/multi_cluster_strategy_utils.hpp
57
+ +++ b/src/vpux_compiler/include/vpux/compiler/dialect/VPU/utils/multi_cluster_strategy_utils.hpp
58
+ @@ -148,6 +148,9 @@ private:
59
+ std::pair<std::pair<mlir::Type, TensorDistributionMap>, std::pair<mlir::Type, TensorDistributionMap>>
60
+ getDistributionsWithStrategy(VPU::ClusteredOpInterface parentOp, VPU::MultiClusterStrategy parentStrategy,
61
+ VPU::ClusteredOpInterface userOp, VPU::MultiClusterStrategy userStrategy) const;
62
+ + std::pair<std::pair<mlir::Type, TensorDistributionMap>, std::pair<mlir::Type, TensorDistributionMap>>
63
+ + getDistributionsWithStrategyImpl(VPU::ClusteredOpInterface parentOp, VPU::MultiClusterStrategy parentStrategy,
64
+ + VPU::ClusteredOpInterface userOp, VPU::MultiClusterStrategy userStrategy) const;
65
+
66
+ /* DMA cost calculation helpers */
67
+ double getDMACostOfType(vpux::NDTypeInterface srcTensorType) const;
68
+ @@ -184,6 +187,8 @@ private:
69
+ SiblingOpsAnalysis& _siblingsOpsAnalysis;
70
+ bool _underSubgraphOpt = false;
71
+ bool _enableTilingFullSearchSpace = false;
72
+ + // onw: memo of getDistributionsWithStrategy results (type-erased, see multi_cluster_strategy_utils.cpp)
73
+ + mutable std::shared_ptr<void> _onwDistCache;
74
+ };
75
+ vpux::VPU::StrategyCost correctSwOpCost(VPU::SWOpInterface swOp, ArrayRef<vpux::NDTypeInterface> tiledInputTypes,
76
+ vpux::VPU::StrategyCost cost);
77
+ diff --git a/src/vpux_compiler/include/vpux/compiler/dialect/VPU/utils/vertical_fusion/v2/merge_vf_region_rewriter.hpp b/src/vpux_compiler/include/vpux/compiler/dialect/VPU/utils/vertical_fusion/v2/merge_vf_region_rewriter.hpp
78
+ index fe48940..c819038 100644
79
+ --- a/src/vpux_compiler/include/vpux/compiler/dialect/VPU/utils/vertical_fusion/v2/merge_vf_region_rewriter.hpp
80
+ +++ b/src/vpux_compiler/include/vpux/compiler/dialect/VPU/utils/vertical_fusion/v2/merge_vf_region_rewriter.hpp
81
+ @@ -40,6 +40,8 @@ protected:
82
+ std::shared_ptr<IVFScheduling<VFConfig>> detectScenario(VFConfig& vfConfig) const override;
83
+ std::optional<VFCase> findVFTiling(VPU::VerticalFusionOp mergedOp, VPU::VerticalFusionOp prevOp,
84
+ VPU::VerticalFusionOp currentOp, const MergeTilingPolicy& tilingPolicy) const;
85
+ + std::optional<VFCase> findVFTilingImpl(VPU::VerticalFusionOp mergedOp, VPU::VerticalFusionOp prevOp,
86
+ + VPU::VerticalFusionOp currentOp, const MergeTilingPolicy& tilingPolicy) const;
87
+ StrategyCost getOriginalVFCost(VPU::VerticalFusionOp vfOp) const;
88
+
89
+ VFCacheAnalysis& _cache;
90
+ diff --git a/src/vpux_compiler/src/compiler.cpp b/src/vpux_compiler/src/compiler.cpp
91
+ index 263a325..acf446c 100644
92
+ --- a/src/vpux_compiler/src/compiler.cpp
93
+ +++ b/src/vpux_compiler/src/compiler.cpp
94
+ @@ -1087,7 +1087,11 @@ void compileIEtoVPU(mlir::OwningOpRef<mlir::ModuleOp>& moduleOp,
95
+ wsExtractionMode.value());
96
+ ieToVPUpm.addPass(VPU::createConstructWsAnalysisPass(log));
97
+ ieToVPUpm.addPass(VPU::createIntroduceInitFunctionPass(wsExtractionMode.value(), initPart, memLimit, log));
98
+ - ieToVPUpm.addPass(VPU::createConcatInitResultsPass(wsExtractionMode.value(), initPart, memLimit, log));
99
+ + // onw: ONW_WS_NO_CONCAT keeps one init output per weight (no concatenation into one obfuscated buffer), so the
100
+ + // plugin can share identical weights between graphs of different structure
101
+ + if (std::getenv("ONW_WS_NO_CONCAT") == nullptr) {
102
+ + ieToVPUpm.addPass(VPU::createConcatInitResultsPass(wsExtractionMode.value(), initPart, memLimit, log));
103
+ + }
104
+ ieToVPUpm.addPass(VPU::createDestructWsAnalysisPass(log));
105
+
106
+ const auto grc = getDefaultGreedyRewriteConfig();
107
+ @@ -1121,8 +1125,10 @@ void compileVPUIP(mlir::OwningOpRef<mlir::ModuleOp>& vpuModule,
108
+ vpuipPM.addPass(VPU::createConstructWsAnalysisPass(log));
109
+ vpuipPM.addPass(VPU::createIntroduceInitFunctionPass(wsExtractionMode.value(), /* initPart = */ std::nullopt,
110
+ memLimit, log));
111
+ - vpuipPM.addPass(VPU::createConcatInitResultsPass(wsExtractionMode.value(), /* initPart = */ std::nullopt,
112
+ - memLimit, log));
113
+ + if (std::getenv("ONW_WS_NO_CONCAT") == nullptr) {
114
+ + vpuipPM.addPass(VPU::createConcatInitResultsPass(wsExtractionMode.value(), /* initPart = */ std::nullopt,
115
+ + memLimit, log));
116
+ + }
117
+ vpuipPM.addPass(VPU::createDestructWsAnalysisPass(log));
118
+ }
119
+
120
+ diff --git a/src/vpux_compiler/src/conversion/passes/IE2VPU/convert_layers_to_VPU.cpp b/src/vpux_compiler/src/conversion/passes/IE2VPU/convert_layers_to_VPU.cpp
121
+ index f80f56e..3cfef97 100644
122
+ --- a/src/vpux_compiler/src/conversion/passes/IE2VPU/convert_layers_to_VPU.cpp
123
+ +++ b/src/vpux_compiler/src/conversion/passes/IE2VPU/convert_layers_to_VPU.cpp
124
+ @@ -10,6 +10,8 @@
125
+ #include <mlir/Support/LLVM.h>
126
+ #include "vpux/compiler/conversion.hpp"
127
+ #include "vpux/compiler/dialect/IE/IR/dialect.hpp"
128
+ +#include "vpux/compiler/dialect/IE/IR/ops/data_movement.hpp"
129
+ +#include "vpux/compiler/dialect/IE/IR/ops/data_type.hpp"
130
+ #include "vpux/compiler/dialect/IE/IR/ops/activation.hpp"
131
+ #include "vpux/compiler/dialect/IE/IR/ops/arithmetic.hpp"
132
+ #include "vpux/compiler/dialect/IE/IR/ops/bitwise.hpp"
133
+ @@ -820,9 +822,72 @@ private:
134
+ void safeRunOnFunc() final;
135
+ };
136
+
137
+ +// onw: the weights-separation @init schedule permutes sub-byte (2/4-bit) weights as data; a permute of 4-bit elements
138
+ +// becomes a PermuteDMA with 4-bit element strides, which the DMA cannot do ("Non byte aligned stride value"). In init
139
+ +// functions only, do such permutes in f16 (exact for these integers) between two Converts.
140
+ +static void onwWidenSubBytePermutes(mlir::func::FuncOp func) {
141
+ + if (std::getenv("ONW_WS_NO_WIDEN") != nullptr || func.getName().find("init") == llvm::StringRef::npos ||
142
+ + config::getArch(func) <= config::ArchKind::NPU37XX) {
143
+ + return;
144
+ + }
145
+ + SmallVector<mlir::Operation*> todo;
146
+ + func.walk([&](mlir::Operation* op) {
147
+ + if (!mlir::isa<IE::MemPermuteOp, IE::ReorderOp, IE::TransposeOp>(op)) {
148
+ + return;
149
+ + }
150
+ + auto elemType = mlir::cast<NDTypeInterface>(op->getOperand(0).getType()).getElementType();
151
+ + mlir::Type storage = elemType;
152
+ + if (auto q = mlir::dyn_cast<mlir::quant::QuantizedType>(elemType)) {
153
+ + storage = q.getStorageType();
154
+ + }
155
+ + auto intType = mlir::dyn_cast<mlir::IntegerType>(storage);
156
+ + if (intType != nullptr && intType.getWidth() < 8) {
157
+ + todo.push_back(op);
158
+ + }
159
+ + });
160
+ + for (auto* op : todo) {
161
+ + mlir::OpBuilder builder(op);
162
+ + const auto loc = op->getLoc();
163
+ + const auto inType = mlir::cast<NDTypeInterface>(op->getOperand(0).getType());
164
+ + const auto outType = mlir::cast<NDTypeInterface>(op->getResult(0).getType());
165
+ + const auto inElem = inType.getElementType();
166
+ + const auto outElem = outType.getElementType();
167
+ + auto toStorage = [](mlir::Type t) -> mlir::Type {
168
+ + if (auto q = mlir::dyn_cast<mlir::quant::QuantizedType>(t)) {
169
+ + return q.getStorageType();
170
+ + }
171
+ + return t;
172
+ + };
173
+ + mlir::Value x = op->getOperand(0);
174
+ + if (mlir::isa<mlir::quant::QuantizedType>(inElem)) {
175
+ + x = builder.create<IE::QuantizeCastOp>(appendLoc(loc, "onw_storage_in"), x, toStorage(inElem));
176
+ + }
177
+ + auto f16 = mlir::Float16Type::get(op->getContext());
178
+ + x = builder.create<IE::ConvertOp>(appendLoc(loc, "onw_to_f16"), x, f16);
179
+ + auto* newOp = builder.clone(*op);
180
+ + newOp->setOperand(0, x);
181
+ + newOp->getResult(0).setType(outType.changeElemType(f16));
182
+ + mlir::Value y = builder.create<IE::ConvertOp>(appendLoc(loc, "onw_from_f16"), newOp->getResult(0),
183
+ + toStorage(outElem));
184
+ + if (mlir::isa<mlir::quant::QuantizedType>(outElem)) {
185
+ + y = builder.create<IE::QuantizeCastOp>(appendLoc(loc, "onw_storage_out"), y, outElem);
186
+ + }
187
+ + op->getResult(0).replaceAllUsesWith(y);
188
+ + op->erase();
189
+ + }
190
+ +}
191
+ +
192
+ void ConvertLayers2VPUPass::safeRunOnFunc() {
193
+ auto& ctx = getContext();
194
+ auto func = getOperation();
195
+ + if (const char* onwDump = std::getenv("ONW_DUMP_IE")) {
196
+ + std::error_code ec;
197
+ + llvm::raw_fd_ostream fo((std::string(onwDump) + "_" + func.getName().str() + ".mlir"), ec);
198
+ + if (!ec) {
199
+ + func->print(fo, mlir::OpPrintingFlags().enableDebugInfo(true, false));
200
+ + }
201
+ + }
202
+ + onwWidenSubBytePermutes(func);
203
+
204
+ mlir::ConversionTarget target(ctx);
205
+ target.addIllegalDialect<IE::IEDialect>();
206
+ diff --git a/src/vpux_compiler/src/dialect/IE/transforms/passes/swap_convert_with_reshape_kind_ops.cpp b/src/vpux_compiler/src/dialect/IE/transforms/passes/swap_convert_with_reshape_kind_ops.cpp
207
+ index d16690c..93f292f 100644
208
+ --- a/src/vpux_compiler/src/dialect/IE/transforms/passes/swap_convert_with_reshape_kind_ops.cpp
209
+ +++ b/src/vpux_compiler/src/dialect/IE/transforms/passes/swap_convert_with_reshape_kind_ops.cpp
210
+ @@ -3,6 +3,7 @@
211
+ // SPDX-License-Identifier: Apache-2.0
212
+ //
213
+
214
+ +#include "vpux/compiler/core/types/quantile_float/types.hpp"
215
+ #include "vpux/compiler/dialect/IE/IR/dialect.hpp"
216
+ #include "vpux/compiler/dialect/IE/IR/ops/data_movement.hpp"
217
+ #include "vpux/compiler/dialect/IE/IR/ops/data_type.hpp"
218
+ @@ -212,6 +213,27 @@ PropagationDecision SwapConvertWithReshapeKindOps::PropagateConvertToFuseConvert
219
+ if (!firstInputSize || !middleTypeSize || !lastOutputSize) {
220
+ return PropagationDecision::NoPropagate;
221
+ }
222
+ + // onw: in the weights-separation @init schedule, Converts around a QuantizeCast to/from a quantile (NF4) or
223
+ + // quantized type carry raw table indices; fusing them would create an unsupported "Convert to quantile".
224
+ + {
225
+ + auto isQuantLike = [](mlir::Type t) {
226
+ + return mlir::isa<mlir::quant::QuantizedType, vpux::type::QuantileType>(t);
227
+ + };
228
+ + auto func = firstConvert->getParentOfType<mlir::func::FuncOp>();
229
+ + if (func != nullptr && func.getName().find("init") != llvm::StringRef::npos &&
230
+ + (isQuantLike(firstInputType) || isQuantLike(firstOutputType) || isQuantLike(lastOutputType))) {
231
+ + return PropagationDecision::NoPropagate;
232
+ + }
233
+ + if (func != nullptr && func.getName().find("init") != llvm::StringRef::npos) {
234
+ + for (mlir::Operation* op = firstConvert.getOperation(); op != nullptr && op != lastConvert.getOperation();
235
+ + op = op->hasOneUse() ? *op->getUsers().begin() : nullptr) {
236
+ + if (mlir::isa<IE::QuantizeCastOp>(op) ||
237
+ + isQuantLike(mlir::cast<vpux::NDTypeInterface>(op->getResult(0).getType()).getElementType())) {
238
+ + return PropagationDecision::NoPropagate;
239
+ + }
240
+ + }
241
+ + }
242
+ + }
243
+
244
+ int64_t minTypeSize = std::min({firstInputSize, middleTypeSize, lastOutputSize});
245
+ if (minTypeSize == lastOutputSize) {
246
+ diff --git a/src/vpux_compiler/src/dialect/IE/transforms/passes/weights_dequantize_to_dynamic_dequantize.cpp b/src/vpux_compiler/src/dialect/IE/transforms/passes/weights_dequantize_to_dynamic_dequantize.cpp
247
+ index ac46b4a..5ec32cc 100644
248
+ --- a/src/vpux_compiler/src/dialect/IE/transforms/passes/weights_dequantize_to_dynamic_dequantize.cpp
249
+ +++ b/src/vpux_compiler/src/dialect/IE/transforms/passes/weights_dequantize_to_dynamic_dequantize.cpp
250
+ @@ -113,7 +113,11 @@ mlir::LogicalResult finishDynamicDequantizeRewrite(mlir::PatternRewriter& rewrit
251
+ rewriter.create<IE::DynamicDequantizeOp>(appendLoc(loc, "ddq"), rawWeights, scaleValue, zpValue, dstType);
252
+
253
+ // Mark the op for the DynDeq -> FakeQuantize bridge pass.
254
+ - if (wdInfo.isKVcachedPattern() || !zpElemType.isInteger(2)) {
255
+ + // [onw] 2-bit zero points too outside the KV-cached (one-row) pattern: unmarked, a multi-row FullyConnected
256
+ + // dequantizes 2-bit weights with the DynamicDequantize SHAVE kernel, which returns zeros for them; marked, they go
257
+ + // FakeQuantize -> group unroll -> palletized LUT on the DPU as the one-row graphs do (2-bit weights kept)
258
+ + static const bool onwMarkAll = std::getenv("ONW_U2_MARK") == nullptr || std::string(std::getenv("ONW_U2_MARK")) != "0";
259
+ + if (wdInfo.isKVcachedPattern() || !zpElemType.isInteger(2) || onwMarkAll) {
260
+ dynamicDequant->setAttr(IE::WEIGHTS_IMPORT_DYN_DEQUANT_ATTR, mlir::UnitAttr::get(rewriter.getContext()));
261
+ }
262
+
263
+ diff --git a/src/vpux_compiler/src/dialect/VPU/IR/ops/convert.cpp b/src/vpux_compiler/src/dialect/VPU/IR/ops/convert.cpp
264
+ index 7390492..4dd29e4 100644
265
+ --- a/src/vpux_compiler/src/dialect/VPU/IR/ops/convert.cpp
266
+ +++ b/src/vpux_compiler/src/dialect/VPU/IR/ops/convert.cpp
267
+ @@ -55,11 +55,37 @@ bool vpux::VPU::ConvertOp::areCastCompatible(mlir::TypeRange inputs, mlir::TypeR
268
+ return input && output && input.getShape() == output.getShape();
269
+ }
270
+
271
+ -bool vpux::VPU::ConvertOp::checkStrategyCompatibility(vpux::VPU::MultiClusterStrategy strategy, size_t) {
272
+ +bool vpux::VPU::ConvertOp::checkStrategyCompatibility(vpux::VPU::MultiClusterStrategy strategy, size_t numTiles) {
273
+ bool isStrategyCompatible = false;
274
+ constexpr int64_t MIN_DIM_SIZE_FOR_TILING = 4;
275
+ auto inputShape = getBoundedShape(getInput());
276
+
277
+ + // onw: a sub-byte output split along its innermost memory dim must give byte-aligned cluster segments, otherwise
278
+ + // the cluster DMAs start mid-byte ("Can't convert 172 Bit to Byte" in UnrollDistributedOps; seen in the init
279
+ + // schedule of weightless blobs: f16 -> si4, 128 rows over 3 clusters = 43 x 4 bit).
280
+ + const auto splitsMidByteFor = [&](mlir::Value v, Dim dim) {
281
+ + const auto type = mlir::cast<NDTypeInterface>(v.getType());
282
+ + const auto bits = type.getElemTypeSize().count();
283
+ + if (bits >= 8 || numTiles == 0 || type.getRank() != 4 || type.getDimsOrder().dimAt(type.getRank() - 1) != dim) {
284
+ + return false;
285
+ + }
286
+ + const auto shape = getBoundedShape(v);
287
+ + const auto chunk = divUp(shape[dim], static_cast<int64_t>(numTiles));
288
+ + return (chunk * bits) % 8 != 0;
289
+ + };
290
+ + const auto splitsMidByte = [&](Dim dim) {
291
+ + static const bool onwOff = std::getenv("ONW_NO_SUBBYTE_SPLIT_FIX") != nullptr;
292
+ + return !onwOff && (splitsMidByteFor(getInput(), dim) || splitsMidByteFor(getOutput(), dim));
293
+ + };
294
+ + if ((strategy == VPU::MultiClusterStrategy::SplitOverHeight ||
295
+ + strategy == VPU::MultiClusterStrategy::SplitOverHeightOverlapped) &&
296
+ + splitsMidByte(Dims4D::Act::H)) {
297
+ + return false;
298
+ + }
299
+ + if (strategy == VPU::MultiClusterStrategy::SplitOverKernel && splitsMidByte(Dims4D::Act::C)) {
300
+ + return false;
301
+ + }
302
+ +
303
+ switch (strategy) {
304
+ case VPU::MultiClusterStrategy::Clustering:
305
+ isStrategyCompatible = true;
306
+ diff --git a/src/vpux_compiler/src/dialect/VPU/transforms/passes/multi_cluster_strategy_assignment.cpp b/src/vpux_compiler/src/dialect/VPU/transforms/passes/multi_cluster_strategy_assignment.cpp
307
+ index adaca74..fab49de 100644
308
+ --- a/src/vpux_compiler/src/dialect/VPU/transforms/passes/multi_cluster_strategy_assignment.cpp
309
+ +++ b/src/vpux_compiler/src/dialect/VPU/transforms/passes/multi_cluster_strategy_assignment.cpp
310
+ @@ -5,6 +5,10 @@
311
+
312
+ #include "vpux/compiler/dialect/VPU/IR/dialect.hpp"
313
+ #include "vpux/compiler/dialect/VPU/transforms/passes.hpp"
314
+ +
315
+ +#include <chrono>
316
+ +#include <cstdio>
317
+ +#include <cstdlib>
318
+ #include "vpux/compiler/dialect/VPU/utils/cost_model/layer_vpunn_cost.hpp"
319
+ #include "vpux/compiler/dialect/VPU/utils/manual_strategy_utils.hpp"
320
+ #include "vpux/compiler/dialect/VPU/utils/multi_cluster_strategy_utils.hpp"
321
+ @@ -121,10 +125,21 @@ void MultiClusterStrategyAssignmentPass::safeRunOnFunc() {
322
+ _log.debug("Greedy Strategy Assignment");
323
+ auto enableMultiClusterForSWLayer =
324
+ config::getAvailableExecutor(module, config::ExecutorKind::SHAVE_ACT) != nullptr;
325
+ + const auto onwT0 = std::chrono::steady_clock::now();
326
+ strategyManager.assignMultiClusterStrategy(enableMultiClusterForSWLayer);
327
+ + const auto onwT1 = std::chrono::steady_clock::now();
328
+
329
+ _log.debug("Execute Subgraph Optimization");
330
+ strategyManager.optimizeMulticlusterStrategy();
331
+ + const auto onwT2 = std::chrono::steady_clock::now();
332
+ + if (const char* onwP = std::getenv("ONW_PROF_FILE")) {
333
+ + if (FILE* f = std::fopen(onwP, "a")) {
334
+ + std::fprintf(f, "mcsa ops %lld assign %.2f s optimize %.2f s%c", (long long)clusteredOpCount,
335
+ + std::chrono::duration<double>(onwT1 - onwT0).count(),
336
+ + std::chrono::duration<double>(onwT2 - onwT1).count(), 10);
337
+ + std::fclose(f);
338
+ + }
339
+ + }
340
+
341
+ _log.trace("Remove Temporary Strategy");
342
+ strategyManager.removeTemporaryMulticlusterStrategy();
343
+ diff --git a/src/vpux_compiler/src/dialect/VPU/utils/strategy_manager/multi_cluster_strategy_utils.cpp b/src/vpux_compiler/src/dialect/VPU/utils/strategy_manager/multi_cluster_strategy_utils.cpp
344
+ index 4bfec49..26e3194 100644
345
+ --- a/src/vpux_compiler/src/dialect/VPU/utils/strategy_manager/multi_cluster_strategy_utils.cpp
346
+ +++ b/src/vpux_compiler/src/dialect/VPU/utils/strategy_manager/multi_cluster_strategy_utils.cpp
347
+ @@ -29,6 +29,11 @@
348
+ #include "vpux/compiler/dialect/VPU/utils/op_tiling_cache.hpp"
349
+ #include "vpux/compiler/dialect/VPU/utils/scheduling/temporal_tiling_driver.hpp"
350
+ #include "vpux/compiler/dialect/VPU/utils/singleton_cache.hpp"
351
+ +#include <atomic>
352
+ +#include <cstdio>
353
+ +#include <cstdlib>
354
+ +#include <map>
355
+ +#include <tuple>
356
+ #include "vpux/compiler/dialect/VPU/utils/sparsity_utils.hpp"
357
+ #include "vpux/compiler/dialect/VPU/utils/tile_utils.hpp"
358
+ #include "vpux/compiler/dialect/VPUIP/utils/convert_to_dma_utils.hpp"
359
+ @@ -1081,8 +1086,90 @@ bool LayerCostModel::hasSpilling(VPU::ClusteredOpInterface op, vpux::NDTypeInter
360
+ return hasSpilling(op, srcTensorDistributionMap, dstTensorDistributionMap);
361
+ }
362
+
363
+ +namespace {
364
+ +using OnwDistPair = std::pair<std::pair<mlir::Type, TensorDistributionMap>, std::pair<mlir::Type, TensorDistributionMap>>;
365
+ +struct OnwDistCache {
366
+ + std::map<std::tuple<void*, int, void*, int>, OnwDistPair> m;
367
+ +};
368
+ +std::atomic<long long> onwDistCalls{0}, onwDistHits{0}, onwDistBad{0};
369
+ +// ONW_DIST_CACHE: unset/0 = off, "check" = compute every time and compare with the memo, anything else = use the memo
370
+ +int onwDistMode() {
371
+ + static const int mode = [] {
372
+ + const char* e = std::getenv("ONW_DIST_CACHE");
373
+ + if (e == nullptr || std::string(e) == "0" || std::string(e).empty()) {
374
+ + return 0;
375
+ + }
376
+ + return std::string(e) == "check" ? 2 : 1;
377
+ + }();
378
+ + return mode;
379
+ +}
380
+ +bool onwSameMap(const TensorDistributionMap& a, const TensorDistributionMap& b) {
381
+ + if (a.size() != b.size()) {
382
+ + return false;
383
+ + }
384
+ + for (const auto& kv : a) {
385
+ + auto it = b.find(kv.first);
386
+ + if (it == b.end() || !(it->second == kv.second)) {
387
+ + return false;
388
+ + }
389
+ + }
390
+ + return true;
391
+ +}
392
+ +bool onwSame(const OnwDistPair& a, const OnwDistPair& b) {
393
+ + return a.first.first == b.first.first && a.second.first == b.second.first &&
394
+ + onwSameMap(a.first.second, b.first.second) && onwSameMap(a.second.second, b.second.second);
395
+ +}
396
+ +struct OnwDistReport {
397
+ + ~OnwDistReport() {
398
+ + if (onwDistCalls.load() == 0) {
399
+ + return;
400
+ + }
401
+ + if (const char* p = std::getenv("ONW_PROF_FILE")) {
402
+ + if (FILE* f = std::fopen(p, "a")) {
403
+ + std::fprintf(f, "distcache calls %lld hits %lld mismatches %lld%c", onwDistCalls.load(),
404
+ + onwDistHits.load(), onwDistBad.load(), 10);
405
+ + std::fclose(f);
406
+ + }
407
+ + }
408
+ + }
409
+ +} onwDistReport;
410
+ +} // namespace
411
+ +
412
+ +OnwDistPair LayerCostModel::getDistributionsWithStrategy(VPU::ClusteredOpInterface parentOp,
413
+ + VPU::MultiClusterStrategy parentStrategy,
414
+ + VPU::ClusteredOpInterface userOp,
415
+ + VPU::MultiClusterStrategy userStrategy) const {
416
+ + const int mode = onwDistMode();
417
+ + if (mode == 0) {
418
+ + return getDistributionsWithStrategyImpl(parentOp, parentStrategy, userOp, userStrategy);
419
+ + }
420
+ + if (_onwDistCache == nullptr) {
421
+ + _onwDistCache = std::make_shared<OnwDistCache>();
422
+ + }
423
+ + auto& cache = static_cast<OnwDistCache*>(_onwDistCache.get())->m;
424
+ + ++onwDistCalls;
425
+ + const auto key = std::make_tuple(static_cast<void*>(parentOp.getOperation()), static_cast<int>(parentStrategy),
426
+ + static_cast<void*>(userOp.getOperation()), static_cast<int>(userStrategy));
427
+ + auto it = cache.find(key);
428
+ + if (it != cache.end()) {
429
+ + ++onwDistHits;
430
+ + if (mode == 1) {
431
+ + return it->second;
432
+ + }
433
+ + auto fresh = getDistributionsWithStrategyImpl(parentOp, parentStrategy, userOp, userStrategy);
434
+ + if (!onwSame(fresh, it->second)) {
435
+ + ++onwDistBad;
436
+ + it->second = fresh;
437
+ + }
438
+ + return fresh;
439
+ + }
440
+ + auto fresh = getDistributionsWithStrategyImpl(parentOp, parentStrategy, userOp, userStrategy);
441
+ + cache.emplace(key, fresh);
442
+ + return fresh;
443
+ +}
444
+ +
445
+ std::pair<std::pair<mlir::Type, TensorDistributionMap>, std::pair<mlir::Type, TensorDistributionMap>>
446
+ -LayerCostModel::getDistributionsWithStrategy(VPU::ClusteredOpInterface parentOp,
447
+ +LayerCostModel::getDistributionsWithStrategyImpl(VPU::ClusteredOpInterface parentOp,
448
+ VPU::MultiClusterStrategy parentStrategy, VPU::ClusteredOpInterface userOp,
449
+ VPU::MultiClusterStrategy userStrategy) const {
450
+ // Set the custom strategy to the op to get the accurate distributed type
451
+ @@ -1090,14 +1177,26 @@ LayerCostModel::getDistributionsWithStrategy(VPU::ClusteredOpInterface parentOp,
452
+ // e.g., Conv (SOK) -> SW (SOK), the output of the Conv would be SEGMENTED
453
+ // Conv (SOK) -> SW (Clustering), the output of the Conv would be DUPLICATED|SEGMENTED
454
+ // The DistributedType is decided by the ops multiCluster strategy attributes
455
+ + // onw: setting an op's strategy to the value it already has is a no-op (same uniqued attribute), so skip the
456
+ + // temporary set/restore pair then - each one rebuilds the op's attribute dictionary in the shared context.
457
+ auto greedyStrategyParentOp = getMultiClusterStrategyValue(parentOp);
458
+ auto greedyStrategyUserOp = getMultiClusterStrategyValue(userOp);
459
+ - parentOp.setMultiClusterStrategy(parentStrategy);
460
+ - userOp.setMultiClusterStrategy(userStrategy);
461
+ + const bool setParent = parentStrategy != greedyStrategyParentOp;
462
+ + const bool setUser = userStrategy != greedyStrategyUserOp;
463
+ + if (setParent) {
464
+ + parentOp.setMultiClusterStrategy(parentStrategy);
465
+ + }
466
+ + if (setUser) {
467
+ + userOp.setMultiClusterStrategy(userStrategy);
468
+ + }
469
+ auto targetOutput = getOutputWithDistribution(parentOp, parentStrategy);
470
+ auto targetInput = getInputWithDistribution(userOp, parentOp, userStrategy);
471
+ - parentOp.setMultiClusterStrategy(greedyStrategyParentOp);
472
+ - userOp.setMultiClusterStrategy(greedyStrategyUserOp);
473
+ + if (setParent) {
474
+ + parentOp.setMultiClusterStrategy(greedyStrategyParentOp);
475
+ + }
476
+ + if (setUser) {
477
+ + userOp.setMultiClusterStrategy(greedyStrategyUserOp);
478
+ + }
479
+
480
+ VPU::DistributionInfo outputDistribution{};
481
+ auto outputType = mlir::isa<VPU::SparseTensorType>(targetOutput.first)
482
+ diff --git a/src/vpux_compiler/src/dialect/VPU/utils/strategy_manager/subgraph_optimizer.cpp b/src/vpux_compiler/src/dialect/VPU/utils/strategy_manager/subgraph_optimizer.cpp
483
+ index 610fe6c..8cac379 100644
484
+ --- a/src/vpux_compiler/src/dialect/VPU/utils/strategy_manager/subgraph_optimizer.cpp
485
+ +++ b/src/vpux_compiler/src/dialect/VPU/utils/strategy_manager/subgraph_optimizer.cpp
486
+ @@ -3,6 +3,10 @@
487
+ // SPDX-License-Identifier: Apache-2.0
488
+ //
489
+
490
+ +#include <atomic>
491
+ +#include <chrono>
492
+ +#include <cstdio>
493
+ +#include <cstdlib>
494
+ #include "vpux/compiler/dialect/VPU/utils/strategy_manager/subgraph_optimizer.hpp"
495
+ #include "vpux/compiler/dialect/VPU/IR/ops/data_movement.hpp"
496
+ #include "vpux/compiler/dialect/VPU/IR/ops/internal.hpp"
497
+ @@ -17,6 +21,19 @@
498
+ #include <llvm/ADT/STLExtras.h>
499
+ #include <llvm/ADT/TypeSwitch.h>
500
+ #include "mlir/IR/Iterators.h"
501
+ +#include <atomic>
502
+ +#include <chrono>
503
+ +#include <optional>
504
+ +
505
+ +namespace {
506
+ +std::atomic<long long> onwSoNs[16];
507
+ +std::atomic<long long> onwSoN[16];
508
+ +struct OnwSoT {
509
+ + int i; std::chrono::steady_clock::time_point t0;
510
+ + explicit OnwSoT(int ii): i(ii), t0(std::chrono::steady_clock::now()) {}
511
+ + ~OnwSoT() { onwSoNs[i] += std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now() - t0).count(); ++onwSoN[i]; }
512
+ +};
513
+ +} // namespace
514
+
515
+ using namespace vpux;
516
+ using namespace VPU;
517
+ @@ -55,6 +72,7 @@ bool SubgraphOptimizer::isStrategySOHLike(VPU::ClusteredOpInterface op) {
518
+ }
519
+
520
+ bool SubgraphOptimizer::isValidStrategy(VPU::ClusteredOpInterface clusteredOp, VPU::MultiClusterStrategy strategy) {
521
+ + OnwSoT onwT8(8);
522
+ auto moduleOp = clusteredOp->getParentOfType<mlir::ModuleOp>();
523
+ auto tileOp = config::getTileExecutor(moduleOp);
524
+ const auto numTiles = tileOp.getCount();
525
+ @@ -249,6 +267,7 @@ VPU::MultiClusterStrategy SubgraphOptimizer::getBestInSOHLikeStrategies(VPU::Clu
526
+ double SubgraphOptimizer::getInputSpillingCostToMultiClusterLayer(VPU::ClusteredOpInterface clusteredOp,
527
+ VPU::MultiClusterStrategy strategy,
528
+ const SubgraphOptConfig& config) {
529
+ + OnwSoT onwT9(9);
530
+ return llvm::TypeSwitch<mlir::Operation*, double>(clusteredOp.getOperation())
531
+ .Case<NCEMaxPoolOp>([&](NCEMaxPoolOp origOp) {
532
+ return getInputSpillingCostToMultiClusterLayer(origOp, origOp.getInput(), strategy, config);
533
+ @@ -351,6 +370,7 @@ double SubgraphOptimizer::getInputSpillingCostToMultiClusterLayer(VPU::Clustered
534
+ double SubgraphOptimizer::getOutputSpillingCostToMultiClusterLayer(VPU::ClusteredOpInterface clusteredOp,
535
+ VPU::MultiClusterStrategy strategy,
536
+ const SubgraphOptConfig& config) {
537
+ + OnwSoT onwT10(10);
538
+ bool hasCalculatedSpillingWriteCost = false;
539
+ double totalSpillingCost = 0.0;
540
+ // Iterate users in stable program order: the write cost is accumulated only for the first user
541
+ @@ -380,8 +400,10 @@ double SubgraphOptimizer::getOutputSpillingCostToMultiClusterLayer(VPU::Clustere
542
+ auto userStrategy = config.useRollbackStrategy
543
+ ? getRollbackStrategy(userClusteredOp)
544
+ : _layerCostModel.getMultiClusterStrategyValue(userClusteredOp);
545
+ + std::optional<OnwSoT> onwCs(std::in_place, 13);
546
+ auto spillingCost =
547
+ _layerCostModel.calculateSpillingCost(clusteredOp, userClusteredOp, strategy, userStrategy);
548
+ + onwCs.reset();
549
+ if (!hasCalculatedSpillingWriteCost) {
550
+ totalSpillingCost += spillingCost.writeCost;
551
+ hasCalculatedSpillingWriteCost = true;
552
+ @@ -802,6 +824,7 @@ bool SubgraphOptimizer::hasInputSpillingToMultiClusterLayer(VPU::ClusteredOpInte
553
+ bool SubgraphOptimizer::hasInputSpillingToMultiClusterLayer(VPU::ClusteredOpInterface origClusteredOp,
554
+ mlir::Value input, VPU::MultiClusterStrategy origStrategy,
555
+ const SubgraphOptConfig& config) {
556
+ + OnwSoT onwT12(12);
557
+ if (mlir::isa<mlir::BlockArgument>(input)) {
558
+ return false;
559
+ }
560
+ @@ -891,6 +914,7 @@ bool SubgraphOptimizer::hasOutputSpillingToMultiClusterLayer(VPU::ClusteredOpInt
561
+ mlir::Operation* userOp,
562
+ VPU::MultiClusterStrategy origStrategy,
563
+ const SubgraphOptConfig& config) {
564
+ + OnwSoT onwT11(11);
565
+ auto propagateShapeCast = false;
566
+
567
+ if (mlir::isa<VPU::QuantizeCastOp, VPU::GroupSparseTensorOp>(userOp)) {
568
+ @@ -1010,6 +1034,8 @@ bool SubgraphOptimizer::hasOutputSpillingToMultiClusterLayer(VPU::ClusteredOpInt
569
+ /// 4. {SOK -> SOH -> SOK} convert to {SOH -> HK -> SOK}
570
+ /// @todo This algorithm is a local strategy optimization for subgraphs. The final strategy maybe not global optimized.
571
+ /// For example, {SOK -> SOK -> SOH} maybe better converting to {SOK -> SOK -> SOK}
572
+ +
573
+ +
574
+ void SubgraphOptimizer::optimizeStrategyAvoidSpillingOnSubgraph(VPU::ClusteredOpInterface clusteredOp) {
575
+ // #E112083 performance regression to be investigated in order to remove this filter
576
+ // TODO: [E-126102] Cost model for Grouped MatMul
577
+ @@ -1021,6 +1047,8 @@ void SubgraphOptimizer::optimizeStrategyAvoidSpillingOnSubgraph(VPU::ClusteredOp
578
+ // As a result we specifically handle it for Runtime Dequant cases. Details : E#162100
579
+ // Non-VF cost calculated here chooses misaligned strategy. This workaround can be removed when VF can
580
+ // align MC strategies itself. E#148750
581
+ + OnwSoT onwAll(0);
582
+ + std::optional<OnwSoT> onwPre(std::in_place, 1);
583
+ if (alignStrategyWithParentRuntimeDequant(clusteredOp, _layerCostModel)) {
584
+ _log.debug("[Subgraph Optimizer] Set SOK to Runtime Dequant Convolution to fix weights spilling");
585
+ }
586
+ @@ -1042,6 +1070,8 @@ void SubgraphOptimizer::optimizeStrategyAvoidSpillingOnSubgraph(VPU::ClusteredOp
587
+ return;
588
+ }
589
+
590
+ + onwPre.reset();
591
+ + OnwSoT onwBfs(2);
592
+ _log.debug("[Subgraph Optimizer] Begin Op {0}", clusteredOp->getLoc());
593
+
594
+ layersNeedRollback.clear();
595
+ @@ -1089,8 +1119,12 @@ void SubgraphOptimizer::optimizeStrategyAvoidSpillingOnSubgraph(VPU::ClusteredOp
596
+ currentOp = layersNeedRollback.front();
597
+ auto clusteredCurrentOp = mlir::dyn_cast<ClusteredOpInterface>(currentOp.getOperation());
598
+ layersNeedRollback.pop_front();
599
+ - rollbackStrategy =
600
+ - rollbackToSOH ? getBestInSOHLikeStrategies(currentOp) : getBestInSOKLikeStrategies(currentOp);
601
+ + ++onwSoN[3];
602
+ + {
603
+ + OnwSoT onwB(4);
604
+ + rollbackStrategy =
605
+ + rollbackToSOH ? getBestInSOHLikeStrategies(currentOp) : getBestInSOKLikeStrategies(currentOp);
606
+ + }
607
+ // Sometimes a layer triggers rollback several times in a subgraph because we allows rollback strategy be
608
+ // changed during optimization. It makes sense when we find rollback strategy A first but later a better
609
+ // rollback strategy B is found. However if B is equal to A, it means the rollback strategy is converged. We
610
+ @@ -1109,6 +1143,7 @@ void SubgraphOptimizer::optimizeStrategyAvoidSpillingOnSubgraph(VPU::ClusteredOp
611
+ // Check if the child layer needs rollback strategy.
612
+ // Iterate users in stable program order so the rollback BFS exploration (and the resulting
613
+ // strategies) does not depend on use-list ordering.
614
+ + std::optional<OnwSoT> onwCh(std::in_place, 6);
615
+ for (auto* directChild : usersInProgramOrder(currentOp.getOperation())) {
616
+ auto executeChild = directChild;
617
+ while (mlir::isa_and_nonnull<VPU::GroupSparseTensorOp>(executeChild) ||
618
+ @@ -1152,6 +1187,8 @@ void SubgraphOptimizer::optimizeStrategyAvoidSpillingOnSubgraph(VPU::ClusteredOp
619
+ }
620
+ }
621
+
622
+ + onwCh.reset();
623
+ + OnwSoT onwPa(7);
624
+ // Check if the parent layer needs rollback strategy
625
+ SmallVector<mlir::Value> layerInputs;
626
+ if (auto eltwiseOp = mlir::dyn_cast<VPU::NCEEltwiseOp>(currentOp.getOperation())) {
627
+ @@ -1210,6 +1247,7 @@ void SubgraphOptimizer::optimizeStrategyAvoidSpillingOnSubgraph(VPU::ClusteredOp
628
+ }
629
+ }
630
+
631
+ + OnwSoT onwAfter(5);
632
+ // Calculate original cost and rollback cost
633
+ double originalCost = 0;
634
+ double rollbackCost = 0;
635
+ @@ -1447,7 +1485,9 @@ void SubgraphOptimizer::optimizeStrategyAvoidSpillingOnModel() {
636
+ // Assure Layer cost only include layer DPU cost under this phase. Exclude layer activation DMAs
637
+ _layerCostModel.setUnderSubgraphOpt(true);
638
+ // detect shortcuts in model like resnet
639
+ + const auto onwS0 = std::chrono::steady_clock::now();
640
+ detectShortcuts();
641
+ + const auto onwS1 = std::chrono::steady_clock::now();
642
+
643
+ const auto callback = [this](VPU::ClusteredOpInterface clusteredOp) {
644
+ if (auto swOp = mlir::dyn_cast_or_null<VPU::SWOpInterface>(clusteredOp.getOperation())) {
645
+ @@ -1465,6 +1505,21 @@ void SubgraphOptimizer::optimizeStrategyAvoidSpillingOnModel() {
646
+
647
+ /// @brief Traversing nodes with preOrder to execute subgraph optimization
648
+ _func.walk(callback);
649
+ + const auto onwS2 = std::chrono::steady_clock::now();
650
+ + if (const char* onwP = std::getenv("ONW_PROF_FILE")) {
651
+ + if (FILE* f = std::fopen(onwP, "a")) {
652
+ + std::fprintf(f, "subgraph detectShortcuts %.2f s avoidSpilling walk %.2f s | per-op all %lld %.2f s, pre-checks %lld %.2f s, rollback search %lld %.2f s, bfs visits %lld, best-strategy %lld %.2f s, after-bfs %lld %.2f s, children %.2f s, parents %.2f s, isValid %lld %.2f s, inSpill %lld %.2f s, outSpill %lld %.2f s, hasOut %lld %.2f s, hasIn %lld %.2f s, calcSpill(out) %lld %.2f s%c",
653
+ + std::chrono::duration<double>(onwS1 - onwS0).count(),
654
+ + std::chrono::duration<double>(onwS2 - onwS1).count(),
655
+ + (long long)onwSoN[0].load(), onwSoNs[0].load() / 1e9, (long long)onwSoN[1].load(), onwSoNs[1].load() / 1e9,
656
+ + (long long)onwSoN[2].load(), onwSoNs[2].load() / 1e9, (long long)onwSoN[3].load(),
657
+ + (long long)onwSoN[4].load(), onwSoNs[4].load() / 1e9, (long long)onwSoN[5].load(), onwSoNs[5].load() / 1e9, onwSoNs[6].load() / 1e9, onwSoNs[7].load() / 1e9,
658
+ + (long long)onwSoN[8].load(), onwSoNs[8].load() / 1e9, (long long)onwSoN[9].load(), onwSoNs[9].load() / 1e9,
659
+ + (long long)onwSoN[10].load(), onwSoNs[10].load() / 1e9, (long long)onwSoN[11].load(), onwSoNs[11].load() / 1e9,
660
+ + (long long)onwSoN[12].load(), onwSoNs[12].load() / 1e9, (long long)onwSoN[13].load(), onwSoNs[13].load() / 1e9, 10);
661
+ + std::fclose(f);
662
+ + }
663
+ + }
664
+
665
+ const auto callbackForHK = [this](VPU::ClusteredOpInterface clusteredOp) {
666
+ auto currentStrategyAttr = clusteredOp.getMultiClusterStrategy();
667
+ diff --git a/src/vpux_compiler/src/dialect/VPU/utils/vertical_fusion/v1/merge_vf_region_rewriter.cpp b/src/vpux_compiler/src/dialect/VPU/utils/vertical_fusion/v1/merge_vf_region_rewriter.cpp
668
+ index 4207b33..7c9f3d1 100644
669
+ --- a/src/vpux_compiler/src/dialect/VPU/utils/vertical_fusion/v1/merge_vf_region_rewriter.cpp
670
+ +++ b/src/vpux_compiler/src/dialect/VPU/utils/vertical_fusion/v1/merge_vf_region_rewriter.cpp
671
+ @@ -17,6 +17,40 @@
672
+
673
+ #include <deque>
674
+
675
+ +#include <atomic>
676
+ +#include <chrono>
677
+ +#include <cstdio>
678
+ +#include <cstdlib>
679
+ +
680
+ +namespace {
681
+ +// [onw] profiling of the merge attempts (ONW_PROF_FILE=path: totals appended at exit)
682
+ +struct OnwVfProf {
683
+ + std::atomic<long long> ns[5]{};
684
+ + std::atomic<long long> n[5]{};
685
+ + const char* tag;
686
+ + explicit OnwVfProf(const char* t): tag(t) {}
687
+ + ~OnwVfProf() {
688
+ + const char* p = std::getenv("ONW_PROF_FILE");
689
+ + if (p == nullptr) return;
690
+ + if (FILE* f = std::fopen(p, "a")) {
691
+ + const char* names[5] = {"match", "fuseOpsInBlock", "findVFCase", "checkVFCost", "fuseBlocks"};
692
+ + for (int i = 0; i < 5; ++i)
693
+ + std::fprintf(f, "%s %s: %lld calls %.2f s%c", tag, names[i], (long long)n[i].load(), ns[i].load() / 1e9, 10);
694
+ + std::fclose(f);
695
+ + }
696
+ + }
697
+ +};
698
+ +struct OnwVfTimer {
699
+ + OnwVfProf& p; int i; std::chrono::steady_clock::time_point t0;
700
+ + OnwVfTimer(OnwVfProf& pp, int ii): p(pp), i(ii), t0(std::chrono::steady_clock::now()) {}
701
+ + ~OnwVfTimer() {
702
+ + p.ns[i] += std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now() - t0).count();
703
+ + ++p.n[i];
704
+ + }
705
+ +};
706
+ +OnwVfProf onwVfProf("v1");
707
+ +} // namespace
708
+ +
709
+ namespace vpux::VPU::VF::v1 {
710
+
711
+ namespace {
712
+ @@ -674,6 +708,7 @@ bool MergeVFRegionRewriter::checkVFCostFunction(VPU::VerticalFusionOp prevOp, VP
713
+
714
+ mlir::LogicalResult MergeVFRegionRewriter::matchAndRewrite(VPU::VerticalFusionOp vfOp,
715
+ mlir::PatternRewriter& rewriter) const {
716
+ + OnwVfTimer onwT0(onwVfProf, 0);
717
+ _log.trace("Starting vertical fusion for region with VerticalFusionOp {0} at location {1}", vfOp, vfOp->getLoc());
718
+ if (vfOp.getIsManualConfigured()) {
719
+ _log.trace("Skipping vertical fusion for manually configured VerticalFusionOp at location {0}", vfOp->getLoc());
720
+ @@ -709,7 +744,10 @@ mlir::LogicalResult MergeVFRegionRewriter::matchAndRewrite(VPU::VerticalFusionOp
721
+ continue;
722
+ }
723
+
724
+ - vfBlock = fuseOpsInBlock(rewriter, vfOp, parentVFOp.getOperation());
725
+ + {
726
+ + OnwVfTimer onwT(onwVfProf, 1);
727
+ + vfBlock = fuseOpsInBlock(rewriter, vfOp, parentVFOp.getOperation());
728
+ + }
729
+ // `findVFCase` internally compares operations using IR order which is calculated "on the fly" in e.g.
730
+ // `isBeforeInBlock()`
731
+ // - order recalculation modifies the state and, due to multi-threaded environment, results in a data race
732
+ @@ -718,10 +756,25 @@ mlir::LogicalResult MergeVFRegionRewriter::matchAndRewrite(VPU::VerticalFusionOp
733
+ // As `findVFCase` is expected to not modify the IR, what can be done is an eager recalculation
734
+ // of the operation orders that would ensure later calls to e.g. `isBeforeInBlock()` become concurrent
735
+ // reads instead of concurrent writes (which is no longer a data race).
736
+ - vfBlock->getBlock()->recomputeOpOrder();
737
+ - vfBlock.getBody()->recomputeOpOrder();
738
+ - auto vfCase = findVFCase(parentVFOp, vfOp, vfBlock);
739
+ - if (!vfCase.has_value() || !checkVFCostFunction(parentVFOp, vfOp, vfCase.value())) {
740
+ + // [onw] only when the order is invalid (see v2): renumbering the whole function block on every candidate made the
741
+ + // pass quadratic in the op count
742
+ + if (!vfBlock->getBlock()->isOpOrderValid()) {
743
+ + vfBlock->getBlock()->recomputeOpOrder();
744
+ + }
745
+ + if (!vfBlock.getBody()->isOpOrderValid()) {
746
+ + vfBlock.getBody()->recomputeOpOrder();
747
+ + }
748
+ + std::optional<VFCase> vfCase;
749
+ + {
750
+ + OnwVfTimer onwT(onwVfProf, 2);
751
+ + vfCase = findVFCase(parentVFOp, vfOp, vfBlock);
752
+ + }
753
+ + bool onwCostOk = false;
754
+ + if (vfCase.has_value()) {
755
+ + OnwVfTimer onwT(onwVfProf, 3);
756
+ + onwCostOk = checkVFCostFunction(parentVFOp, vfOp, vfCase.value());
757
+ + }
758
+ + if (!vfCase.has_value() || !onwCostOk) {
759
+ // Drop all references to vfBlock to avoid it being added back to the rewriter
760
+ // worklist.
761
+ vfBlock->dropAllReferences();
762
+ @@ -743,7 +796,10 @@ mlir::LogicalResult MergeVFRegionRewriter::matchAndRewrite(VPU::VerticalFusionOp
763
+ }
764
+
765
+ _log.trace("Merged subgraph {0}", vfBlock);
766
+ - fuseBlocks(rewriter, vfOp, vfBlock);
767
+ + {
768
+ + OnwVfTimer onwT(onwVfProf, 4);
769
+ + fuseBlocks(rewriter, vfOp, vfBlock);
770
+ + }
771
+
772
+ return mlir::success();
773
+ }
774
+ diff --git a/src/vpux_compiler/src/dialect/VPU/utils/vertical_fusion/v2/merge_vf_region_rewriter.cpp b/src/vpux_compiler/src/dialect/VPU/utils/vertical_fusion/v2/merge_vf_region_rewriter.cpp
775
+ index f3d8660..2e84289 100644
776
+ --- a/src/vpux_compiler/src/dialect/VPU/utils/vertical_fusion/v2/merge_vf_region_rewriter.cpp
777
+ +++ b/src/vpux_compiler/src/dialect/VPU/utils/vertical_fusion/v2/merge_vf_region_rewriter.cpp
778
+ @@ -28,6 +28,43 @@
779
+ #include <mlir/IR/IRMapping.h>
780
+ #include <mlir/IR/Value.h>
781
+
782
+ +#include <atomic>
783
+ +#include <string>
784
+ +#include <unordered_set>
785
+ +#include <mutex>
786
+ +#include <chrono>
787
+ +#include <cstdio>
788
+ +#include <cstdlib>
789
+ +
790
+ +namespace {
791
+ +// [onw] profiling of the merge attempts (ONW_PROF_FILE=path: totals appended at exit)
792
+ +struct OnwVfProf {
793
+ + std::atomic<long long> ns[20]{};
794
+ + std::atomic<long long> n[20]{};
795
+ + const char* tag;
796
+ + explicit OnwVfProf(const char* t): tag(t) {}
797
+ + ~OnwVfProf() {
798
+ + const char* p = std::getenv("ONW_PROF_FILE");
799
+ + if (p == nullptr) return;
800
+ + if (FILE* f = std::fopen(p, "a")) {
801
+ + const char* names[20] = {"match", "fuseOpsInBlock", "findVFCase", "checkVFCost", "fuseBlocks", "isLegalFusion", "isMCAligned", "VFConfig x2", "policy.match", "adjustMC", "findVFTiling", "snapshot/restore", "tiling none fail", "tiling none ok", "tiling one fail", "tiling one ok", "tiling both fail", "tiling both ok", "skipMerge", "negCacheHit"};
802
+ + for (int i = 0; i < 20; ++i)
803
+ + std::fprintf(f, "%s %s: %lld calls %.2f s%c", tag, names[i], (long long)n[i].load(), ns[i].load() / 1e9, 10);
804
+ + std::fclose(f);
805
+ + }
806
+ + }
807
+ +};
808
+ +struct OnwVfTimer {
809
+ + OnwVfProf& p; int i; std::chrono::steady_clock::time_point t0;
810
+ + OnwVfTimer(OnwVfProf& pp, int ii): p(pp), i(ii), t0(std::chrono::steady_clock::now()) {}
811
+ + ~OnwVfTimer() {
812
+ + p.ns[i] += std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now() - t0).count();
813
+ + ++p.n[i];
814
+ + }
815
+ +};
816
+ +OnwVfProf onwVfProf("v2");
817
+ +} // namespace
818
+ +
819
+ namespace vpux::VPU::VF::v2 {
820
+
821
+ namespace {
822
+ @@ -241,11 +278,20 @@ bool MergeVFRegionRewriter::isMCStrategyAligned(VPU::VerticalFusionOp currentOp,
823
+
824
+ std::optional<VFCase> MergeVFRegionRewriter::findVFCase(VPU::VerticalFusionOp prevOp, VPU::VerticalFusionOp currentOp,
825
+ VPU::VerticalFusionOp mergedVFOp) const {
826
+ - if (!isLegalFusion(currentOp, prevOp)) {
827
+ + bool onwLegal;
828
+ + {
829
+ + OnwVfTimer onwT(onwVfProf, 5);
830
+ + onwLegal = isLegalFusion(currentOp, prevOp);
831
+ + }
832
+ + if (!onwLegal) {
833
+ _log.trace("Illegal fusion between {0} and {1}", currentOp->getLoc(), prevOp->getLoc());
834
+ return std::nullopt;
835
+ }
836
+ - const auto isMCStrategyAlreadyAligned = isMCStrategyAligned(currentOp, prevOp);
837
+ + bool isMCStrategyAlreadyAligned;
838
+ + {
839
+ + OnwVfTimer onwT(onwVfProf, 6);
840
+ + isMCStrategyAlreadyAligned = isMCStrategyAligned(currentOp, prevOp);
841
+ + }
842
+ const auto supportedPolicyTypes = getSupportedMergeTilingPolicyTypes();
843
+
844
+ for (const auto& tilingPolicy : _tilingPolicies) {
845
+ @@ -253,20 +299,47 @@ std::optional<VFCase> MergeVFRegionRewriter::findVFCase(VPU::VerticalFusionOp pr
846
+ continue;
847
+ }
848
+
849
+ - VFConfig prevConfig(prevOp, _cache, _enableVerticalFusionPipelining);
850
+ - VFConfig currentConfig(currentOp, _cache, _enableVerticalFusionPipelining);
851
+ - if (!tilingPolicy->match(prevConfig, currentConfig)) {
852
+ + std::optional<VFConfig> prevConfigO, currentConfigO;
853
+ + {
854
+ + OnwVfTimer onwT(onwVfProf, 7);
855
+ + prevConfigO.emplace(prevOp, _cache, _enableVerticalFusionPipelining);
856
+ + currentConfigO.emplace(currentOp, _cache, _enableVerticalFusionPipelining);
857
+ + }
858
+ + auto& prevConfig = *prevConfigO;
859
+ + auto& currentConfig = *currentConfigO;
860
+ + bool onwMatch;
861
+ + {
862
+ + OnwVfTimer onwT(onwVfProf, 8);
863
+ + onwMatch = tilingPolicy->match(prevConfig, currentConfig);
864
+ + }
865
+ + if (!onwMatch) {
866
+ continue;
867
+ }
868
+
869
+ - auto mcStrategyAttrSnapshot = snapshotMCStrategyAttrs(mergedVFOp);
870
+ - if (isMCStrategyAlreadyAligned || tilingPolicy->adjustMCStrategyInMergedVF(currentOp, prevOp, mergedVFOp)) {
871
+ - auto mergedCase = findVFTiling(mergedVFOp, prevOp, currentOp, *tilingPolicy);
872
+ + std::optional<decltype(snapshotMCStrategyAttrs(mergedVFOp))> snapO;
873
+ + {
874
+ + OnwVfTimer onwT(onwVfProf, 11);
875
+ + snapO.emplace(snapshotMCStrategyAttrs(mergedVFOp));
876
+ + }
877
+ + bool onwAdj = isMCStrategyAlreadyAligned;
878
+ + if (!onwAdj) {
879
+ + OnwVfTimer onwT(onwVfProf, 9);
880
+ + onwAdj = tilingPolicy->adjustMCStrategyInMergedVF(currentOp, prevOp, mergedVFOp);
881
+ + }
882
+ + if (onwAdj) {
883
+ + std::optional<VFCase> mergedCase;
884
+ + {
885
+ + OnwVfTimer onwT(onwVfProf, 10);
886
+ + mergedCase = findVFTiling(mergedVFOp, prevOp, currentOp, *tilingPolicy);
887
+ + }
888
+ if (mergedCase.has_value()) {
889
+ return mergedCase;
890
+ }
891
+ }
892
+ - restoreMCStrategyAttrs(mcStrategyAttrSnapshot);
893
+ + {
894
+ + OnwVfTimer onwT(onwVfProf, 11);
895
+ + restoreMCStrategyAttrs(*snapO);
896
+ + }
897
+ }
898
+
899
+ if (!isMCStrategyAlreadyAligned) {
900
+ @@ -325,6 +398,19 @@ MergeVFRegionRewriter::IVFSchedulingPtr MergeVFRegionRewriter::detectScenario(VF
901
+ std::optional<VFCase> MergeVFRegionRewriter::findVFTiling(VPU::VerticalFusionOp mergedOp, VPU::VerticalFusionOp prevOp,
902
+ VPU::VerticalFusionOp currentOp,
903
+ const MergeTilingPolicy& tilingPolicy) const {
904
+ + const auto t0 = std::chrono::steady_clock::now();
905
+ + const int cls = (hasTiling(parseIntArrayAttr<int64_t>(currentOp.getTilingStrategy())) ? 1 : 0) +
906
+ + (hasTiling(parseIntArrayAttr<int64_t>(prevOp.getTilingStrategy())) ? 1 : 0);
907
+ + auto r = findVFTilingImpl(mergedOp, prevOp, currentOp, tilingPolicy);
908
+ + const int slot = 12 + cls * 2 + (r.has_value() ? 1 : 0);
909
+ + onwVfProf.ns[slot] += std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now() - t0).count();
910
+ + ++onwVfProf.n[slot];
911
+ + return r;
912
+ +}
913
+ +
914
+ +std::optional<VFCase> MergeVFRegionRewriter::findVFTilingImpl(VPU::VerticalFusionOp mergedOp, VPU::VerticalFusionOp prevOp,
915
+ + VPU::VerticalFusionOp currentOp,
916
+ + const MergeTilingPolicy& tilingPolicy) const {
917
+ const auto currentTiling = parseIntArrayAttr<int64_t>(currentOp.getTilingStrategy());
918
+ const auto prevTiling = parseIntArrayAttr<int64_t>(prevOp.getTilingStrategy());
919
+
920
+ @@ -345,6 +431,7 @@ std::optional<VFCase> MergeVFRegionRewriter::findVFTiling(VPU::VerticalFusionOp
921
+
922
+ bool opsNeedTiling = prevHasTiling || curHasTiling;
923
+ if (canSkipMergeVF(vfConfig, opsNeedTiling)) {
924
+ + ++onwVfProf.n[18];
925
+ return std::nullopt;
926
+ }
927
+
928
+ @@ -543,8 +630,61 @@ bool MergeVFRegionRewriter::checkVFCostFunction(VPU::VerticalFusionOp prevOp, VP
929
+ return true;
930
+ }
931
+
932
+ +
933
+ +namespace {
934
+ +// [onw] Structural signature of a VF candidate pair: the ops of both regions (names, attributes, operand / result
935
+ +// types with quantized element types reduced to their storage type) and the link between them. Group-unrolled
936
+ +// MatMuls (2-bit / 4-bit group-quantized weights) produce long runs of identically shaped VF regions; the same merge
937
+ +// was searched (tiling + VPUNN cost, ~15 ms) and rejected thousands of times. Rejections are remembered per
938
+ +// signature (ONW_VF_NEG_CACHE=0: off).
939
+ +llvm::hash_code onwHashType(mlir::Type t) {
940
+ + if (auto nd = mlir::dyn_cast<vpux::NDTypeInterface>(t)) {
941
+ + auto elem = nd.getElementType();
942
+ + if (auto q = mlir::dyn_cast<mlir::quant::QuantizedType>(elem)) {
943
+ + elem = q.getStorageType();
944
+ + }
945
+ + const auto shape = nd.getShape().raw();
946
+ + return llvm::hash_combine(llvm::hash_combine_range(shape.begin(), shape.end()), elem.getAsOpaquePointer(),
947
+ + nd.getDimsOrder().code(), nd.getMemSpace().getAsOpaquePointer(),
948
+ + t.getTypeID().getAsOpaquePointer());
949
+ + }
950
+ + return llvm::hash_value(t.getAsOpaquePointer());
951
+ +}
952
+ +
953
+ +llvm::hash_code onwHashVF(VPU::VerticalFusionOp op) {
954
+ + llvm::hash_code h = llvm::hash_value(op->getAttrDictionary().getAsOpaquePointer());
955
+ + for (auto v : op->getOperands()) {
956
+ + h = llvm::hash_combine(h, onwHashType(v.getType()));
957
+ + }
958
+ + op.getBody()->walk([&](mlir::Operation* o) {
959
+ + h = llvm::hash_combine(h, o->getName().getAsOpaquePointer(), o->getAttrDictionary().getAsOpaquePointer());
960
+ + for (auto v : o->getOperands()) {
961
+ + h = llvm::hash_combine(h, onwHashType(v.getType()));
962
+ + }
963
+ + for (auto t : o->getResultTypes()) {
964
+ + h = llvm::hash_combine(h, onwHashType(t));
965
+ + }
966
+ + });
967
+ + return h;
968
+ +}
969
+ +
970
+ +struct OnwVfNegCache {
971
+ + std::mutex mutex;
972
+ + std::unordered_set<size_t> keys;
973
+ +};
974
+ +OnwVfNegCache& onwVfNegCache() {
975
+ + static OnwVfNegCache c;
976
+ + return c;
977
+ +}
978
+ +bool onwNegCacheOn() {
979
+ + static const bool on = std::getenv("ONW_VF_NEG_CACHE") == nullptr || std::string(std::getenv("ONW_VF_NEG_CACHE")) != "0";
980
+ + return on;
981
+ +}
982
+ +} // namespace
983
+ +
984
+ mlir::LogicalResult MergeVFRegionRewriter::matchAndRewrite(VPU::VerticalFusionOp vfOp,
985
+ mlir::PatternRewriter& rewriter) const {
986
+ + OnwVfTimer onwT0(onwVfProf, 0);
987
+ _log.trace("Starting vertical fusion for region with VerticalFusionOp {0} at location {1}", vfOp, vfOp->getLoc());
988
+ if (vfOp.getIsManualConfigured()) {
989
+ _log.trace("Skipping vertical fusion for manually configured VerticalFusionOp at location {0}", vfOp->getLoc());
990
+ @@ -553,13 +693,27 @@ mlir::LogicalResult MergeVFRegionRewriter::matchAndRewrite(VPU::VerticalFusionOp
991
+
992
+ VPU::VerticalFusionOp vfBlock = nullptr;
993
+ VPU::VerticalFusionOp parentVFOp = nullptr;
994
+ + unsigned onwIdx = 0;
995
+ for (auto operand : vfOp->getOperands()) {
996
+ + const unsigned onwOperandIdx = onwIdx++;
997
+ parentVFOp = operand.getDefiningOp<VPU::VerticalFusionOp>();
998
+ vfBlock = nullptr;
999
+
1000
+ if (parentVFOp == nullptr || parentVFOp.getIsManualConfigured()) {
1001
+ continue;
1002
+ }
1003
+ + size_t onwKey = 0;
1004
+ + if (onwNegCacheOn()) {
1005
+ + onwKey = llvm::hash_combine(onwHashVF(parentVFOp), onwHashVF(vfOp), onwOperandIdx);
1006
+ + std::lock_guard<std::mutex> lock(onwVfNegCache().mutex);
1007
+ + if (onwVfNegCache().keys.count(onwKey) != 0) {
1008
+ + ++onwVfProf.n[19];
1009
+ + if (checkOtherVFInput(vfOp, parentVFOp)) {
1010
+ + continue;
1011
+ + }
1012
+ + return mlir::failure();
1013
+ + }
1014
+ + }
1015
+
1016
+ _log.trace("Analyzing vertical fusion region with parent VerticalFusionOp {0} at location {1}", parentVFOp,
1017
+ parentVFOp->getLoc());
1018
+ @@ -580,7 +734,10 @@ mlir::LogicalResult MergeVFRegionRewriter::matchAndRewrite(VPU::VerticalFusionOp
1019
+ continue;
1020
+ }
1021
+
1022
+ - vfBlock = fuseOpsInBlock(rewriter, vfOp, parentVFOp.getOperation());
1023
+ + {
1024
+ + OnwVfTimer onwT(onwVfProf, 1);
1025
+ + vfBlock = fuseOpsInBlock(rewriter, vfOp, parentVFOp.getOperation());
1026
+ + }
1027
+ _log.trace("Merged subgraph candidate {0}", vfBlock.getLoc());
1028
+ // `findVFCase` internally compares operations using IR order which is calculated "on the fly" in e.g.
1029
+ // `isBeforeInBlock()`
1030
+ @@ -590,15 +747,34 @@ mlir::LogicalResult MergeVFRegionRewriter::matchAndRewrite(VPU::VerticalFusionOp
1031
+ // As `findVFCase` is expected to not modify the IR, what can be done is an eager recalculation
1032
+ // of the operation orders that would ensure later calls to e.g. `isBeforeInBlock()` become concurrent
1033
+ // reads instead of concurrent writes (which is no longer a data race).
1034
+ - vfBlock->getBlock()->recomputeOpOrder();
1035
+ - vfBlock.getBody()->recomputeOpOrder();
1036
+ - auto vfCase = findVFCase(parentVFOp, vfOp, vfBlock);
1037
+ - if (!vfCase.has_value() || !checkVFCostFunction(parentVFOp, vfOp, vfCase.value())) {
1038
+ + // [onw] only when the order is invalid: renumbering the whole function block on every candidate made the pass
1039
+ + // quadratic in the op count (Bonsai 2-bit group-unrolled graphs: ~60% of the compile time)
1040
+ + if (!vfBlock->getBlock()->isOpOrderValid()) {
1041
+ + vfBlock->getBlock()->recomputeOpOrder();
1042
+ + }
1043
+ + if (!vfBlock.getBody()->isOpOrderValid()) {
1044
+ + vfBlock.getBody()->recomputeOpOrder();
1045
+ + }
1046
+ + std::optional<VFCase> vfCase;
1047
+ + {
1048
+ + OnwVfTimer onwT(onwVfProf, 2);
1049
+ + vfCase = findVFCase(parentVFOp, vfOp, vfBlock);
1050
+ + }
1051
+ + bool onwCostOk = false;
1052
+ + if (vfCase.has_value()) {
1053
+ + OnwVfTimer onwT(onwVfProf, 3);
1054
+ + onwCostOk = checkVFCostFunction(parentVFOp, vfOp, vfCase.value());
1055
+ + }
1056
+ + if (!vfCase.has_value() || !onwCostOk) {
1057
+ // Drop all references to vfBlock to avoid it being added back to the rewriter
1058
+ // worklist.
1059
+ vfBlock->dropAllReferences();
1060
+ rewriter.eraseOp(vfBlock);
1061
+ vfBlock = nullptr;
1062
+ + if (onwNegCacheOn()) {
1063
+ + std::lock_guard<std::mutex> lock(onwVfNegCache().mutex);
1064
+ + onwVfNegCache().keys.insert(onwKey);
1065
+ + }
1066
+ if (checkOtherVFInput(vfOp, parentVFOp)) {
1067
+ continue;
1068
+ }
1069
+ @@ -614,7 +790,10 @@ mlir::LogicalResult MergeVFRegionRewriter::matchAndRewrite(VPU::VerticalFusionOp
1070
+ }
1071
+
1072
+ _log.trace("Merged subgraph {0}", vfBlock->getLoc());
1073
+ - fuseBlocks(rewriter, vfOp, vfBlock);
1074
+ + {
1075
+ + OnwVfTimer onwT(onwVfProf, 4);
1076
+ + fuseBlocks(rewriter, vfOp, vfBlock);
1077
+ + }
1078
+
1079
+ return mlir::success();
1080
+ }
1081
+ diff --git a/src/vpux_compiler/src/dialect/VPU/utils/weights_separation.cpp b/src/vpux_compiler/src/dialect/VPU/utils/weights_separation.cpp
1082
+ index 35a6c00..d48e08c 100644
1083
+ --- a/src/vpux_compiler/src/dialect/VPU/utils/weights_separation.cpp
1084
+ +++ b/src/vpux_compiler/src/dialect/VPU/utils/weights_separation.cpp
1085
+ @@ -4,6 +4,7 @@
1086
+ //
1087
+
1088
+ #include "vpux/compiler/dialect/VPU/utils/weights_separation.hpp"
1089
+ +#include "vpux/compiler/core/types/quantile_float/types.hpp"
1090
+ #include "vpux/compiler/core/force_link_macros.hpp"
1091
+ #include "vpux/compiler/dialect/IE/IR/ops/data_movement.hpp"
1092
+ #include "vpux/compiler/dialect/IE/IR/ops/data_type.hpp"
1093
+ @@ -345,6 +346,9 @@ mlir::Value createAvgPoolForInterQuantizedConvert(mlir::OpBuilder& builder, mlir
1094
+ /// Returns an IE operation for the given constant transformation.
1095
+ mlir::Value createMatchingIeOperation(mlir::OpBuilder& builder, mlir::Location loc, mlir::Value input,
1096
+ Const::TransformAttrInterface t) {
1097
+ + if (std::getenv("ONW_DEBUG_WS") != nullptr) {
1098
+ + llvm::errs() << "ONW ws transform: in " << input.getType() << " t " << t << char(10);
1099
+ + }
1100
+ return llvm::TypeSwitch<Const::TransformAttrInterface, mlir::Value>(t)
1101
+ .Case<Const::AddAttr>([&](Const::AddAttr add) {
1102
+ const auto biasValue = checked_cast<float>(add.getBias().getValueAsDouble());
1103
+ @@ -398,15 +402,94 @@ mlir::Value createMatchingIeOperation(mlir::OpBuilder& builder, mlir::Location l
1104
+ const auto inElemType = mlir::cast<NDTypeInterface>(input.getType()).getElementType();
1105
+ const auto outElemType = cast.getElemType();
1106
+
1107
+ - const bool inputQuantized = mlir::isa<mlir::quant::QuantizedType>(inElemType);
1108
+ - const bool outputQuantized = mlir::isa<mlir::quant::QuantizedType>(outElemType);
1109
+ + // onw: quantile (NF4) element types are a storage reinterpretation too, like quantized ones
1110
+ + const bool inputQuantized = mlir::isa<mlir::quant::QuantizedType, vpux::type::QuantileType>(inElemType);
1111
+ + const bool outputQuantized =
1112
+ + mlir::isa<mlir::quant::QuantizedType, vpux::type::QuantileType>(outElemType);
1113
+
1114
+ + const auto hasQuantileStorage = [](mlir::Type t) {
1115
+ + if (auto q = mlir::dyn_cast<mlir::quant::QuantizedType>(t)) {
1116
+ + t = q.getStorageType();
1117
+ + }
1118
+ + return mlir::isa<vpux::type::QuantileType>(t);
1119
+ + };
1120
+ + if (hasQuantileStorage(inElemType) || hasQuantileStorage(outElemType)) {
1121
+ + // onw: CastElemType keeps the stored values (NF4 table indices); go through the storage type:
1122
+ + // quantile -> storage int (reinterpret), int/float conversions of the index values (via f16,
1123
+ + // exact for 0..15), storage int -> quantile (reinterpret)
1124
+ + auto storageOf = [](mlir::Type t) -> mlir::Type {
1125
+ + // quant.uniform<quantile<ui4>> -> quantile<ui4> -> ui4
1126
+ + for (int k = 0; k < 2; ++k) {
1127
+ + if (auto q = mlir::dyn_cast<vpux::type::QuantileType>(t)) {
1128
+ + t = q.getStorageType();
1129
+ + } else if (auto q = mlir::dyn_cast<mlir::quant::QuantizedType>(t)) {
1130
+ + t = q.getStorageType();
1131
+ + }
1132
+ + }
1133
+ + return t;
1134
+ + };
1135
+ + // Index values only pass through value-preserving casts here (integer widening, int <-> f16,
1136
+ + // storage reinterpretation). Walk back to the earliest value of this chain whose type is the
1137
+ + // target or its storage type and reinterpret that directly - the greedy canonicalizer would
1138
+ + // otherwise fold a Convert+QuantizeCast pair into an unsupported "Convert to quantile".
1139
+ + {
1140
+ + const auto targetStorage = storageOf(outElemType);
1141
+ + mlir::Value origin = nullptr;
1142
+ + for (mlir::Value v = input; v != nullptr;) {
1143
+ + const auto vt = mlir::cast<NDTypeInterface>(v.getType()).getElementType();
1144
+ + if (vt == outElemType || vt == targetStorage) {
1145
+ + origin = v;
1146
+ + }
1147
+ + auto* def = v.getDefiningOp();
1148
+ + if (!mlir::isa_and_nonnull<IE::ConvertOp, IE::QuantizeCastOp>(def)) {
1149
+ + break;
1150
+ + }
1151
+ + v = def->getOperand(0);
1152
+ + }
1153
+ + if (origin != nullptr && mlir::cast<NDTypeInterface>(origin.getType()).getShape() ==
1154
+ + mlir::cast<NDTypeInterface>(input.getType()).getShape()) {
1155
+ + const auto ot = mlir::cast<NDTypeInterface>(origin.getType()).getElementType();
1156
+ + return ot == outElemType ? origin
1157
+ + : builder.create<IE::QuantizeCastOp>(loc, origin, outElemType)
1158
+ + .getOutput();
1159
+ + }
1160
+ + }
1161
+ + const auto f16 = mlir::Float16Type::get(builder.getContext());
1162
+ + mlir::Value x = input;
1163
+ + mlir::Type cur = inElemType;
1164
+ + if (storageOf(cur) != cur) {
1165
+ + cur = storageOf(cur);
1166
+ + x = builder.create<IE::QuantizeCastOp>(appendLoc(loc, "onw_q_in"), x, cur);
1167
+ + }
1168
+ + const auto target = storageOf(outElemType);
1169
+ + if (cur != target) {
1170
+ + if (cur != f16 && target != f16 && !mlir::isa<mlir::FloatType>(cur)) {
1171
+ + x = builder.create<IE::ConvertOp>(appendLoc(loc, "onw_q_f16"), x, f16);
1172
+ + }
1173
+ + x = builder.create<IE::ConvertOp>(appendLoc(loc, "onw_q_cvt"), x, target);
1174
+ + }
1175
+ + if (target != outElemType) {
1176
+ + x = builder.create<IE::QuantizeCastOp>(loc, x, outElemType);
1177
+ + }
1178
+ + return x;
1179
+ + }
1180
+ if (inputQuantized || outputQuantized) {
1181
+ return createTypeCorrectedQuantizedCast(builder, input, loc, inElemType, outElemType);
1182
+ }
1183
+ return builder.create<IE::ConvertOp>(loc, input, outElemType);
1184
+ })
1185
+ .Case<Const::ConvertElemTypeAttr>([&](Const::ConvertElemTypeAttr convert) -> mlir::Value {
1186
+ + {
1187
+ + // onw: storage integer <-> quantile (NF4) of the same width is a reinterpretation
1188
+ + const auto inT = mlir::cast<NDTypeInterface>(input.getType()).getElementType();
1189
+ + const auto outT = convert.getElemType();
1190
+ + const auto inQ = mlir::dyn_cast<vpux::type::QuantileType>(inT);
1191
+ + const auto outQ = mlir::dyn_cast<vpux::type::QuantileType>(outT);
1192
+ + if ((outQ != nullptr && outQ.getStorageType() == inT) ||
1193
+ + (inQ != nullptr && inQ.getStorageType() == outT)) {
1194
+ + return builder.create<IE::QuantizeCastOp>(loc, input, outT);
1195
+ + }
1196
+ + }
1197
+ const auto inElemType = mlir::dyn_cast_or_null<mlir::quant::QuantizedType>(
1198
+ mlir::cast<NDTypeInterface>(input.getType()).getElementType());
1199
+ const auto outElemType = mlir::dyn_cast_or_null<mlir::quant::QuantizedType>(convert.getElemType());
1200
+ diff --git a/src/vpux_compiler/src/dialect/VPUIP/transforms/passes/convert_async_ops_to_tasks.cpp b/src/vpux_compiler/src/dialect/VPUIP/transforms/passes/convert_async_ops_to_tasks.cpp
1201
+ index f637bad..6c3db25 100644
1202
+ --- a/src/vpux_compiler/src/dialect/VPUIP/transforms/passes/convert_async_ops_to_tasks.cpp
1203
+ +++ b/src/vpux_compiler/src/dialect/VPUIP/transforms/passes/convert_async_ops_to_tasks.cpp
1204
+ @@ -164,7 +164,40 @@ mlir::LogicalResult RemoveWait::matchAndRewrite(mlir::async::AwaitOp waitOp, OpA
1205
+ // If you faced with "'async.await' has more than one consumer" make sure you add your layer
1206
+ // into src/vpux_compiler/src/dialect/VPUIP/ops.cpp `redirectOpInterfacesForIE(...)`
1207
+ VPUX_THROW_UNLESS(waitOp.getResult().hasOneUse(), "'async.await' doesn't have only one consumer");
1208
+ - VPUX_THROW_UNLESS(mlir::isa<mlir::func::ReturnOp>(*waitOp.getResult().user_begin()),
1209
+ + if (!mlir::isa<mlir::func::ReturnOp>(*waitOp.getResult().user_begin())) {
1210
+ + if (const char* onwDump = std::getenv("ONW_DEBUG_ASYNC")) {
1211
+ + std::error_code ec;
1212
+ + llvm::raw_fd_ostream fo(onwDump, ec);
1213
+ + if (!ec) {
1214
+ + fo << "=== await user: " << **waitOp.getResult().user_begin() << char(10);
1215
+ + fo << "=== await: " << waitOp << char(10);
1216
+ + waitOp->getParentOfType<mlir::func::FuncOp>()->print(fo, mlir::OpPrintingFlags().enableDebugInfo(true, false));
1217
+ + fo << char(10);
1218
+ + }
1219
+ + }
1220
+ + }
1221
+ + // onw: a pure view (ShapeCast, PermuteCast, ...: no data movement) between the await and the return is fine as
1222
+ + // well - the 8.3 pipeline leaves such views of a function's result outside the async regions (Qwen3.5 4-token
1223
+ + // blocks, Bonsai 4/8-token blocks: "'async.await' has non 'return' consumer")
1224
+ + const auto onlyViewsToReturn = [](mlir::Value v) {
1225
+ + while (true) {
1226
+ + if (!v.hasOneUse()) {
1227
+ + return false;
1228
+ + }
1229
+ + auto* user = *v.user_begin();
1230
+ + if (mlir::isa<mlir::func::ReturnOp>(user)) {
1231
+ + return true;
1232
+ + }
1233
+ + if (!mlir::isa<VPUIP::ShapeCastOp, VPUIP::PermuteCastOp, VPUIP::GenericReshapeOp, VPUIP::QuantizeCastOp,
1234
+ + VPUIP::ViewOp>(user) ||
1235
+ + user->getNumResults() != 1) {
1236
+ + return false;
1237
+ + }
1238
+ + v = user->getResult(0);
1239
+ + }
1240
+ + };
1241
+ + VPUX_THROW_UNLESS(mlir::isa<mlir::func::ReturnOp>(*waitOp.getResult().user_begin()) ||
1242
+ + onlyViewsToReturn(waitOp.getResult()),
1243
+ "'async.await' has non 'return' consumer");
1244
+
1245
+ rewriter.replaceOp(waitOp, newArgs.getOperand());
1246
+ diff --git a/src/vpux_compiler/src/dialect/VPUIP/transforms/passes/unroll_distributed_ops.cpp b/src/vpux_compiler/src/dialect/VPUIP/transforms/passes/unroll_distributed_ops.cpp
1247
+ index 23fe4fc..b93a938 100644
1248
+ --- a/src/vpux_compiler/src/dialect/VPUIP/transforms/passes/unroll_distributed_ops.cpp
1249
+ +++ b/src/vpux_compiler/src/dialect/VPUIP/transforms/passes/unroll_distributed_ops.cpp
1250
+ @@ -1732,6 +1732,13 @@ void VPUIP::unrollDistributedOpsCommon40XXPlus(mlir::func::FuncOp func,
1251
+ const VPUIP::ClusterNCERewriter nceRewriter(ctx, log);
1252
+ const VPUIP::ClusterConvertDMARewriter convertDMARewriter(ctx, dmaPortCount, log);
1253
+
1254
+ + if (const char* onwDump = std::getenv("ONW_DUMP_UNROLL")) {
1255
+ + std::error_code ec;
1256
+ + llvm::raw_fd_ostream fo((std::string(onwDump) + "_" + func.getName().str() + ".mlir"), ec);
1257
+ + if (!ec) {
1258
+ + func->print(fo, mlir::OpPrintingFlags().enableDebugInfo(true, false));
1259
+ + }
1260
+ + }
1261
+ uint64_t unrollIdx = 0;
1262
+ func.walk<mlir::WalkOrder::PostOrder>([&](VPURT::TaskOp vpurtTask) {
1263
+ auto op = vpurtTask.getInnerTaskOp();
1264
+ @@ -1740,6 +1747,7 @@ void VPUIP::unrollDistributedOpsCommon40XXPlus(mlir::func::FuncOp func,
1265
+ }
1266
+
1267
+ mlir::OpBuilder builder(op);
1268
+ + try {
1269
+ if (auto nndmaOp = mlir::dyn_cast<VPUIP::NNDMAOp>(op)) {
1270
+ dmaRewriter.matchAndRewrite(nndmaOp, builder, unrollIdx, /*isDataOverlapped*/ true);
1271
+ unrollIdx++;
1272
+ @@ -1748,6 +1756,22 @@ void VPUIP::unrollDistributedOpsCommon40XXPlus(mlir::func::FuncOp func,
1273
+ } else if (auto dmaOp = mlir::dyn_cast<VPUIP::DMATypeOpInterface>(op)) {
1274
+ convertDMARewriter.matchAndRewrite(dmaOp, builder);
1275
+ }
1276
+ + } catch (...) {
1277
+ + if (std::getenv("ONW_DEBUG_UNROLL") != nullptr) {
1278
+ + llvm::errs() << "ONW unroll failed on: " << *op << char(10);
1279
+ + {
1280
+ + std::error_code ec;
1281
+ + llvm::raw_fd_ostream fo(std::getenv("ONW_DEBUG_UNROLL"), ec);
1282
+ + if (!ec) {
1283
+ + func->print(fo);
1284
+ + }
1285
+ + }
1286
+ + for (auto operand : op->getOperands()) {
1287
+ + llvm::errs() << " operand type: " << operand.getType() << char(10); if (auto d = operand.getDefiningOp()) { llvm::errs() << " def: " << *d << char(10); for (auto u : d->getOperands()) if (auto dd = u.getDefiningOp()) llvm::errs() << " def-def: " << *dd << char(10); }
1288
+ + }
1289
+ + }
1290
+ + throw;
1291
+ + }
1292
+ });
1293
+ }
1294
+
1295
+ diff --git a/src/vpux_compiler/src/dialect/const/attributes/content.cpp b/src/vpux_compiler/src/dialect/const/attributes/content.cpp
1296
+ index 3f4f862..f5d66fe 100644
1297
+ --- a/src/vpux_compiler/src/dialect/const/attributes/content.cpp
1298
+ +++ b/src/vpux_compiler/src/dialect/const/attributes/content.cpp
1299
+ @@ -7,6 +7,14 @@
1300
+ #include "vpux/compiler/dialect/const/attributes/stable_hash_storage.hpp"
1301
+ #include "vpux/compiler/dialect/const/constant_call_stack.hpp"
1302
+ #include "vpux/compiler/dialect/const/constant_transformations_control.hpp"
1303
+ +
1304
+ +#include <atomic>
1305
+ +#include <cstdio>
1306
+ +#include <cstdlib>
1307
+ +#include <list>
1308
+ +#include <memory>
1309
+ +#include <mutex>
1310
+ +#include <vector>
1311
+ #include "vpux/compiler/dialect/const/dialect.hpp"
1312
+ #include "vpux/compiler/dialect/const/utils/const_logger.hpp"
1313
+ #include "vpux/compiler/utils/stable_hash.hpp"
1314
+ @@ -468,11 +476,117 @@ mlir::DenseElementsAttr Const::detail::createConstContentWithConversion(mlir::Sh
1315
+ // ContentAttr::fold
1316
+ //
1317
+
1318
+ +namespace {
1319
+ +// [onw] Folding memo for "... Concat ... SubView ..." chains: a large weight is concatenated from many pieces (the
1320
+ +// unrolled groups of a group-quantized MatMul, Q/K/V merged), reshaped, then cut into DPU tiles with a SubView each.
1321
+ +// When the SubView cannot be moved in front of the reshape (it slices the inner factor of a flattened dim), every tile
1322
+ +// folds the whole concatenation again: O(tiles x weight) - most of the ELF export time of one-row (decode) graphs
1323
+ +// (Granite 4.2 8B part on NPU 5010: ~2000 tiles x 0.16 s). The result of the prefix before the SubView is the same for
1324
+ +// all tiles of a weight (attributes are uniqued: pointers identify it), so it is kept and copied for each tile.
1325
+ +struct FoldMemo {
1326
+ + std::mutex mutex;
1327
+ + struct Entry {
1328
+ + std::vector<const void*> key;
1329
+ + std::shared_ptr<std::vector<char>> data;
1330
+ + vpux::NDTypeInterface type;
1331
+ + mlir::Type storageElemType;
1332
+ + bool isSplat;
1333
+ + };
1334
+ + std::list<Entry> entries; // most recent first
1335
+ + size_t bytes = 0;
1336
+ + static constexpr size_t LIMIT = size_t(512) << 20;
1337
+ +};
1338
+ +
1339
+ +FoldMemo& foldMemo() {
1340
+ + static FoldMemo memo;
1341
+ + return memo;
1342
+ +}
1343
+ +} // namespace
1344
+ +
1345
+ Const::Content vpux::Const::ContentAttr::fold() const {
1346
+ auto baseContent = getBaseContent();
1347
+ - auto res = wrapBaseContent(baseContent);
1348
+ - for (const auto& attr : getTransformations()) {
1349
+ - res = attr.transform(res);
1350
+ + const auto transformations = getTransformations();
1351
+ + // the last SubView that follows a Concat: the prefix before it is memoized
1352
+ + int64_t cut = -1;
1353
+ + bool sawConcat = false;
1354
+ + for (size_t i = 0; i < transformations.size(); ++i) {
1355
+ + if (mlir::isa<Const::ConcatAttr>(transformations[i])) {
1356
+ + sawConcat = true;
1357
+ + } else if (sawConcat && mlir::isa<Const::SubViewAttr>(transformations[i])) {
1358
+ + cut = static_cast<int64_t>(i);
1359
+ + }
1360
+ + }
1361
+ + static std::atomic<int64_t> nHit{0}, nMiss{0}, nPlain{0};
1362
+ + if (cut < 0 || std::getenv("ONW_NO_FOLD_MEMO") != nullptr) {
1363
+ + ++nPlain;
1364
+ + auto res = wrapBaseContent(baseContent);
1365
+ + for (const auto& attr : transformations) {
1366
+ + res = attr.transform(res);
1367
+ + }
1368
+ + return res;
1369
+ + }
1370
+ + std::vector<const void*> key;
1371
+ + key.reserve(cut + 1);
1372
+ + key.push_back(baseContent.getAsOpaquePointer());
1373
+ + for (int64_t i = 0; i < cut; ++i) {
1374
+ + key.push_back(transformations[i].getAsOpaquePointer());
1375
+ + }
1376
+ + Const::Content res;
1377
+ + bool hit = false;
1378
+ + std::shared_ptr<std::vector<char>> held; // (alive while the SubView reads it, whatever the memo evicts)
1379
+ + vpux::NDTypeInterface heldType;
1380
+ + mlir::Type heldStorage;
1381
+ + bool heldSplat = false;
1382
+ + {
1383
+ + auto& memo = foldMemo();
1384
+ + std::lock_guard<std::mutex> lock(memo.mutex);
1385
+ + for (auto it = memo.entries.begin(); it != memo.entries.end(); ++it) {
1386
+ + if (it->key == key) {
1387
+ + held = it->data;
1388
+ + heldType = it->type;
1389
+ + heldStorage = it->storageElemType;
1390
+ + heldSplat = it->isSplat;
1391
+ + memo.entries.splice(memo.entries.begin(), memo.entries, it);
1392
+ + hit = true;
1393
+ + ++nHit;
1394
+ + break;
1395
+ + }
1396
+ + }
1397
+ + }
1398
+ + if (hit) {
1399
+ + // no copy: the SubView only reads its input and writes a buffer of its own (SubViewAttr::transform)
1400
+ + auto view = Const::Content::fromRawBuffer(heldType, ArrayRef<char>(held->data(), held->size()), heldStorage,
1401
+ + heldSplat);
1402
+ + res = transformations[cut].transform(view);
1403
+ + for (size_t i = static_cast<size_t>(cut) + 1; i < transformations.size(); ++i) {
1404
+ + res = transformations[i].transform(res);
1405
+ + }
1406
+ + return res;
1407
+ + }
1408
+ + if (!hit) {
1409
+ + ++nMiss;
1410
+ + res = wrapBaseContent(baseContent);
1411
+ + for (int64_t i = 0; i < cut; ++i) {
1412
+ + res = transformations[i].transform(res);
1413
+ + }
1414
+ + FoldMemo::Entry e;
1415
+ + e.key = key;
1416
+ + e.type = res.getType();
1417
+ + e.storageElemType = res.getStorageElemType();
1418
+ + e.isSplat = res.isSplat();
1419
+ + const auto raw = res.getRawStorageBuf();
1420
+ + e.data = std::make_shared<std::vector<char>>(raw.begin(), raw.end());
1421
+ + auto& memo = foldMemo();
1422
+ + std::lock_guard<std::mutex> lock(memo.mutex);
1423
+ + memo.bytes += e.data->size();
1424
+ + memo.entries.push_front(std::move(e));
1425
+ + while (memo.bytes > FoldMemo::LIMIT && memo.entries.size() > 1) {
1426
+ + memo.bytes -= memo.entries.back().data->size();
1427
+ + memo.entries.pop_back();
1428
+ + }
1429
+ + }
1430
+ + for (size_t i = static_cast<size_t>(cut); i < transformations.size(); ++i) {
1431
+ + res = transformations[i].transform(res);
1432
+ }
1433
+ return res;
1434
+ }
1435
+ diff --git a/src/vpux_compiler/src/utils/dma_transaction_utils.cpp b/src/vpux_compiler/src/utils/dma_transaction_utils.cpp
1436
+ index 10cb1f4..45e02e8 100644
1437
+ --- a/src/vpux_compiler/src/utils/dma_transaction_utils.cpp
1438
+ +++ b/src/vpux_compiler/src/utils/dma_transaction_utils.cpp
1439
+ @@ -181,6 +181,16 @@ DMAPattern reduceDimsForDma(SmallVector<int64_t> memShape, SmallVector<vpux::Mem
1440
+ // Innermost dim is special
1441
+ reducedDims.front() = Bit(reducedDims.front() * elemSize).to<Byte>().count();
1442
+
1443
+ + if (std::getenv("ONW_DEBUG_UNROLL") != nullptr &&
1444
+ + std::any_of(std::begin(reducedBitStrides), std::prev(std::end(reducedBitStrides)), [](auto bitStride) {
1445
+ + return bitStride.count() % CHAR_BIT != 0;
1446
+ + })) {
1447
+ + llvm::errs() << "ONW dma reduce: elem " << elemSize << " shape";
1448
+ + for (auto v : memShape) llvm::errs() << " " << v;
1449
+ + llvm::errs() << " strides";
1450
+ + for (auto v : memStrides) llvm::errs() << " " << v.count();
1451
+ + llvm::errs() << char(10);
1452
+ + }
1453
+ VPUX_THROW_WHEN(std::any_of(std::begin(reducedBitStrides), std::prev(std::end(reducedBitStrides)),
1454
+ [](auto bitStride) {
1455
+ return bitStride.count() % CHAR_BIT != 0;
onw/npukit/patches/openvino-6db412d-npu_plugin.patch ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ diff --git a/src/plugins/intel_npu/src/compiler_adapter/include/weightless_graph.hpp b/src/plugins/intel_npu/src/compiler_adapter/include/weightless_graph.hpp
2
+ index c5f05a69..e976d53b 100644
3
+ --- a/src/plugins/intel_npu/src/compiler_adapter/include/weightless_graph.hpp
4
+ +++ b/src/plugins/intel_npu/src/compiler_adapter/include/weightless_graph.hpp
5
+ @@ -133,6 +133,9 @@ private:
6
+ * per init compiled model and not per init schedule output for performance reasons.
7
+ */
8
+ mutable std::vector<std::shared_ptr<ZeroTensor>> _mainInputsAllocatedTensors;
9
+ + // onw: init outputs found identical to an earlier graph's - released only after the init schedules' graphs and
10
+ + // command lists are destroyed (freed earlier, the driver later released their reused address range)
11
+ + mutable std::vector<std::shared_ptr<ZeroTensor>> _onwDeferredRelease;
12
+ /**
13
+ * @brief Tensors pointing towards the buffers found in "_mainInputsAllocatedTensors".
14
+ * @details Each map entry corresponds to one input of the main schedule.
15
+ diff --git a/src/plugins/intel_npu/src/compiler_adapter/src/weightless_graph.cpp b/src/plugins/intel_npu/src/compiler_adapter/src/weightless_graph.cpp
16
+ index b56d95b5..24790c3c 100644
17
+ --- a/src/plugins/intel_npu/src/compiler_adapter/src/weightless_graph.cpp
18
+ +++ b/src/plugins/intel_npu/src/compiler_adapter/src/weightless_graph.cpp
19
+ @@ -5,6 +5,11 @@
20
+ #include "weightless_graph.hpp"
21
+
22
+ #include <cinttypes>
23
+ +#include <unordered_map>
24
+ +#include <optional>
25
+ +#include <cstring>
26
+ +#include <cstdlib>
27
+ +#include <cstdio>
28
+ #include <condition_variable>
29
+ #include <iterator>
30
+ #include <mutex>
31
+ @@ -30,6 +35,102 @@ namespace intel_npu {
32
+
33
+ namespace {
34
+
35
+ +// onw: share identical initialized weights between weightless graphs. Every weightless graph runs its init schedules
36
+ +// into its own device buffer - a fused graph of an 8B model holds ~4.8 GB, so each extra graph variant (more positions,
37
+ +// another block size) of the same weights costs that much again. After an init schedule ran, its output buffers are
38
+ +// looked up by size + a sampled hash and confirmed byte by byte; when every output of the schedule already exists in
39
+ +// a live buffer of an earlier graph, the main schedule is bound to those and the new buffer is released.
40
+ +// ONW_WS_SHARE=0 turns it off; ONW_WS_SHARE_LOG=1 prints what was shared.
41
+ +struct OnwWsEntry {
42
+ + std::weak_ptr<ZeroTensor> owner;
43
+ + const unsigned char* data;
44
+ + size_t size;
45
+ +};
46
+ +std::mutex onwWsMutex;
47
+ +std::unordered_multimap<uint64_t, OnwWsEntry> onwWsRegistry;
48
+ +
49
+ +uint64_t onwSampledHash(const unsigned char* p, size_t n) {
50
+ + uint64_t h = 1469598103934665603ULL ^ n;
51
+ + auto mix = [&](const unsigned char* q, size_t len) {
52
+ + for (size_t i = 0; i < len; ++i) {
53
+ + h = (h ^ q[i]) * 1099511628211ULL;
54
+ + }
55
+ + };
56
+ + for (size_t off = 0; off < n; off += 4096) {
57
+ + mix(p + off, std::min<size_t>(64, n - off));
58
+ + }
59
+ + if (n > 64) {
60
+ + mix(p + n - 64, 64);
61
+ + }
62
+ + return h;
63
+ +}
64
+ +
65
+ +bool onwWsShareEnabled() {
66
+ + const char* e = std::getenv("ONW_WS_SHARE");
67
+ + return e == nullptr || std::string(e) != "0";
68
+ +}
69
+ +
70
+ +// returns the owners to keep alive when every output was found (and rewrites the map to point at them), else registers
71
+ +// this buffer's outputs and returns nothing
72
+ +std::optional<std::vector<std::shared_ptr<ZeroTensor>>> onwWsShare(
73
+ + const std::shared_ptr<ZeroTensor>& hostTensor,
74
+ + std::unordered_map<std::string, std::shared_ptr<ov::ITensor>>& tensorsMap) {
75
+ + if (!onwWsShareEnabled() || hostTensor == nullptr) {
76
+ + return std::nullopt;
77
+ + }
78
+ + std::lock_guard<std::mutex> lock(onwWsMutex);
79
+ + std::vector<std::pair<std::string, std::pair<std::shared_ptr<ZeroTensor>, const unsigned char*>>> found;
80
+ + size_t bytes = 0;
81
+ + for (const auto& [name, t] : tensorsMap) {
82
+ + const auto* p = static_cast<const unsigned char*>(t->data());
83
+ + const size_t n = t->get_byte_size();
84
+ + const uint64_t h = onwSampledHash(p, n);
85
+ + bool hit = false;
86
+ + auto range = onwWsRegistry.equal_range(h);
87
+ + for (auto it = range.first; it != range.second; ++it) {
88
+ + auto owner = it->second.owner.lock();
89
+ + if (owner == nullptr || owner == hostTensor || it->second.size != n) {
90
+ + continue;
91
+ + }
92
+ + if (std::memcmp(it->second.data, p, n) == 0) {
93
+ + found.push_back({name, {owner, it->second.data}});
94
+ + bytes += n;
95
+ + hit = true;
96
+ + break;
97
+ + }
98
+ + }
99
+ + if (!hit) {
100
+ + break;
101
+ + }
102
+ + }
103
+ + const bool log = std::getenv("ONW_WS_SHARE_LOG") != nullptr;
104
+ + if (found.size() != tensorsMap.size()) {
105
+ + for (const auto& [name, t] : tensorsMap) {
106
+ + const auto* p = static_cast<const unsigned char*>(t->data());
107
+ + const size_t n = t->get_byte_size();
108
+ + onwWsRegistry.emplace(onwSampledHash(p, n), OnwWsEntry{hostTensor, p, n});
109
+ + }
110
+ + if (log) {
111
+ + std::fprintf(stderr, "[onw ws-share] new weights: %zu outputs, %.1f MB%c", tensorsMap.size(),
112
+ + hostTensor->get_byte_size() / 1e6, 10);
113
+ + }
114
+ + return std::nullopt;
115
+ + }
116
+ + std::vector<std::shared_ptr<ZeroTensor>> owners;
117
+ + for (auto& [name, src] : found) {
118
+ + const auto& old = tensorsMap.at(name);
119
+ + tensorsMap[name] = ov::make_tensor(old->get_element_type(), old->get_shape(),
120
+ + const_cast<unsigned char*>(src.second));
121
+ + owners.push_back(src.first);
122
+ + }
123
+ + if (log) {
124
+ + std::fprintf(stderr, "[onw ws-share] shared: %zu outputs, %.1f MB released%c", found.size(),
125
+ + hostTensor->get_byte_size() / 1e6, 10);
126
+ + }
127
+ + return owners;
128
+ +}
129
+ +
130
+ +
131
+ constexpr uint8_t MAIN_SCHEDULE_INDEX = 0;
132
+ constexpr std::string_view WEIGHTS_IR_EXTENSION = ".bin";
133
+ constexpr std::string_view WEIGHTS_ONNX_EXTENSION = ".data";
134
+ @@ -423,6 +524,7 @@ void WeightlessGraph::initialize_impl(const FilteredConfig& config) {
135
+ _initsFences.clear();
136
+ _initsMetadata.clear();
137
+ _initsCommandQueue.reset();
138
+ + _onwDeferredRelease.clear();
139
+
140
+ // The main schedule is initialized after the weights initialization ones in order to save some memory
141
+ Graph::initialize_impl(config);
142
+ @@ -502,7 +604,13 @@ WeightlessGraph::InputData WeightlessGraph::allocate_inputs(
143
+ // model appears in exactly one schedule. Thus, one can delete
144
+ // the handle to the constant memory early.
145
+ if (auto it = constants.find(id); it != constants.end()) {
146
+ - ov::wsh::Extension::hint_evict(*it->second);
147
+ + // onw: several graphs (paged parts, fused graphs, every block size) are built from the same mapped weight
148
+ + // files and imported one after another; evicting a constant's source here left later imports reading
149
+ + // released memory (access violation in this function). The mapping is file-backed, so the OS reclaims it
150
+ + // anyway. ONW_WS_EVICT=1 restores the eviction.
151
+ + if (std::getenv("ONW_WS_EVICT") != nullptr) {
152
+ + ov::wsh::Extension::hint_evict(*it->second);
153
+ + }
154
+ constants.erase(it);
155
+ }
156
+ }
157
+ @@ -557,6 +665,14 @@ void WeightlessGraph::run_init_single_threaded() {
158
+ create_pipeline(initIndex, initInputsViewTensors, initOutputsViewTensors);
159
+ run_pipeline(initIndex);
160
+
161
+ + if (auto owners = onwWsShare(initOutputsAllocatedTensor, initOutputsViewTensorsMap)) {
162
+ + merge_two_maps(_mainInputsViewTensors, initOutputsViewTensorsMap);
163
+ + for (auto& o : *owners) {
164
+ + _mainInputsAllocatedTensors.push_back(o);
165
+ + }
166
+ + _onwDeferredRelease.push_back(std::move(initOutputsAllocatedTensor));
167
+ + continue;
168
+ + }
169
+ merge_two_maps(_mainInputsViewTensors, initOutputsViewTensorsMap);
170
+ _mainInputsAllocatedTensors.push_back(std::move(initOutputsAllocatedTensor));
171
+ }
172
+ @@ -600,6 +716,14 @@ void WeightlessGraph::run_init_multi_threaded() {
173
+ run_pipeline(data.initIndex);
174
+
175
+ // TODO: pre-allocate those well in advance? (outside of this loop)
176
+ + if (auto owners = onwWsShare(data.outputs.hostTensor, data.outputs.tensorsMap)) {
177
+ + merge_two_maps(_mainInputsViewTensors, data.outputs.tensorsMap);
178
+ + for (auto& o : *owners) {
179
+ + _mainInputsAllocatedTensors.push_back(o);
180
+ + }
181
+ + _onwDeferredRelease.push_back(std::move(data.outputs.hostTensor));
182
+ + return;
183
+ + }
184
+ merge_two_maps(_mainInputsViewTensors, data.outputs.tensorsMap);
185
+ _mainInputsAllocatedTensors.push_back(data.outputs.hostTensor);
186
+ },
onw/npukit/patches/vpucostmodel.patch ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ diff --git a/include/core/cache.h b/include/core/cache.h
2
+ index 0340f76..20dca16 100644
3
+ --- a/include/core/cache.h
4
+ +++ b/include/core/cache.h
5
+ @@ -215,6 +215,12 @@ public:
6
+ if (map_it == m_table.cend()) {
7
+ return std::nullopt;
8
+ }
9
+ + // onw: below the size limit nothing is ever evicted, so the recency order does not matter - return
10
+ + // under the shared lock instead of taking the exclusive one (every hit serialised all compile threads)
11
+ + if (m_table.size() < max_size) {
12
+ + if (source) *source = "dyn_cache";
13
+ + return (map_it->second->second);
14
+ + }
15
+ }
16
+
17
+ // If found, acquire a unique lock and do the mutation
onw/npukit/plugins.xml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ <ie>
2
+ <plugins>
3
+ <plugin name="NPU" location="openvino_intel_npu_plugin.dll">
4
+ </plugin>
5
+ </plugins>
6
+ </ie>
onw/precompile.py CHANGED
@@ -1,19 +1,20 @@
1
- """A first load's precompile child (runtime.compile_jobs): build the segments named in ONW_PRECOMPILE_ONLY exactly as
2
- the parent would, so they land in the compile cache (marked as the parent's run, ONW_NPUW_SESSION), then exit.
3
- usage (internal): python -m onw.precompile '[MODEL_DIR, DEVICE, CONFIG, SIZES, CONTEXT]'"""
4
- import json, sys
5
- import openvino as ov
6
-
7
-
8
- def main():
9
- d, device, config, sizes, context = json.loads(sys.argv[1])
10
- from .runtime import Precompiled, SegmentedModel
11
- try:
12
- SegmentedModel(ov.Core(), d, device, config, sizes=sizes, context=context)
13
- except Precompiled:
14
- return
15
- sys.exit(3) # (not reached: the model raises once compiled)
16
-
17
-
18
- if __name__ == "__main__":
19
- main()
 
 
1
+ """A first load's precompile child (runtime.compile_jobs): build the segments named in ONW_PRECOMPILE_ONLY exactly as
2
+ the parent would, so they land in the compile cache (marked as the parent's run, ONW_NPUW_SESSION), then exit.
3
+ usage (internal): python -m onw.precompile '[MODEL_DIR, DEVICE, CONFIG, SIZES, CONTEXT]'"""
4
+ import json, sys
5
+ import openvino as ov
6
+ from . import npukit
7
+
8
+
9
+ def main():
10
+ d, device, config, sizes, context = json.loads(sys.argv[1])
11
+ from .runtime import Precompiled, SegmentedModel
12
+ try:
13
+ SegmentedModel(npukit.core(), d, device, config, sizes=sizes, context=context)
14
+ except Precompiled:
15
+ return
16
+ sys.exit(3) # (not reached: the model raises once compiled)
17
+
18
+
19
+ if __name__ == "__main__":
20
+ main()
onw/probe.py CHANGED
@@ -1,59 +1,60 @@
1
- """Compile one segment in a separate process, so a crash inside the NPU compiler (a driver bug: the process dies
2
- with no Python exception) does not take the server down. With EXPORT_BLOB the compiled blob is written there for the
3
- parent to import (onw's own blob store, see runtime.import_blob); otherwise the result lands in the OpenVINO cache dir
4
- given in the config and the parent's compile of the same segment is a cache hit.
5
- usage (internal): python -m onw.probe MODEL_DIR SEG_XML CONFIG_JSON NPUW(0/1) T [EXPORT_BLOB]
6
- ONW_RING (env): the sliding-window ring length the parent reshaped the caches to (paged mode).
7
- exit 0: compiled; 1: the compiler rejected it (reason on stdout); 2: something else went wrong (disk full, a file
8
- missing - not the compiler's verdict, so the parent does not remember it); anything else: crashed"""
9
- import io, json, os, sys
10
- import openvino as ov
11
-
12
-
13
- def main():
14
- d, name, cfg, npuw, T = sys.argv[1], sys.argv[2], json.loads(sys.argv[3]), sys.argv[4] == "1", int(sys.argv[5])
15
- from .runtime import read_segment
16
- core = ov.Core()
17
- from .runtime import enable_compiler
18
- enable_compiler(core, "NPU", {}, quiet=True) # [2.0.3] the same NPU compiler as the parent (see npu_compiler)
19
- meta = json.load(open(f"{d}/engine.json"))
20
- bins = {} # keeps the .bin buffer alive while compiling
21
- try:
22
- segs = {**meta["segments"], **(meta.get("mtp") or {}).get("segments", {})} # (the MTP graphs too)
23
- model = read_segment(core, d, name, segs[name], npuw, bins, T, meta["T"])
24
- if os.environ.get("ONW_KV", "paged") != "legacy": # the same graph the parent compiles (host-side rope)
25
- from . import paged
26
- paged.host_rope(model)
27
- if os.environ.get("ONW_RING"): # paged mode: sliding-window caches live in a ring
28
- from .runtime import ring_shapes
29
- model.reshape(ring_shapes(model, int(os.environ["ONW_RING"]), segs[name]["S"]))
30
- except Exception as e:
31
- print(f"{type(e).__name__}: {e}", flush=True)
32
- sys.exit(2)
33
- if os.environ.get("ONW_TEST_CRASH_KEY") in cfg: # test hook: behave like a compiler crash on this route
34
- os.abort()
35
- try:
36
- compiled = core.compile_model(model, "NPU", cfg)
37
- except MemoryError as e: # (out of memory: not the compiler's verdict)
38
- print(f"MemoryError: {e}", flush=True)
39
- sys.exit(2)
40
- except RuntimeError as e:
41
- why = str(e)
42
- print(why[why.find("While validating"):] if "While validating" in why else why[-300:], flush=True)
43
- sys.exit(1)
44
- if len(sys.argv) > 6: # write the blob for the parent to import
45
- out = sys.argv[6]
46
- try:
47
- os.makedirs(os.path.dirname(out), exist_ok=True)
48
- buf = io.BytesIO()
49
- compiled.export_model(buf)
50
- with open(out + ".tmp", "wb") as f:
51
- f.write(buf.getbuffer())
52
- os.replace(out + ".tmp", out)
53
- except Exception as e:
54
- print(f"{type(e).__name__}: {e}", flush=True)
55
- sys.exit(2)
56
-
57
-
58
- if __name__ == "__main__":
59
- main()
 
 
1
+ """Compile one segment in a separate process, so a crash inside the NPU compiler (a driver bug: the process dies
2
+ with no Python exception) does not take the server down. With EXPORT_BLOB the compiled blob is written there for the
3
+ parent to import (onw's own blob store, see runtime.import_blob); otherwise the result lands in the OpenVINO cache dir
4
+ given in the config and the parent's compile of the same segment is a cache hit.
5
+ usage (internal): python -m onw.probe MODEL_DIR SEG_XML CONFIG_JSON NPUW(0/1) T [EXPORT_BLOB]
6
+ ONW_RING (env): the sliding-window ring length the parent reshaped the caches to (paged mode).
7
+ exit 0: compiled; 1: the compiler rejected it (reason on stdout); 2: something else went wrong (disk full, a file
8
+ missing - not the compiler's verdict, so the parent does not remember it); anything else: crashed"""
9
+ import io, json, os, sys
10
+ import openvino as ov
11
+ from . import npukit
12
+
13
+
14
+ def main():
15
+ d, name, cfg, npuw, T = sys.argv[1], sys.argv[2], json.loads(sys.argv[3]), sys.argv[4] == "1", int(sys.argv[5])
16
+ from .runtime import read_segment
17
+ core = npukit.core()
18
+ from .runtime import enable_compiler
19
+ enable_compiler(core, "NPU", {}, quiet=True) # [2.0.3] the same NPU compiler as the parent (see npu_compiler)
20
+ meta = json.load(open(f"{d}/engine.json"))
21
+ bins = {} # keeps the .bin buffer alive while compiling
22
+ try:
23
+ segs = {**meta["segments"], **(meta.get("mtp") or {}).get("segments", {})} # (the MTP graphs too)
24
+ model = read_segment(core, d, name, segs[name], npuw, bins, T, meta["T"])
25
+ if os.environ.get("ONW_KV", "paged") != "legacy": # the same graph the parent compiles (host-side rope)
26
+ from . import paged
27
+ paged.host_rope(model)
28
+ if os.environ.get("ONW_RING"): # paged mode: sliding-window caches live in a ring
29
+ from .runtime import ring_shapes
30
+ model.reshape(ring_shapes(model, int(os.environ["ONW_RING"]), segs[name]["S"]))
31
+ except Exception as e:
32
+ print(f"{type(e).__name__}: {e}", flush=True)
33
+ sys.exit(2)
34
+ if os.environ.get("ONW_TEST_CRASH_KEY") in cfg: # test hook: behave like a compiler crash on this route
35
+ os.abort()
36
+ try:
37
+ compiled = core.compile_model(model, "NPU", cfg)
38
+ except MemoryError as e: # (out of memory: not the compiler's verdict)
39
+ print(f"MemoryError: {e}", flush=True)
40
+ sys.exit(2)
41
+ except RuntimeError as e:
42
+ why = str(e)
43
+ print(why[why.find("While validating"):] if "While validating" in why else why[-300:], flush=True)
44
+ sys.exit(1)
45
+ if len(sys.argv) > 6: # write the blob for the parent to import
46
+ out = sys.argv[6]
47
+ try:
48
+ os.makedirs(os.path.dirname(out), exist_ok=True)
49
+ buf = io.BytesIO()
50
+ compiled.export_model(buf)
51
+ with open(out + ".tmp", "wb") as f:
52
+ f.write(buf.getbuffer())
53
+ os.replace(out + ".tmp", out)
54
+ except Exception as e:
55
+ print(f"{type(e).__name__}: {e}", flush=True)
56
+ sys.exit(2)
57
+
58
+
59
+ if __name__ == "__main__":
60
+ main()
onw/runtime.py CHANGED
@@ -692,6 +692,46 @@ def import_blob(core, path, device, cfg):
692
  return core.import_model(io.BytesIO(f.read()), device, cfg)
693
 
694
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
695
  def _signature(model):
696
  """A cheap fingerprint of a graph's structure (op types, shapes, input / output names): a part's compiled blob is
697
  only reused for exactly the same part, whatever changes in how segments are split."""
@@ -1041,6 +1081,18 @@ class SegmentedModel:
1041
  # MoE models already hold their experts once (weights as inputs), so NPUW is off for them unless ONW_NPUW_MOE=1:
1042
  # it stopped mid-answer on LFM2 (NPU 4000), but that was with a Linux driver < 1.38 (see the conv fix there)
1043
  npuw = device == "NPU" and use_npuw(core) and (not self.bank or os.environ.get("ONW_NPUW_MOE") == "1")
 
 
 
 
 
 
 
 
 
 
 
 
1044
  if npuw and self.meta.get("needs") == "u2" and os.environ.get("ONW_NPUW") != "1":
1045
  # 2-bit weights stay 2-bit compiled plain only: NPUW expands them (Lunar Lake, Ternary Bonsai 2 27B: 12.6 GB
1046
  # of NPU memory instead of ~7, the 16 GB machine out of memory, 0.04 tok/s against 4 tok/s plain)
@@ -1052,6 +1104,8 @@ class SegmentedModel:
1052
  print(f"[npuw] off: ~{parts} compiled parts (each attention layer cuts a segment) - more than the NPU driver "
1053
  f"holds with NPUW (~30); ONW_NPUW=1 forces it", flush=True)
1054
  npuw = False
 
 
1055
  if npuw:
1056
  # experimental: NPUW pulls the weights out of every compiled graph into one weights bank; constants match
1057
  # by data pointer, so every block size reads its IR against the same in-memory copy of the .bin
@@ -1340,6 +1394,12 @@ class SegmentedModel:
1340
  # ---- [2.0.4] fused graphs (onw/fused.py): one NPU call per segment, for answers and for prompts ----
1341
  FUSED_B = (1, 2, 3) # the variants: the first 1 / 2 / 3 paged blocks (1K / 2K / 4K)
1342
 
 
 
 
 
 
 
1343
  def fused_prepare(self, need, S=None):
1344
  """Make the fused S-token graphs (default: the answer size) covering `need` positions the ones in use for that
1345
  size (loaded from onw's blob store, or compiled the first time); the other variants are compiled in the
@@ -1352,9 +1412,38 @@ class SegmentedModel:
1352
  if S not in sizes:
1353
  return None
1354
  from . import fused as _f
1355
- caps = [(B, sum(_f.blocks_for(self.kv.capacity, B))) for B in self.FUSED_B]
 
 
 
 
 
1356
  want = next((B for B, c in caps if c >= min(need, self.T)), None)
1357
  F = self._fused.get(S)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1358
  if want is None: # longer than the largest variant: the paged graphs
1359
  if F is not None:
1360
  F = None # [2.1.0] (no reference left: freed before the paged graphs load)
@@ -1387,6 +1476,10 @@ class SegmentedModel:
1387
  self.swap_to(S)
1388
  return None
1389
  self.S = max([*self.segs, *self._fused]) # (prompts are still fed in blocks of the largest size)
 
 
 
 
1390
  npuw_ = (self._ctx["config"] or {}).get("NPU_USE_NPUW") == "YES" # (NPUW: compiled when needed, no background)
1391
  if os.environ.get("ONW_FUSED_BG", "1") != "0" and not self._fused_bg and not npuw_:
1392
  # every variant of every fused size, in one background thread, the short ones first (compiled once; later
@@ -1396,7 +1489,6 @@ class SegmentedModel:
1396
  jobs = [(x, B) for B, _ in caps for x in self._fused_sizes]
1397
  self._fused_thread = threading.Thread(target=self._fused_precompile, args=(jobs,), daemon=True)
1398
  self._fused_thread.start()
1399
- return self._fused[S]
1400
 
1401
  def _fused_precompile(self, jobs):
1402
  from . import fused as _f
@@ -1842,10 +1934,33 @@ class SegmentedModel:
1842
  os.remove(bp)
1843
  except OSError:
1844
  pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1845
  if own and os.path.exists(bp):
1846
  try:
1847
  req = import_blob(core, bp, device, nocache).create_infer_request()
1848
  USED_BLOBS.add(bp)
 
 
1849
  tried.set(gname, how)
1850
  return req
1851
  except (RuntimeError, OSError) as e: # (OSError: removed meanwhile by another process)
@@ -1858,6 +1973,47 @@ class SegmentedModel:
1858
  except OSError:
1859
  pass
1860
  fresh = how != known and device == "NPU"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1861
  try:
1862
  if fresh:
1863
  tried.trying(gname, how)
 
692
  return core.import_model(io.BytesIO(f.read()), device, cfg)
693
 
694
 
695
+ # [3.0] Weightless blobs: the compiled blob keeps no weights (CACHE_MODE=OPTIMIZE_SIZE + ENABLE_WEIGHTLESS); importing
696
+ # it with the same ov.Model (MODEL_PTR) runs the blob's init schedules, which rebuild the weights on the NPU from the
697
+ # model's constants (the mapped .bin files). The disk cache then holds the graphs only, and onw's own NPU plugin shares
698
+ # identical rebuilt weights between graphs (paged parts, fused graphs, every block size: the model's weights once in
699
+ # memory) - with one init output per weight (ONW_WS_NO_CONCAT, read by onw's own compiler). Needs a compiler that
700
+ # supports it for the weight format (onw's own build; pip OpenVINO 2026.5: not NF4; NPU 3720: no): after the first
701
+ # failure in a process, full blobs as before. ONW_WEIGHTLESS=0 turns it off.
702
+ WEIGHTLESS_CFG = {"CACHE_MODE": "OPTIMIZE_SIZE", "ENABLE_WEIGHTLESS": True, "NPU_COMPILER_TYPE": "PLUGIN"}
703
+ _WEIGHTLESS = {"ok": None}
704
+ os.environ.setdefault("ONW_WS_NO_CONCAT", "1")
705
+
706
+
707
+ def weightless_wanted():
708
+ from . import npukit # (default: with onw's NPU kit only - OpenVINO's compiler fails NF4)
709
+ return (os.environ.get("ONW_WEIGHTLESS", "1" if npukit.active() else "0") != "0" and _WEIGHTLESS["ok"] is not False
710
+ and not _WEIGHTLESS.get("skip_model"))
711
+
712
+
713
+ def int4_weights(d, meta):
714
+ """[3.0] The model's weights are INT4 (i4 / u4 constants, no NF4) - read from its first segment's IR."""
715
+ names = sorted(n for n in meta.get("segments", {}) if n.endswith(".xml"))
716
+ if not names:
717
+ return False
718
+ try:
719
+ with open(os.path.join(d, names[0]), encoding="utf8", errors="replace") as f:
720
+ xml = f.read()
721
+ except OSError:
722
+ return False
723
+ return ('element_type="nf4"' not in xml and
724
+ ('element_type="i4"' in xml or 'element_type="u4"' in xml))
725
+
726
+
727
+ def weightless_failed(e, log=print):
728
+ if _WEIGHTLESS["ok"] is not False:
729
+ log(f"[compile] weightless blobs not supported here ({' '.join(str(e).split())[-140:]}): full blobs",
730
+ flush=True)
731
+ _WEIGHTLESS["ok"] = False
732
+ _WEIGHTLESS["full"] = True
733
+
734
+
735
  def _signature(model):
736
  """A cheap fingerprint of a graph's structure (op types, shapes, input / output names): a part's compiled blob is
737
  only reused for exactly the same part, whatever changes in how segments are split."""
 
1081
  # MoE models already hold their experts once (weights as inputs), so NPUW is off for them unless ONW_NPUW_MOE=1:
1082
  # it stopped mid-answer on LFM2 (NPU 4000), but that was with a Linux driver < 1.38 (see the conv fix there)
1083
  npuw = device == "NPU" and use_npuw(core) and (not self.bank or os.environ.get("ONW_NPUW_MOE") == "1")
1084
+ # [3.0] 2-bit weights stay inside the blobs: the weights separation leaves them there anyway (Bonsai 2 9B part:
1085
+ # 332.6 -> 331.8 MB) and compiles 7x slower (19 -> 142 s)
1086
+ # INT4 weights of the plain paged models as well: never separated by the compiler (Granite 4.2 3B INT4 part:
1087
+ # 63.5 MB weightless against 63.6 MB, multi-row parts 20-35x slower to compile) - INT8 and NF4 are (llm-jp INT8
1088
+ # part: 2.5 against 216 MB). (NPUW and MoE models as they were checked: gemma-4, Qwen3.6)
1089
+ _WEIGHTLESS["skip_model"] = self.meta.get("needs") == "u2"
1090
+ _WEIGHTLESS["full"] = False # (this model: a part with its weights inside, see fused.wanted)
1091
+ if device == "NPU" and not _WEIGHTLESS.get("told"):
1092
+ from . import npukit
1093
+ _WEIGHTLESS["told"] = True
1094
+ print(f"[npukit] {'onw NPU kit ' + npukit.why() if npukit.active() else 'OpenVINO NPU plugin (onw kit: ' + npukit.why() + ')'}",
1095
+ flush=True)
1096
  if npuw and self.meta.get("needs") == "u2" and os.environ.get("ONW_NPUW") != "1":
1097
  # 2-bit weights stay 2-bit compiled plain only: NPUW expands them (Lunar Lake, Ternary Bonsai 2 27B: 12.6 GB
1098
  # of NPU memory instead of ~7, the 16 GB machine out of memory, 0.04 tok/s against 4 tok/s plain)
 
1104
  print(f"[npuw] off: ~{parts} compiled parts (each attention layer cuts a segment) - more than the NPU driver "
1105
  f"holds with NPUW (~30); ONW_NPUW=1 forces it", flush=True)
1106
  npuw = False
1107
+ if not npuw and not self.bank and int4_weights(d, self.meta):
1108
+ _WEIGHTLESS["skip_model"] = True # (decided here: NPUW may have been turned off just above)
1109
  if npuw:
1110
  # experimental: NPUW pulls the weights out of every compiled graph into one weights bank; constants match
1111
  # by data pointer, so every block size reads its IR against the same in-memory copy of the .bin
 
1394
  # ---- [2.0.4] fused graphs (onw/fused.py): one NPU call per segment, for answers and for prompts ----
1395
  FUSED_B = (1, 2, 3) # the variants: the first 1 / 2 / 3 paged blocks (1K / 2K / 4K)
1396
 
1397
+ def fused_keep_all(self):
1398
+ """[3.0] every fused variant stays loaded (onw's NPU kit, NPU 4000+, not the memory-saving mode)."""
1399
+ from . import npukit
1400
+ return os.environ.get("ONW_FUSED_KEEP", "1" if npukit.active() and npu_arch(self._ctx["core"]) >= 4000
1401
+ and not mem_saver() else "0") == "1"
1402
+
1403
  def fused_prepare(self, need, S=None):
1404
  """Make the fused S-token graphs (default: the answer size) covering `need` positions the ones in use for that
1405
  size (loaded from onw's blob store, or compiled the first time); the other variants are compiled in the
 
1412
  if S not in sizes:
1413
  return None
1414
  from . import fused as _f
1415
+ keep_all = self.fused_keep_all()
1416
+ # [3.0] with every variant kept (shared weights) the variants go on up to the context: 8K / 16K / 32K positions
1417
+ # (the first 4 / 5 / 6 paged blocks) - past 4K the paged graphs answered at about half the speed
1418
+ nb = len(_f.blocks_for(self.kv.capacity, 99))
1419
+ bs = tuple(range(1, min(nb, int(os.environ.get("ONW_FUSED_BLOCKS", "6"))) + 1)) if keep_all else self.FUSED_B
1420
+ caps = [(B, sum(_f.blocks_for(self.kv.capacity, B))) for B in bs]
1421
  want = next((B for B, c in caps if c >= min(need, self.T)), None)
1422
  F = self._fused.get(S)
1423
+ if keep_all:
1424
+ # [3.0] every variant stays loaded next to the paged graphs (with onw's own NPU plugin they share one copy
1425
+ # of the weights): no graph is dropped or reloaded while chatting - no pause at 1K / 2K / 4K positions, and
1426
+ # the same graphs for the same positions whatever came before
1427
+ keep = self.__dict__.setdefault("_fused_keep", {})
1428
+ if want is None:
1429
+ self._fused.pop(S, None)
1430
+ if S not in self.segs:
1431
+ self.swap_to(S)
1432
+ self.S = max([*self.segs, *self._fused])
1433
+ return None
1434
+ F = next((keep[(S, B)] for B in bs if B >= want and (S, B) in keep), None)
1435
+ if F is None:
1436
+ with self._mutex:
1437
+ try:
1438
+ F = keep[(S, want)] = _f.FusedDecode(self, S, want)
1439
+ except Exception as e:
1440
+ print(f"[fused] {S}-token graphs off: {' '.join(str(e).split())[-400:]}", flush=True)
1441
+ self._fused_sizes = [x for x in sizes if x != S]
1442
+ return None
1443
+ self._fused[S] = F
1444
+ self.S = max([*self.segs, *self._fused])
1445
+ self._fused_background(caps)
1446
+ return F
1447
  if want is None: # longer than the largest variant: the paged graphs
1448
  if F is not None:
1449
  F = None # [2.1.0] (no reference left: freed before the paged graphs load)
 
1476
  self.swap_to(S)
1477
  return None
1478
  self.S = max([*self.segs, *self._fused]) # (prompts are still fed in blocks of the largest size)
1479
+ self._fused_background(caps)
1480
+ return self._fused[S]
1481
+
1482
+ def _fused_background(self, caps):
1483
  npuw_ = (self._ctx["config"] or {}).get("NPU_USE_NPUW") == "YES" # (NPUW: compiled when needed, no background)
1484
  if os.environ.get("ONW_FUSED_BG", "1") != "0" and not self._fused_bg and not npuw_:
1485
  # every variant of every fused size, in one background thread, the short ones first (compiled once; later
 
1489
  jobs = [(x, B) for B, _ in caps for x in self._fused_sizes]
1490
  self._fused_thread = threading.Thread(target=self._fused_precompile, args=(jobs,), daemon=True)
1491
  self._fused_thread.start()
 
1492
 
1493
  def _fused_precompile(self, jobs):
1494
  from . import fused as _f
 
1934
  os.remove(bp)
1935
  except OSError:
1936
  pass
1937
+ bpw = blob_path(cache, gname, src + [key, "weightless"]) if own and device == "NPU" else None
1938
+ if bpw and os.path.exists(bpw) and stale(binf, bpw):
1939
+ try:
1940
+ os.remove(bpw)
1941
+ except OSError:
1942
+ pass
1943
+ if bpw and os.path.exists(bpw) and weightless_wanted():
1944
+ try:
1945
+ req = import_blob(core, bpw, device, {**nocache, "MODEL_PTR": model}).create_infer_request()
1946
+ USED_BLOBS.add(bpw)
1947
+ tried.set(gname, how)
1948
+ return req
1949
+ except (RuntimeError, OSError) as e:
1950
+ if _transient(str(e)):
1951
+ raise RuntimeError(TRANSIENT + str(e)) from e
1952
+ print(f"[compile] {gname}: stored weightless blob unusable ({' '.join(str(e).split())[-120:]})",
1953
+ flush=True)
1954
+ try:
1955
+ os.remove(bpw)
1956
+ except OSError:
1957
+ pass
1958
  if own and os.path.exists(bp):
1959
  try:
1960
  req = import_blob(core, bp, device, nocache).create_infer_request()
1961
  USED_BLOBS.add(bp)
1962
+ if bpw and weightless_wanted():
1963
+ _WEIGHTLESS["full"] = True # (compiled with its weights: weightless failed for it before)
1964
  tried.set(gname, how)
1965
  return req
1966
  except (RuntimeError, OSError) as e: # (OSError: removed meanwhile by another process)
 
1973
  except OSError:
1974
  pass
1975
  fresh = how != known and device == "NPU"
1976
+ if bpw and weightless_wanted():
1977
+ try:
1978
+ if fresh:
1979
+ tried.trying(gname, how)
1980
+ if part is None: # (whole segments: out of process, as below)
1981
+ r = _probe(d, name, {**nocache, **WEIGHTLESS_CFG}, npuw, T, export=bpw, ring=ring)
1982
+ if r is not True:
1983
+ raise RuntimeError(r if isinstance(r, str) else "rejected")
1984
+ else:
1985
+ cm = core.compile_model(model, device, {**nocache, **WEIGHTLESS_CFG})
1986
+ import io
1987
+ buf = io.BytesIO()
1988
+ cm.export_model(buf)
1989
+ del cm
1990
+ os.makedirs(os.path.dirname(bpw), exist_ok=True)
1991
+ with open(f"{bpw}.{os.getpid()}.tmp", "wb") as f:
1992
+ f.write(buf.getbuffer())
1993
+ del buf
1994
+ os.replace(f"{bpw}.{os.getpid()}.tmp", bpw)
1995
+ req = import_blob(core, bpw, device, {**nocache, "MODEL_PTR": model}).create_infer_request()
1996
+ USED_BLOBS.add(bpw)
1997
+ _WEIGHTLESS["ok"] = True
1998
+ if fresh:
1999
+ print(f"[compile] {gname}: {how} (weightless)", flush=True)
2000
+ if provisional:
2001
+ tried.untry(gname, how, known)
2002
+ else:
2003
+ tried.set(gname, how)
2004
+ tried.save()
2005
+ return req
2006
+ except RuntimeError as e:
2007
+ if _transient(str(e)):
2008
+ tried.untry(gname, how, known)
2009
+ raise RuntimeError(TRANSIENT + str(e)) from e
2010
+ weightless_failed(e)
2011
+ if fresh:
2012
+ tried.untry(gname, how, known)
2013
+ try:
2014
+ os.remove(bpw)
2015
+ except OSError:
2016
+ pass
2017
  try:
2018
  if fresh:
2019
  tried.trying(gname, how)
onw/server.py CHANGED
@@ -1,748 +1,749 @@
1
- """OpenAI-compatible API server for an onw model on the Intel NPU (point other apps at http://HOST:PORT/v1).
2
-
3
- GET / what this server is: model, OpenAI base URL, endpoints (JSON)
4
- GET /v1/models
5
- GET /v1/models/{id}
6
- POST /v1/chat/completions (stream true/false; text and image_url parts: data: URLs or http(s)); responses
7
- also carry "onw_stats" (prefill / decode speed, reused tokens) - extra field
8
- GET /health 200 when the model is loaded
9
- GET /chat browser chat UI for trying the model
10
- GET /check verification page: speed, image, multi-turn reuse
11
- GET /api/info model and device details
12
-
13
- usage: onw serve MODEL [--port 8000] [--host 0.0.0.0] [--api-key KEY] [--device NPU|CPU] [--open]
14
- CORS is open, so browser-based clients work too; with --api-key every /v1 request needs "Authorization: Bearer KEY"
15
- (except from this PC's own programs and pages: the chat and check pages send no key).
16
- Thinking mode: "chat_template_kwargs": {"enable_thinking": true}, or "reasoning_effort": "low" | "medium" | "high"
17
- (gpt-oss uses the level; "none" / "minimal" turn thinking off). Whatever the model thought comes back as
18
- message.reasoning_content (delta.reasoning_content when streaming; also for models that think with thinking off, as vLLM's
19
- reasoning parsers do), the answer as content, and the thinking's token count as OpenAI's
20
- usage.completion_tokens_details.reasoning_tokens.
21
- Tool calls: "tools" (OpenAI format) -> message.tool_calls / finish_reason "tool_calls"; results as role "tool".
22
- tool_choice "none" hides the tools; a named function narrows them to that one ("required" cannot be enforced).
23
- response_format json_object / json_schema is best effort (an instruction is added; the output is not constrained).
24
- Not supported: n > 1 (400), logprobs (null).
25
- One NPU pipeline serves requests one at a time (a lock serializes them).
26
- """
27
- import argparse, asyncio, base64, io, ipaddress, json, os, re, socket, threading, time, uuid, urllib.parse, urllib.request
28
-
29
- import uvicorn
30
- from fastapi import FastAPI, HTTPException, Request
31
- from fastapi.exceptions import RequestValidationError
32
- from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
33
- from PIL import Image
34
- from onw import __version__, i18n
35
- from onw import tools as tool_calls
36
-
37
- MODEL_ID = "npu-model" # set from the engine directory in main()
38
- app = FastAPI(title="LLM on Intel NPU")
39
- pipe = None # onw.chat.ChatEngine, created in main()
40
- info = {}
41
- lock = threading.Lock() # one generation at a time on the NPU
42
- WEB = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
43
- API_KEY = None # --api-key: required as a Bearer token on /v1
44
- API_SAMPLING = None # sampling for requests that send none (ONW_API_SAMPLING: "recommended" or a JSON of values)
45
- TOO_LONG = re.compile(r"the prompt is (\d+) tokens; the context holds (\d+)") # (ChatEngine.stream_chat's ValueError)
46
-
47
-
48
- def oai_error(status, message, type_="invalid_request_error", code=None):
49
- """An error in OpenAI's shape: {"error": {"message", "type", "code"}} (a prompt over the context: code
50
- "context_length_exceeded", as OpenAI's)."""
51
- code = code or ("context_length_exceeded" if TOO_LONG.match(message) else None)
52
- return JSONResponse({"error": {"message": message, "type": type_, "param": None, "code": code}}, status_code=status)
53
-
54
-
55
- def anthropic_message(message):
56
- """Anthropic's wording for a prompt over the context ("prompt is too long: N tokens > M maximum" - Claude Code
57
- compacts the conversation on it); other messages as they are."""
58
- m = TOO_LONG.match(message)
59
- return f"prompt is too long: {m[1]} tokens > {m[2]} maximum" if m else message
60
-
61
-
62
- def api_error(request, status, message, type_="invalid_request_error", code=None):
63
- """oai_error, or on the Anthropic Messages API (/v1/messages...) Anthropic's shape:
64
- {"type": "error", "error": {"type", "message"}}."""
65
- if request.url.path.startswith("/v1/messages"):
66
- typ = {401: "authentication_error", 403: "permission_error", 404: "not_found_error",
67
- 413: "request_too_large"}.get(status, "api_error" if status >= 500 else "invalid_request_error")
68
- return JSONResponse({"type": "error", "error": {"type": typ, "message": anthropic_message(message)}},
69
- status_code=status)
70
- return oai_error(status, message, type_, code)
71
-
72
-
73
- @app.exception_handler(HTTPException)
74
- async def _http_error(request, e):
75
- return api_error(request, e.status_code, str(e.detail),
76
- "invalid_request_error" if e.status_code < 500 else "server_error")
77
-
78
-
79
- @app.exception_handler(RequestValidationError)
80
- async def _validation_error(request, e):
81
- return api_error(request, 400, str(e))
82
-
83
-
84
- @app.exception_handler(Exception)
85
- async def _server_error(request, e):
86
- return api_error(request, 500, f"{type(e).__name__}: {e}", "server_error")
87
-
88
-
89
- CORS = [(b"access-control-allow-origin", b"*"), (b"access-control-allow-headers", b"*"),
90
- (b"access-control-allow-methods", b"GET, POST, OPTIONS")]
91
-
92
-
93
- class CorsAndKey:
94
- """Open CORS (browser-based OpenAI clients) and the optional API key on /v1 (not asked of this PC's own programs and
95
- pages - the chat and check pages send none; the key keeps the LAN out). A plain ASGI middleware, not
96
- @app.middleware("http"): behind that one (Starlette's BaseHTTPMiddleware) request.is_disconnected() never turns
97
- true, and a client that left a whole (non-streamed) answer kept the NPU busy to max_tokens."""
98
- def __init__(self, inner):
99
- self.inner = inner
100
-
101
- async def __call__(self, scope, receive, send):
102
- if scope["type"] != "http":
103
- return await self.inner(scope, receive, send)
104
- request = Request(scope) # (headers and client only: the body stays with the app)
105
- path = scope["path"]
106
- if path.startswith("/api/chats"): # saved conversations: this PC's own chat page only
107
- why = _chats_refused(request)
108
- if why:
109
- return await JSONResponse({"error": why}, status_code=403)(scope, receive, send) # (no CORS)
110
- return await self.inner(scope, receive, send)
111
-
112
- async def with_cors(msg):
113
- if msg["type"] == "http.response.start":
114
- msg = {**msg, "headers": [h for h in msg.get("headers", []) if h[0].lower() not in dict(CORS)] + CORS}
115
- await send(msg)
116
- if scope["method"] == "OPTIONS":
117
- return await JSONResponse({})(scope, receive, with_cors)
118
- if API_KEY and path.startswith("/v1") and not _local(request) and \
119
- request.headers.get("authorization", "") != f"Bearer {API_KEY}" and \
120
- request.headers.get("x-api-key", "") != API_KEY: # (Anthropic clients send x-api-key)
121
- return await api_error(request, 401, "invalid API key", code="invalid_api_key")(scope, receive, with_cors)
122
- await self.inner(scope, receive, with_cors)
123
-
124
-
125
- app.add_middleware(CorsAndKey)
126
-
127
-
128
- # ---------------------------------------------------------------- saved conversations (the chat page's history)
129
- LOCAL_HOSTS = ("localhost", "127.0.0.1", "[::1]", "::1")
130
- CHAT_ID = __import__("re").compile(r"^[a-z0-9-]{8,48}$")
131
- CHAT_MAX = 32 << 20 # bytes per conversation (images are kept small by the page)
132
-
133
-
134
- def _chats_refused(request):
135
- """Why a request for the saved conversations is refused, or None: only from this PC (also when the server listens on
136
- the LAN), only under a localhost name (a rebound DNS name of some web site would otherwise pass as same-origin) and
137
- only from the chat page itself (no cross-site reads or writes: these responses carry no CORS headers)."""
138
- client = request.client.host if request.client else ""
139
- try:
140
- if not ipaddress.ip_address(client).is_loopback:
141
- return "only from this PC"
142
- except ValueError:
143
- return "only from this PC"
144
- host = urllib.parse.urlsplit("//" + request.headers.get("host", "")).hostname or ""
145
- if host not in LOCAL_HOSTS:
146
- return "only under localhost"
147
- origin = request.headers.get("origin")
148
- if origin and (urllib.parse.urlsplit(origin).hostname or "") not in LOCAL_HOSTS:
149
- return "not from another site"
150
- return None
151
-
152
-
153
- def _local(request):
154
- """True for a request from this PC itself as _chats_refused judges it (this PC's address, a localhost name, no other
155
- site's page) and not one a proxy forwards (with X-Forwarded-For from a proxy on this PC, uvicorn already puts the
156
- real client's address in request.client; a bare "Forwarded" it does not read)."""
157
- return _chats_refused(request) is None and "forwarded" not in request.headers and \
158
- "x-forwarded-for" not in request.headers
159
-
160
-
161
- def chats_dir():
162
- from onw import app as _app
163
- d = os.path.join(_app.config_dir(), "chats")
164
- os.makedirs(d, exist_ok=True)
165
- return d
166
-
167
-
168
- def _chat_path(cid):
169
- if not CHAT_ID.match(cid or ""):
170
- raise HTTPException(400, "bad conversation id")
171
- return os.path.join(chats_dir(), cid + ".json")
172
-
173
-
174
- @app.get("/api/chats")
175
- def chats_list():
176
- out = []
177
- for f in os.listdir(chats_dir()):
178
- if not f.endswith(".json"):
179
- continue
180
- try:
181
- c = json.load(open(os.path.join(chats_dir(), f), encoding="utf8"))
182
- out.append({k: c.get(k) for k in ("id", "title", "model", "created", "updated")} | {"n": len(c.get("messages", []))})
183
- except (OSError, ValueError):
184
- continue
185
- return sorted(out, key=lambda c: c.get("updated") or 0, reverse=True)
186
-
187
-
188
- @app.get("/api/chats/{cid}")
189
- def chat_get(cid: str):
190
- try:
191
- return json.load(open(_chat_path(cid), encoding="utf8"))
192
- except FileNotFoundError:
193
- raise HTTPException(404, "no such conversation")
194
-
195
-
196
- @app.put("/api/chats/{cid}")
197
- async def chat_put(cid: str, request: Request):
198
- p = _chat_path(cid)
199
- raw = await request.body()
200
- if len(raw) > CHAT_MAX:
201
- raise HTTPException(413, "conversation too large")
202
- try:
203
- c = json.loads(raw)
204
- assert isinstance(c, dict) and isinstance(c.get("messages"), list)
205
- except (ValueError, AssertionError):
206
- raise HTTPException(400, "not a conversation")
207
- c["id"] = cid
208
- with open(p + ".tmp", "w", encoding="utf8") as f:
209
- json.dump(c, f, ensure_ascii=False)
210
- os.replace(p + ".tmp", p)
211
- return {"ok": True}
212
-
213
-
214
- @app.delete("/api/chats/{cid}")
215
- def chat_delete(cid: str):
216
- try:
217
- os.remove(_chat_path(cid))
218
- except FileNotFoundError:
219
- pass
220
- return {"ok": True}
221
-
222
-
223
- @app.delete("/api/chats")
224
- def chats_delete_all():
225
- n = 0
226
- for f in os.listdir(chats_dir()):
227
- if f.endswith(".json"):
228
- try:
229
- os.remove(os.path.join(chats_dir(), f))
230
- n += 1
231
- except OSError:
232
- pass
233
- return {"ok": True, "deleted": n}
234
-
235
-
236
- def start_generation(msgs, kw):
237
- """Run stream_chat in a worker thread; returns (asyncio queue of deltas/stats/exceptions, None at the end; stop
238
- event). Called on the event loop: the worker hands its items over to it, so no thread waits per queued request."""
239
- loop, q, stop = asyncio.get_running_loop(), asyncio.Queue(), threading.Event()
240
-
241
- def put(x):
242
- try:
243
- loop.call_soon_threadsafe(q.put_nowait, x)
244
- except RuntimeError: # the event loop is gone (the server is stopping)
245
- stop.set()
246
-
247
- def work():
248
- try:
249
- with lock:
250
- if stop.is_set(): # the client left while this request waited for the NPU
251
- return
252
- set_priority(True)
253
- try:
254
- for d in pipe.stream_chat(msgs, should_stop=stop.is_set, split_reasoning=True, **kw):
255
- put(d)
256
- finally:
257
- set_priority(False)
258
- except Exception as e: # surfaced to the client
259
- put(e)
260
- finally:
261
- put(None)
262
- threading.Thread(target=work, daemon=True).start()
263
- return q, stop
264
-
265
-
266
- def reader(q, stop, request=None):
267
- """-> async function returning start_generation's next item. With request (a whole response, not a stream): a
268
- client that leaves meanwhile stops the generation (checked about once a second; a stream stops when it closes)."""
269
- last = [time.monotonic()]
270
-
271
- async def next_item():
272
- while request is not None and not stop.is_set():
273
- wait = last[0] + 1.0 - time.monotonic()
274
- if wait <= 0:
275
- last[0] = time.monotonic()
276
- if await request.is_disconnected():
277
- stop.set()
278
- continue
279
- try:
280
- return await asyncio.wait_for(q.get(), wait)
281
- except asyncio.TimeoutError:
282
- pass
283
- return await q.get()
284
- return next_item
285
-
286
-
287
- MAX_IMAGE = 20 << 20 # bytes
288
-
289
-
290
- def _public_host(url):
291
- """True when every address the URL's host resolves to is a public one: the server does not fetch from the local
292
- network on a client's behalf (ONW_ALLOW_LOCAL_IMAGES=1 allows it)."""
293
- if os.environ.get("ONW_ALLOW_LOCAL_IMAGES") == "1":
294
- return True
295
- host = urllib.parse.urlsplit(url).hostname
296
- try:
297
- addrs = {a[4][0] for a in socket.getaddrinfo(host, None)}
298
- except (socket.gaierror, UnicodeError, TypeError):
299
- return False
300
- return bool(addrs) and all(ipaddress.ip_address(a.split("%")[0]).is_global for a in addrs)
301
-
302
-
303
- def load_image(url):
304
- try:
305
- if url.startswith("data:"):
306
- data = base64.b64decode(url.split(",", 1)[1])
307
- elif url.startswith(("http://", "https://")):
308
- if not _public_host(url):
309
- raise HTTPException(400, "image_url: only public http(s) addresses are fetched (send a data: URL instead)")
310
- req = urllib.request.Request(url, headers={"User-Agent": f"onw/{__version__}"})
311
- with urllib.request.urlopen(req, timeout=20) as r:
312
- data = r.read(MAX_IMAGE + 1)
313
- if len(data) > MAX_IMAGE:
314
- raise HTTPException(400, "image_url: the image is larger than 20 MB")
315
- else:
316
- raise HTTPException(400, "image_url must be a data: URL or http(s) URL")
317
- return Image.open(io.BytesIO(data)).convert("RGB")
318
- except HTTPException:
319
- raise
320
- except Exception as e: # bad base64, not an image, unreachable URL
321
- raise HTTPException(400, f"image_url: {type(e).__name__}: {e}")
322
-
323
-
324
- def to_hf_messages(messages):
325
- """OpenAI chat messages -> HF chat format with PIL images."""
326
- out = []
327
- for m in tool_calls.history(messages):
328
- keep = {k: m[k] for k in ("tool_calls", "tool_call_id", "name", "reasoning_content") if m.get(k) is not None}
329
- c = m.get("content")
330
- if c is None or isinstance(c, str):
331
- out.append({"role": m["role"], "content": c or "", **keep})
332
- continue
333
- parts = []
334
- for p in c:
335
- if p.get("type") == "text":
336
- parts.append({"type": "text", "text": p.get("text") or ""})
337
- elif p.get("type") == "image_url":
338
- iu = p.get("image_url") or {}
339
- url = iu.get("url") if isinstance(iu, dict) else iu
340
- if not isinstance(url, str):
341
- raise HTTPException(400, "image_url part without a url")
342
- parts.append({"type": "image", "image": load_image(url)})
343
- else:
344
- raise HTTPException(400, f"unsupported content part type: {p.get('type')}")
345
- out.append({"role": m["role"], "content": parts, **keep})
346
- return out
347
-
348
-
349
- def _num(body, key, default, cast=float):
350
- v = body.get(key)
351
- if v is None:
352
- return default
353
- try:
354
- return cast(v)
355
- except (TypeError, ValueError):
356
- raise HTTPException(400, f"{key} must be a number")
357
-
358
-
359
- LEVELS = ("off", "low", "medium", "high") # the thinking levels of the window and the chat page (reasoning_effort)
360
- _PREFS = [None, None] # (config mtime, this model's defaults)
361
-
362
-
363
- def model_defaults():
364
- """This model's defaults from the onw window (config model_prefs, read again when the file changes - no restart):
365
- {"think": level, "sampling": "greedy" | "recommended" | "custom", values...}; {} when none were saved. Settings
366
- of earlier versions (ONW_THINK_DEFAULT, ONW_API_SAMPLING) apply where these say nothing."""
367
- key = os.environ.get("ONW_MODEL_KEY")
368
- if not key:
369
- return {}
370
- try:
371
- from onw import app as _app
372
- p = os.path.join(_app.config_dir(), "config.json")
373
- mt = os.path.getmtime(p)
374
- if _PREFS[0] != mt:
375
- _PREFS[:] = [mt, (_app.load_config().get("model_prefs") or {}).get(key) or {}]
376
- return _PREFS[1]
377
- except (OSError, ValueError):
378
- return {}
379
-
380
-
381
- def default_think():
382
- """Thinking for a request that says nothing about it: the model's saved level, else the old switch."""
383
- lvl = model_defaults().get("think")
384
- if lvl == "off":
385
- return False
386
- if lvl in LEVELS:
387
- return lvl
388
- return True if os.environ.get("ONW_THINK_DEFAULT") == "1" else None
389
-
390
-
391
- def default_sampling():
392
- """Sampling for a request that sends none: the model's saved choice, else the old setting (API_SAMPLING)."""
393
- d = model_defaults()
394
- mode = d.get("sampling")
395
- if mode == "greedy":
396
- return None
397
- if mode == "recommended":
398
- return {k: v for k, v in (info.get("sampling") or {}).items() if k != "source"} or None
399
- if mode == "custom":
400
- return {k: d[k] for k in ("temperature", "top_p", "top_k", "presence_penalty") if k in d}
401
- return API_SAMPLING
402
-
403
-
404
- def gen_args(body):
405
- """OpenAI request fields -> ChatEngine.stream_chat arguments."""
406
- if _num(body, "n", 1, int) != 1:
407
- raise HTTPException(400, "n > 1 is not supported (one NPU pipeline)")
408
- stop = body.get("stop") or []
409
- mx = body.get("max_completion_tokens", body.get("max_tokens"))
410
- # a request without max_tokens: the setting (ONW_MAX_TOKENS; 0 = up to the end of the context - the engine stops
411
- # there anyway); agents send none and write whole files in one answer
412
- if mx is None:
413
- mx = int(os.environ.get("ONW_MAX_TOKENS", "8192") or 0) or (1 << 30)
414
- else:
415
- mx = _num({"max_tokens": mx}, "max_tokens", 512, int)
416
- if mx < 1:
417
- raise HTTPException(400, "max_tokens must be at least 1")
418
- think = (body.get("chat_template_kwargs") or {}).get("enable_thinking")
419
- effort = body.get("reasoning_effort")
420
- if isinstance(effort, str): # a level: low / medium / high (gpt-oss, llm-jp: trained;
421
- think = False if effort in ("none", "minimal", "off") else effort # others: a thinking budget)
422
- if think is None: # nothing said: the window's default for this model
423
- think = default_think()
424
- tools, choice = body.get("tools") or None, body.get("tool_choice")
425
- if choice == "none":
426
- tools = None
427
- elif isinstance(choice, dict) and tools: # a named function: offer just that one
428
- name = (choice.get("function") or {}).get("name")
429
- tools = [t for t in tools if (t.get("function") or {}).get("name") == name] or tools
430
- # what the request leaves out: greedy, or the settings' API defaults (API_SAMPLING; thinking has its own set)
431
- dflt = dict(default_sampling() or {})
432
- if think and isinstance(dflt.get("think"), dict):
433
- dflt.update(dflt["think"])
434
- return dict(max_new_tokens=mx, temperature=_num(body, "temperature", dflt.get("temperature", 0.0)),
435
- top_p=_num(body, "top_p", dflt.get("top_p", 1.0)),
436
- stop=[stop] if isinstance(stop, str) else list(stop), seed=body.get("seed"), think=think,
437
- top_k=_num(body, "top_k", dflt.get("top_k", 0), int), repetition_penalty=_num(body, "repetition_penalty", 1.0),
438
- presence_penalty=_num(body, "presence_penalty", dflt.get("presence_penalty", 0.0)),
439
- frequency_penalty=_num(body, "frequency_penalty", 0.0), tools=tools)
440
-
441
-
442
- def with_format(msgs, body):
443
- """response_format json_object / json_schema: best effort - an instruction as a system message (the output is not
444
- constrained)."""
445
- rf = body.get("response_format") or {}
446
- kind = rf.get("type") if isinstance(rf, dict) else None
447
- if kind not in ("json_object", "json_schema"):
448
- return msgs
449
- note = "Respond with a single JSON object only, no other text."
450
- if kind == "json_schema":
451
- note += " It must follow this JSON schema: " + json.dumps((rf.get("json_schema") or {}).get("schema") or {},
452
- ensure_ascii=False)
453
- if msgs and msgs[0]["role"] == "system" and isinstance(msgs[0]["content"], str):
454
- return [dict(msgs[0], content=msgs[0]["content"] + "\n\n" + note)] + msgs[1:]
455
- return [{"role": "system", "content": note}] + msgs
456
-
457
-
458
- @app.get("/")
459
- def index(request: Request):
460
- base = str(request.base_url).rstrip("/")
461
- return {"object": "onw.server", "name": "onw - 俺のNPUがこんなに動くわけない", "version": __version__, "model": MODEL_ID,
462
- "openai_base_url": base + "/v1", "api_key_required": bool(API_KEY),
463
- "endpoints": {"models": "/v1/models", "chat_completions": "/v1/chat/completions", "health": "/health",
464
- "chat_ui": "/chat", "check_page": "/check", "info": "/api/info"},
465
- "context": info.get("max_context"), "vision": info.get("vision")}
466
-
467
-
468
- @app.get("/health")
469
- def health():
470
- return {"status": "ok", "model": MODEL_ID}
471
-
472
-
473
- @app.get("/chat")
474
- def chat_ui():
475
- return HTMLResponse(i18n.page(os.path.join(WEB, "index.html")))
476
-
477
-
478
- @app.get("/check")
479
- def check_page():
480
- return HTMLResponse(i18n.page(os.path.join(WEB, "check.html")))
481
-
482
-
483
- @app.get("/api/info")
484
- def api_info():
485
- # the window's defaults for this model, read now (they change without a restart)
486
- d, lvl = model_defaults(), default_think()
487
- samp = default_sampling()
488
- mode = d.get("sampling") or ("custom" if API_SAMPLING and os.environ.get("ONW_API_SAMPLING") != "recommended"
489
- else "recommended" if API_SAMPLING else "greedy")
490
- return {**info, "api_sampling": samp, "api_sampling_mode": mode,
491
- "think_default": "off" if lvl is False else ("medium" if lvl is True else lvl)}
492
-
493
-
494
- STARTED = int(time.time())
495
-
496
-
497
- def _model_card():
498
- return {"id": MODEL_ID, "object": "model", "created": STARTED, "owned_by": "local-npu"}
499
-
500
-
501
- @app.get("/v1/models")
502
- def models():
503
- return {"object": "list", "data": [_model_card()]}
504
-
505
-
506
- @app.get("/v1/models/{model_id:path}")
507
- def model(model_id: str):
508
- if model_id != MODEL_ID:
509
- raise HTTPException(404, f"model {model_id!r} not found (this server runs {MODEL_ID!r})")
510
- return _model_card()
511
-
512
-
513
- def finalize(text, tools, finish):
514
- """The answer's end: tool calls parsed out of the text (OpenAI tool_calls), finish_reason "tool_calls" when there
515
- are any. -> (text, calls, finish_reason)."""
516
- if not tools:
517
- return text, [], finish
518
- text, calls = tool_calls.parse(text, tools)
519
- return text, calls, ("tool_calls" if calls else finish)
520
-
521
-
522
- def usage_of(st):
523
- return {"prompt_tokens": st["prompt_tokens"], "completion_tokens": st["completion_tokens"],
524
- "total_tokens": st["prompt_tokens"] + st["completion_tokens"],
525
- "prompt_tokens_details": {"cached_tokens": st.get("cached_tokens", 0)},
526
- # (OpenAI's field: how many of the completion tokens were thinking)
527
- "completion_tokens_details": {"reasoning_tokens": st.get("reasoning_tokens", 0)}}
528
-
529
-
530
- def stats_of(st):
531
- return {k: (round(v, 2) if isinstance(v, float) else v) for k, v in st.items() if k != "finish_reason"}
532
-
533
-
534
- def worker_error(e):
535
- """A generation error -> (HTTP status, OpenAI error type): the request's fault (a prompt too long, an image for a
536
- text-only model) or the server's (the NPU, the driver)."""
537
- return (400, "invalid_request_error") if isinstance(e, (ValueError, KeyError, TypeError)) else (500, "server_error")
538
-
539
-
540
- @app.post("/v1/chat/completions")
541
- async def chat(request: Request):
542
- try:
543
- body = await request.json()
544
- except ValueError:
545
- raise HTTPException(400, "the request body is not valid JSON")
546
- if not isinstance(body, dict) or not isinstance(body.get("messages"), list):
547
- raise HTTPException(400, "messages (a list) is required")
548
- kw = gen_args(body)
549
- loop = asyncio.get_running_loop()
550
- try: # images may be downloaded: not on the event loop
551
- msgs = await loop.run_in_executor(None, to_hf_messages, body["messages"])
552
- except (KeyError, TypeError, AttributeError, ValueError) as e:
553
- raise HTTPException(400, f"malformed messages: {e}")
554
- msgs = with_format(msgs, body)
555
- cid, created = f"chatcmpl-{uuid.uuid4().hex[:24]}", int(time.time())
556
- tools = kw["tools"]
557
- if body.get("stream"):
558
- def chunk(delta, finish=None, **extra):
559
- d = {"id": cid, "object": "chat.completion.chunk", "created": created, "model": MODEL_ID,
560
- "choices": [{"index": 0, "delta": delta, "logprobs": None, "finish_reason": finish}], **extra}
561
- return f"data: {json.dumps(d, ensure_ascii=False)}\n\n"
562
-
563
- async def events():
564
- full, sent = "", 0 # with tools: text is held back once a call may start
565
- # (started here, not before the response: one whose stream never began would generate on to max_tokens)
566
- q, stop = start_generation(msgs, kw)
567
- next_item = reader(q, stop)
568
- try:
569
- yield chunk({"role": "assistant", "content": ""})
570
- while True:
571
- d = await next_item()
572
- if d is None:
573
- break
574
- if isinstance(d, Exception):
575
- status, typ = worker_error(d)
576
- err = {"error": {"message": str(d), "type": typ, "param": None,
577
- "code": "context_length_exceeded" if TOO_LONG.match(str(d)) else None}}
578
- yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
579
- elif isinstance(d, dict) and "reasoning" in d:
580
- yield chunk({"reasoning_content": d["reasoning"]})
581
- elif isinstance(d, dict):
582
- rest, calls, finish = finalize(full, tools, d["finish_reason"])
583
- if tools and rest.startswith(full[:sent]) and rest[sent:].strip(): # text after the calls
584
- yield chunk({"content": rest[sent:]})
585
- for i, tc in enumerate(calls):
586
- yield chunk({"tool_calls": [dict(tc, index=i)]})
587
- yield chunk({}, finish, onw_stats=stats_of(d))
588
- if (body.get("stream_options") or {}).get("include_usage"): # OpenAI: its own last chunk
589
- yield f"data: {json.dumps({'id': cid, 'object': 'chat.completion.chunk', 'created': created, 'model': MODEL_ID, 'choices': [], 'usage': usage_of(d)})}\n\n"
590
- elif tools:
591
- full += d
592
- upto = tool_calls.safe_upto(full)
593
- if upto > sent:
594
- yield chunk({"content": full[sent:upto]})
595
- sent = upto
596
- else:
597
- yield chunk({"content": d})
598
- yield "data: [DONE]\n\n"
599
- finally: # client disconnected or finished: stop the NPU loop so the lock is released
600
- stop.set()
601
- return StreamingResponse(events(), media_type="text/event-stream",
602
- headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
603
-
604
- parts, thought, st = [], [], None
605
- q, stop = start_generation(msgs, kw)
606
- next_item = reader(q, stop, request) # (a client that leaves stops the generation)
607
- try:
608
- while True:
609
- d = await next_item()
610
- if d is None:
611
- break
612
- if isinstance(d, Exception):
613
- status, typ = worker_error(d)
614
- return oai_error(status, str(d), typ)
615
- if isinstance(d, dict) and "reasoning" in d:
616
- thought.append(d["reasoning"])
617
- elif isinstance(d, dict):
618
- st = d
619
- else:
620
- parts.append(d)
621
- finally:
622
- stop.set()
623
- if st is None:
624
- return oai_error(500, "the generation ended without a result", "server_error")
625
- text, calls, finish = finalize("".join(parts), tools, st["finish_reason"])
626
- msg = {"role": "assistant", "content": (text.strip() or None) if tools else text}
627
- if thought:
628
- msg["reasoning_content"] = "".join(thought)
629
- if calls:
630
- msg["tool_calls"] = calls
631
- return JSONResponse({
632
- "id": cid, "object": "chat.completion", "created": created, "model": MODEL_ID,
633
- "choices": [{"index": 0, "message": msg, "logprobs": None, "finish_reason": finish}],
634
- "usage": usage_of(st),
635
- "onw_stats": stats_of(st),
636
- })
637
-
638
-
639
- def recommended_sampling(arch, engine_dir=None):
640
- """The chat page's starting sampling values ({temperature, top_p, top_k, presence_penalty?, think?, source}) or
641
- None (greedy): the model's own generation_config.json first (Gemma 4, Granite 4.2), else onw/sampling.json by
642
- model_type prefix. The API itself stays greedy unless asked."""
643
- keys = ("temperature", "top_p", "top_k")
644
- try:
645
- with open(os.path.join(engine_dir, "generation_config.json"), encoding="utf8") as f:
646
- g = json.load(f)
647
- if g.get("do_sample") and any(k in g for k in keys):
648
- return {"source": "model", "temperature": g.get("temperature", 1.0), "top_p": g.get("top_p", 1.0),
649
- "top_k": g.get("top_k", 0)}
650
- except (OSError, ValueError, TypeError):
651
- pass
652
- try:
653
- with open(os.path.join(WEB, "..", "sampling.json"), encoding="utf8") as f:
654
- table = {k: v for k, v in json.load(f).items() if not k.startswith("_")}
655
- except (OSError, ValueError):
656
- return None
657
- hit = max((k for k in table if arch.startswith(k)), key=len, default=None)
658
- if hit is None:
659
- return None
660
- return {k: v for k, v in table[hit].items() if k != "note"}
661
-
662
-
663
- from onw import compat # noqa: E402,F401 (/v1/messages and /v1/responses on this app)
664
-
665
-
666
- def set_priority(busy):
667
- """Windows: the server's priority class - normal while loading and waiting, above normal while it generates
668
- (busy). Windows puts a below-normal process on the low-power cores at low clocks (onw 1.4.5 and earlier started
669
- the server below normal: Lunar Lake, Qwen3.5-9B NF4 with MTP ~12 tok/s, 16 above normal); generating takes one or two
670
- cores, so the rest of the PC hardly notices. ONW_PRIORITY: the class while generating, normal | above (default) |
671
- off (leave it as started)."""
672
- want = os.environ.get("ONW_PRIORITY", "above")
673
- if os.name != "nt" or want not in ("normal", "above"):
674
- return
675
- import ctypes
676
- k32 = ctypes.WinDLL("kernel32")
677
- k32.GetCurrentProcess.restype = ctypes.c_void_p
678
- k32.SetPriorityClass.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
679
- k32.SetPriorityClass(k32.GetCurrentProcess(), 0x8000 if busy and want == "above" else 0x20)
680
-
681
-
682
- def main(argv=None):
683
- global pipe, MODEL_ID, API_KEY, API_SAMPLING
684
- ap = argparse.ArgumentParser(prog="onw serve", description="chat UI + OpenAI-compatible API on the Intel NPU")
685
- ap.add_argument("model", help="onw model folder, or a Hugging Face repo id (downloaded into ./models)")
686
- ap.add_argument("--host", default="127.0.0.1")
687
- ap.add_argument("--port", type=int, default=8000)
688
- ap.add_argument("--device", default="NPU", help="run every component on this device (CPU works without an NPU driver)")
689
- ap.add_argument("--name", help="model id / title (default: engine directory name)")
690
- ap.add_argument("--context", type=int, default=None,
691
- help="context length in tokens (default 32768, at most the model's maximum); no recompile")
692
- ap.add_argument("--open", action="store_true", help="open the chat UI in the default browser once loaded")
693
- ap.add_argument("--no-pld", action="store_true", help="disable prompt lookup decoding")
694
- ap.add_argument("--api-key", help="require this key as a Bearer token on /v1 from other machines "
695
- "(e.g. with --host 0.0.0.0)")
696
- a = ap.parse_args(argv)
697
- API_KEY = a.api_key or os.environ.get("ONW_API_KEY") or None
698
- from onw.hub import resolve
699
- a.engine = resolve(a.model)
700
- import openvino as ov
701
- from onw.runtime import npu_info
702
- print(f"[onw] {__version__} | OpenVINO {ov.__version__} | {npu_info(ov.Core())}", flush=True)
703
- if a.device == "NPU" and "NPU" not in ov.Core().available_devices:
704
- print("WARNING: no NPU found (is the NPU driver installed?) - running everything on the CPU instead.", flush=True)
705
- a.device = "CPU"
706
- from onw.chat import ChatEngine
707
- pipe = ChatEngine(a.engine, a.device, context=a.context, pld=not a.no_pld)
708
- set_priority(False)
709
- MODEL_ID = a.name or os.path.basename(os.path.normpath(a.engine))
710
- v = pipe.vision
711
- info.update(model=MODEL_ID, title=MODEL_ID, devices={"lm": a.device}, fast=False, vision=bool(v),
712
- image_px=v.get("image_px") if v else None,
713
- think=pipe.think_style is not None, max_context=pipe.max_context,
714
- always_thinks=pipe.think_style == "harmony", # (gpt-oss, llm-jp: the thinking counts against max_tokens)
715
- note=f"文脈は約{pipe.max_context}トークン", context=pipe.max_context, pld=pipe.pld,
716
- mtp=getattr(pipe.model, "mtp", None) is not None)
717
- try:
718
- with open(os.path.join(a.engine, "config.json"), encoding="utf8") as f:
719
- arch = json.load(f).get("model_type") or ""
720
- except (OSError, ValueError):
721
- arch = ""
722
- # greedy Gemma on the NPU can fall into repeating one word (fp16 on the NPU tips a near tie; the same happens with
723
- # any onw version): the pages suggest a repetition penalty, the API default stays 1.0 (OpenAI behaviour)
724
- info.update(arch=arch, rep_hint=1.2 if arch.startswith("gemma") else None, sampling=recommended_sampling(arch, a.engine))
725
- want = os.environ.get("ONW_API_SAMPLING")
726
- if want == "recommended":
727
- API_SAMPLING = {k: v for k, v in (info["sampling"] or {}).items() if k != "source"} or None
728
- elif want:
729
- try:
730
- API_SAMPLING = json.loads(want)
731
- except ValueError:
732
- API_SAMPLING = None
733
- info.update(api_sampling=API_SAMPLING, api_sampling_mode="recommended" if want == "recommended" else
734
- "custom" if API_SAMPLING else "greedy")
735
- if API_SAMPLING:
736
- print(f"[api] requests without sampling values use {API_SAMPLING}", flush=True)
737
- if a.open:
738
- import webbrowser
739
- threading.Timer(1.5, webbrowser.open, [f"http://{'localhost' if a.host in ('0.0.0.0', '127.0.0.1') else a.host}:{a.port}/chat"]).start()
740
- print(f"\n OpenAI API: http://localhost:{a.port}/v1 (model id: {MODEL_ID})"
741
- f"\n Chat UI: http://localhost:{a.port}/chat\n Check page: http://localhost:{a.port}/check\n", flush=True)
742
- uvicorn.run(app, host=a.host, port=a.port, log_level="warning")
743
-
744
-
745
- if __name__ == "__main__":
746
- # (python -m onw.server: run the module that onw.compat put its routes on - this one is a second copy of it)
747
- from onw.server import main as _main
748
- _main()
 
 
1
+ """OpenAI-compatible API server for an onw model on the Intel NPU (point other apps at http://HOST:PORT/v1).
2
+
3
+ GET / what this server is: model, OpenAI base URL, endpoints (JSON)
4
+ GET /v1/models
5
+ GET /v1/models/{id}
6
+ POST /v1/chat/completions (stream true/false; text and image_url parts: data: URLs or http(s)); responses
7
+ also carry "onw_stats" (prefill / decode speed, reused tokens) - extra field
8
+ GET /health 200 when the model is loaded
9
+ GET /chat browser chat UI for trying the model
10
+ GET /check verification page: speed, image, multi-turn reuse
11
+ GET /api/info model and device details
12
+
13
+ usage: onw serve MODEL [--port 8000] [--host 0.0.0.0] [--api-key KEY] [--device NPU|CPU] [--open]
14
+ CORS is open, so browser-based clients work too; with --api-key every /v1 request needs "Authorization: Bearer KEY"
15
+ (except from this PC's own programs and pages: the chat and check pages send no key).
16
+ Thinking mode: "chat_template_kwargs": {"enable_thinking": true}, or "reasoning_effort": "low" | "medium" | "high"
17
+ (gpt-oss uses the level; "none" / "minimal" turn thinking off). Whatever the model thought comes back as
18
+ message.reasoning_content (delta.reasoning_content when streaming; also for models that think with thinking off, as vLLM's
19
+ reasoning parsers do), the answer as content, and the thinking's token count as OpenAI's
20
+ usage.completion_tokens_details.reasoning_tokens.
21
+ Tool calls: "tools" (OpenAI format) -> message.tool_calls / finish_reason "tool_calls"; results as role "tool".
22
+ tool_choice "none" hides the tools; a named function narrows them to that one ("required" cannot be enforced).
23
+ response_format json_object / json_schema is best effort (an instruction is added; the output is not constrained).
24
+ Not supported: n > 1 (400), logprobs (null).
25
+ One NPU pipeline serves requests one at a time (a lock serializes them).
26
+ """
27
+ import argparse, asyncio, base64, io, ipaddress, json, os, re, socket, threading, time, uuid, urllib.parse, urllib.request
28
+
29
+ import uvicorn
30
+ from fastapi import FastAPI, HTTPException, Request
31
+ from fastapi.exceptions import RequestValidationError
32
+ from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
33
+ from PIL import Image
34
+ from onw import __version__, i18n
35
+ from onw import tools as tool_calls
36
+
37
+ MODEL_ID = "npu-model" # set from the engine directory in main()
38
+ app = FastAPI(title="LLM on Intel NPU")
39
+ pipe = None # onw.chat.ChatEngine, created in main()
40
+ info = {}
41
+ lock = threading.Lock() # one generation at a time on the NPU
42
+ WEB = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
43
+ API_KEY = None # --api-key: required as a Bearer token on /v1
44
+ API_SAMPLING = None # sampling for requests that send none (ONW_API_SAMPLING: "recommended" or a JSON of values)
45
+ TOO_LONG = re.compile(r"the prompt is (\d+) tokens; the context holds (\d+)") # (ChatEngine.stream_chat's ValueError)
46
+
47
+
48
+ def oai_error(status, message, type_="invalid_request_error", code=None):
49
+ """An error in OpenAI's shape: {"error": {"message", "type", "code"}} (a prompt over the context: code
50
+ "context_length_exceeded", as OpenAI's)."""
51
+ code = code or ("context_length_exceeded" if TOO_LONG.match(message) else None)
52
+ return JSONResponse({"error": {"message": message, "type": type_, "param": None, "code": code}}, status_code=status)
53
+
54
+
55
+ def anthropic_message(message):
56
+ """Anthropic's wording for a prompt over the context ("prompt is too long: N tokens > M maximum" - Claude Code
57
+ compacts the conversation on it); other messages as they are."""
58
+ m = TOO_LONG.match(message)
59
+ return f"prompt is too long: {m[1]} tokens > {m[2]} maximum" if m else message
60
+
61
+
62
+ def api_error(request, status, message, type_="invalid_request_error", code=None):
63
+ """oai_error, or on the Anthropic Messages API (/v1/messages...) Anthropic's shape:
64
+ {"type": "error", "error": {"type", "message"}}."""
65
+ if request.url.path.startswith("/v1/messages"):
66
+ typ = {401: "authentication_error", 403: "permission_error", 404: "not_found_error",
67
+ 413: "request_too_large"}.get(status, "api_error" if status >= 500 else "invalid_request_error")
68
+ return JSONResponse({"type": "error", "error": {"type": typ, "message": anthropic_message(message)}},
69
+ status_code=status)
70
+ return oai_error(status, message, type_, code)
71
+
72
+
73
+ @app.exception_handler(HTTPException)
74
+ async def _http_error(request, e):
75
+ return api_error(request, e.status_code, str(e.detail),
76
+ "invalid_request_error" if e.status_code < 500 else "server_error")
77
+
78
+
79
+ @app.exception_handler(RequestValidationError)
80
+ async def _validation_error(request, e):
81
+ return api_error(request, 400, str(e))
82
+
83
+
84
+ @app.exception_handler(Exception)
85
+ async def _server_error(request, e):
86
+ return api_error(request, 500, f"{type(e).__name__}: {e}", "server_error")
87
+
88
+
89
+ CORS = [(b"access-control-allow-origin", b"*"), (b"access-control-allow-headers", b"*"),
90
+ (b"access-control-allow-methods", b"GET, POST, OPTIONS")]
91
+
92
+
93
+ class CorsAndKey:
94
+ """Open CORS (browser-based OpenAI clients) and the optional API key on /v1 (not asked of this PC's own programs and
95
+ pages - the chat and check pages send none; the key keeps the LAN out). A plain ASGI middleware, not
96
+ @app.middleware("http"): behind that one (Starlette's BaseHTTPMiddleware) request.is_disconnected() never turns
97
+ true, and a client that left a whole (non-streamed) answer kept the NPU busy to max_tokens."""
98
+ def __init__(self, inner):
99
+ self.inner = inner
100
+
101
+ async def __call__(self, scope, receive, send):
102
+ if scope["type"] != "http":
103
+ return await self.inner(scope, receive, send)
104
+ request = Request(scope) # (headers and client only: the body stays with the app)
105
+ path = scope["path"]
106
+ if path.startswith("/api/chats"): # saved conversations: this PC's own chat page only
107
+ why = _chats_refused(request)
108
+ if why:
109
+ return await JSONResponse({"error": why}, status_code=403)(scope, receive, send) # (no CORS)
110
+ return await self.inner(scope, receive, send)
111
+
112
+ async def with_cors(msg):
113
+ if msg["type"] == "http.response.start":
114
+ msg = {**msg, "headers": [h for h in msg.get("headers", []) if h[0].lower() not in dict(CORS)] + CORS}
115
+ await send(msg)
116
+ if scope["method"] == "OPTIONS":
117
+ return await JSONResponse({})(scope, receive, with_cors)
118
+ if API_KEY and path.startswith("/v1") and not _local(request) and \
119
+ request.headers.get("authorization", "") != f"Bearer {API_KEY}" and \
120
+ request.headers.get("x-api-key", "") != API_KEY: # (Anthropic clients send x-api-key)
121
+ return await api_error(request, 401, "invalid API key", code="invalid_api_key")(scope, receive, with_cors)
122
+ await self.inner(scope, receive, with_cors)
123
+
124
+
125
+ app.add_middleware(CorsAndKey)
126
+
127
+
128
+ # ---------------------------------------------------------------- saved conversations (the chat page's history)
129
+ LOCAL_HOSTS = ("localhost", "127.0.0.1", "[::1]", "::1")
130
+ CHAT_ID = __import__("re").compile(r"^[a-z0-9-]{8,48}$")
131
+ CHAT_MAX = 32 << 20 # bytes per conversation (images are kept small by the page)
132
+
133
+
134
+ def _chats_refused(request):
135
+ """Why a request for the saved conversations is refused, or None: only from this PC (also when the server listens on
136
+ the LAN), only under a localhost name (a rebound DNS name of some web site would otherwise pass as same-origin) and
137
+ only from the chat page itself (no cross-site reads or writes: these responses carry no CORS headers)."""
138
+ client = request.client.host if request.client else ""
139
+ try:
140
+ if not ipaddress.ip_address(client).is_loopback:
141
+ return "only from this PC"
142
+ except ValueError:
143
+ return "only from this PC"
144
+ host = urllib.parse.urlsplit("//" + request.headers.get("host", "")).hostname or ""
145
+ if host not in LOCAL_HOSTS:
146
+ return "only under localhost"
147
+ origin = request.headers.get("origin")
148
+ if origin and (urllib.parse.urlsplit(origin).hostname or "") not in LOCAL_HOSTS:
149
+ return "not from another site"
150
+ return None
151
+
152
+
153
+ def _local(request):
154
+ """True for a request from this PC itself as _chats_refused judges it (this PC's address, a localhost name, no other
155
+ site's page) and not one a proxy forwards (with X-Forwarded-For from a proxy on this PC, uvicorn already puts the
156
+ real client's address in request.client; a bare "Forwarded" it does not read)."""
157
+ return _chats_refused(request) is None and "forwarded" not in request.headers and \
158
+ "x-forwarded-for" not in request.headers
159
+
160
+
161
+ def chats_dir():
162
+ from onw import app as _app
163
+ d = os.path.join(_app.config_dir(), "chats")
164
+ os.makedirs(d, exist_ok=True)
165
+ return d
166
+
167
+
168
+ def _chat_path(cid):
169
+ if not CHAT_ID.match(cid or ""):
170
+ raise HTTPException(400, "bad conversation id")
171
+ return os.path.join(chats_dir(), cid + ".json")
172
+
173
+
174
+ @app.get("/api/chats")
175
+ def chats_list():
176
+ out = []
177
+ for f in os.listdir(chats_dir()):
178
+ if not f.endswith(".json"):
179
+ continue
180
+ try:
181
+ c = json.load(open(os.path.join(chats_dir(), f), encoding="utf8"))
182
+ out.append({k: c.get(k) for k in ("id", "title", "model", "created", "updated")} | {"n": len(c.get("messages", []))})
183
+ except (OSError, ValueError):
184
+ continue
185
+ return sorted(out, key=lambda c: c.get("updated") or 0, reverse=True)
186
+
187
+
188
+ @app.get("/api/chats/{cid}")
189
+ def chat_get(cid: str):
190
+ try:
191
+ return json.load(open(_chat_path(cid), encoding="utf8"))
192
+ except FileNotFoundError:
193
+ raise HTTPException(404, "no such conversation")
194
+
195
+
196
+ @app.put("/api/chats/{cid}")
197
+ async def chat_put(cid: str, request: Request):
198
+ p = _chat_path(cid)
199
+ raw = await request.body()
200
+ if len(raw) > CHAT_MAX:
201
+ raise HTTPException(413, "conversation too large")
202
+ try:
203
+ c = json.loads(raw)
204
+ assert isinstance(c, dict) and isinstance(c.get("messages"), list)
205
+ except (ValueError, AssertionError):
206
+ raise HTTPException(400, "not a conversation")
207
+ c["id"] = cid
208
+ with open(p + ".tmp", "w", encoding="utf8") as f:
209
+ json.dump(c, f, ensure_ascii=False)
210
+ os.replace(p + ".tmp", p)
211
+ return {"ok": True}
212
+
213
+
214
+ @app.delete("/api/chats/{cid}")
215
+ def chat_delete(cid: str):
216
+ try:
217
+ os.remove(_chat_path(cid))
218
+ except FileNotFoundError:
219
+ pass
220
+ return {"ok": True}
221
+
222
+
223
+ @app.delete("/api/chats")
224
+ def chats_delete_all():
225
+ n = 0
226
+ for f in os.listdir(chats_dir()):
227
+ if f.endswith(".json"):
228
+ try:
229
+ os.remove(os.path.join(chats_dir(), f))
230
+ n += 1
231
+ except OSError:
232
+ pass
233
+ return {"ok": True, "deleted": n}
234
+
235
+
236
+ def start_generation(msgs, kw):
237
+ """Run stream_chat in a worker thread; returns (asyncio queue of deltas/stats/exceptions, None at the end; stop
238
+ event). Called on the event loop: the worker hands its items over to it, so no thread waits per queued request."""
239
+ loop, q, stop = asyncio.get_running_loop(), asyncio.Queue(), threading.Event()
240
+
241
+ def put(x):
242
+ try:
243
+ loop.call_soon_threadsafe(q.put_nowait, x)
244
+ except RuntimeError: # the event loop is gone (the server is stopping)
245
+ stop.set()
246
+
247
+ def work():
248
+ try:
249
+ with lock:
250
+ if stop.is_set(): # the client left while this request waited for the NPU
251
+ return
252
+ set_priority(True)
253
+ try:
254
+ for d in pipe.stream_chat(msgs, should_stop=stop.is_set, split_reasoning=True, **kw):
255
+ put(d)
256
+ finally:
257
+ set_priority(False)
258
+ except Exception as e: # surfaced to the client
259
+ put(e)
260
+ finally:
261
+ put(None)
262
+ threading.Thread(target=work, daemon=True).start()
263
+ return q, stop
264
+
265
+
266
+ def reader(q, stop, request=None):
267
+ """-> async function returning start_generation's next item. With request (a whole response, not a stream): a
268
+ client that leaves meanwhile stops the generation (checked about once a second; a stream stops when it closes)."""
269
+ last = [time.monotonic()]
270
+
271
+ async def next_item():
272
+ while request is not None and not stop.is_set():
273
+ wait = last[0] + 1.0 - time.monotonic()
274
+ if wait <= 0:
275
+ last[0] = time.monotonic()
276
+ if await request.is_disconnected():
277
+ stop.set()
278
+ continue
279
+ try:
280
+ return await asyncio.wait_for(q.get(), wait)
281
+ except asyncio.TimeoutError:
282
+ pass
283
+ return await q.get()
284
+ return next_item
285
+
286
+
287
+ MAX_IMAGE = 20 << 20 # bytes
288
+
289
+
290
+ def _public_host(url):
291
+ """True when every address the URL's host resolves to is a public one: the server does not fetch from the local
292
+ network on a client's behalf (ONW_ALLOW_LOCAL_IMAGES=1 allows it)."""
293
+ if os.environ.get("ONW_ALLOW_LOCAL_IMAGES") == "1":
294
+ return True
295
+ host = urllib.parse.urlsplit(url).hostname
296
+ try:
297
+ addrs = {a[4][0] for a in socket.getaddrinfo(host, None)}
298
+ except (socket.gaierror, UnicodeError, TypeError):
299
+ return False
300
+ return bool(addrs) and all(ipaddress.ip_address(a.split("%")[0]).is_global for a in addrs)
301
+
302
+
303
+ def load_image(url):
304
+ try:
305
+ if url.startswith("data:"):
306
+ data = base64.b64decode(url.split(",", 1)[1])
307
+ elif url.startswith(("http://", "https://")):
308
+ if not _public_host(url):
309
+ raise HTTPException(400, "image_url: only public http(s) addresses are fetched (send a data: URL instead)")
310
+ req = urllib.request.Request(url, headers={"User-Agent": f"onw/{__version__}"})
311
+ with urllib.request.urlopen(req, timeout=20) as r:
312
+ data = r.read(MAX_IMAGE + 1)
313
+ if len(data) > MAX_IMAGE:
314
+ raise HTTPException(400, "image_url: the image is larger than 20 MB")
315
+ else:
316
+ raise HTTPException(400, "image_url must be a data: URL or http(s) URL")
317
+ return Image.open(io.BytesIO(data)).convert("RGB")
318
+ except HTTPException:
319
+ raise
320
+ except Exception as e: # bad base64, not an image, unreachable URL
321
+ raise HTTPException(400, f"image_url: {type(e).__name__}: {e}")
322
+
323
+
324
+ def to_hf_messages(messages):
325
+ """OpenAI chat messages -> HF chat format with PIL images."""
326
+ out = []
327
+ for m in tool_calls.history(messages):
328
+ keep = {k: m[k] for k in ("tool_calls", "tool_call_id", "name", "reasoning_content") if m.get(k) is not None}
329
+ c = m.get("content")
330
+ if c is None or isinstance(c, str):
331
+ out.append({"role": m["role"], "content": c or "", **keep})
332
+ continue
333
+ parts = []
334
+ for p in c:
335
+ if p.get("type") == "text":
336
+ parts.append({"type": "text", "text": p.get("text") or ""})
337
+ elif p.get("type") == "image_url":
338
+ iu = p.get("image_url") or {}
339
+ url = iu.get("url") if isinstance(iu, dict) else iu
340
+ if not isinstance(url, str):
341
+ raise HTTPException(400, "image_url part without a url")
342
+ parts.append({"type": "image", "image": load_image(url)})
343
+ else:
344
+ raise HTTPException(400, f"unsupported content part type: {p.get('type')}")
345
+ out.append({"role": m["role"], "content": parts, **keep})
346
+ return out
347
+
348
+
349
+ def _num(body, key, default, cast=float):
350
+ v = body.get(key)
351
+ if v is None:
352
+ return default
353
+ try:
354
+ return cast(v)
355
+ except (TypeError, ValueError):
356
+ raise HTTPException(400, f"{key} must be a number")
357
+
358
+
359
+ LEVELS = ("off", "low", "medium", "high") # the thinking levels of the window and the chat page (reasoning_effort)
360
+ _PREFS = [None, None] # (config mtime, this model's defaults)
361
+
362
+
363
+ def model_defaults():
364
+ """This model's defaults from the onw window (config model_prefs, read again when the file changes - no restart):
365
+ {"think": level, "sampling": "greedy" | "recommended" | "custom", values...}; {} when none were saved. Settings
366
+ of earlier versions (ONW_THINK_DEFAULT, ONW_API_SAMPLING) apply where these say nothing."""
367
+ key = os.environ.get("ONW_MODEL_KEY")
368
+ if not key:
369
+ return {}
370
+ try:
371
+ from onw import app as _app
372
+ p = os.path.join(_app.config_dir(), "config.json")
373
+ mt = os.path.getmtime(p)
374
+ if _PREFS[0] != mt:
375
+ _PREFS[:] = [mt, (_app.load_config().get("model_prefs") or {}).get(key) or {}]
376
+ return _PREFS[1]
377
+ except (OSError, ValueError):
378
+ return {}
379
+
380
+
381
+ def default_think():
382
+ """Thinking for a request that says nothing about it: the model's saved level, else the old switch."""
383
+ lvl = model_defaults().get("think")
384
+ if lvl == "off":
385
+ return False
386
+ if lvl in LEVELS:
387
+ return lvl
388
+ return True if os.environ.get("ONW_THINK_DEFAULT") == "1" else None
389
+
390
+
391
+ def default_sampling():
392
+ """Sampling for a request that sends none: the model's saved choice, else the old setting (API_SAMPLING)."""
393
+ d = model_defaults()
394
+ mode = d.get("sampling")
395
+ if mode == "greedy":
396
+ return None
397
+ if mode == "recommended":
398
+ return {k: v for k, v in (info.get("sampling") or {}).items() if k != "source"} or None
399
+ if mode == "custom":
400
+ return {k: d[k] for k in ("temperature", "top_p", "top_k", "presence_penalty") if k in d}
401
+ return API_SAMPLING
402
+
403
+
404
+ def gen_args(body):
405
+ """OpenAI request fields -> ChatEngine.stream_chat arguments."""
406
+ if _num(body, "n", 1, int) != 1:
407
+ raise HTTPException(400, "n > 1 is not supported (one NPU pipeline)")
408
+ stop = body.get("stop") or []
409
+ mx = body.get("max_completion_tokens", body.get("max_tokens"))
410
+ # a request without max_tokens: the setting (ONW_MAX_TOKENS; 0 = up to the end of the context - the engine stops
411
+ # there anyway); agents send none and write whole files in one answer
412
+ if mx is None:
413
+ mx = int(os.environ.get("ONW_MAX_TOKENS", "8192") or 0) or (1 << 30)
414
+ else:
415
+ mx = _num({"max_tokens": mx}, "max_tokens", 512, int)
416
+ if mx < 1:
417
+ raise HTTPException(400, "max_tokens must be at least 1")
418
+ think = (body.get("chat_template_kwargs") or {}).get("enable_thinking")
419
+ effort = body.get("reasoning_effort")
420
+ if isinstance(effort, str): # a level: low / medium / high (gpt-oss, llm-jp: trained;
421
+ think = False if effort in ("none", "minimal", "off") else effort # others: a thinking budget)
422
+ if think is None: # nothing said: the window's default for this model
423
+ think = default_think()
424
+ tools, choice = body.get("tools") or None, body.get("tool_choice")
425
+ if choice == "none":
426
+ tools = None
427
+ elif isinstance(choice, dict) and tools: # a named function: offer just that one
428
+ name = (choice.get("function") or {}).get("name")
429
+ tools = [t for t in tools if (t.get("function") or {}).get("name") == name] or tools
430
+ # what the request leaves out: greedy, or the settings' API defaults (API_SAMPLING; thinking has its own set)
431
+ dflt = dict(default_sampling() or {})
432
+ if think and isinstance(dflt.get("think"), dict):
433
+ dflt.update(dflt["think"])
434
+ return dict(max_new_tokens=mx, temperature=_num(body, "temperature", dflt.get("temperature", 0.0)),
435
+ top_p=_num(body, "top_p", dflt.get("top_p", 1.0)),
436
+ stop=[stop] if isinstance(stop, str) else list(stop), seed=body.get("seed"), think=think,
437
+ top_k=_num(body, "top_k", dflt.get("top_k", 0), int), repetition_penalty=_num(body, "repetition_penalty", 1.0),
438
+ presence_penalty=_num(body, "presence_penalty", dflt.get("presence_penalty", 0.0)),
439
+ frequency_penalty=_num(body, "frequency_penalty", 0.0), tools=tools)
440
+
441
+
442
+ def with_format(msgs, body):
443
+ """response_format json_object / json_schema: best effort - an instruction as a system message (the output is not
444
+ constrained)."""
445
+ rf = body.get("response_format") or {}
446
+ kind = rf.get("type") if isinstance(rf, dict) else None
447
+ if kind not in ("json_object", "json_schema"):
448
+ return msgs
449
+ note = "Respond with a single JSON object only, no other text."
450
+ if kind == "json_schema":
451
+ note += " It must follow this JSON schema: " + json.dumps((rf.get("json_schema") or {}).get("schema") or {},
452
+ ensure_ascii=False)
453
+ if msgs and msgs[0]["role"] == "system" and isinstance(msgs[0]["content"], str):
454
+ return [dict(msgs[0], content=msgs[0]["content"] + "\n\n" + note)] + msgs[1:]
455
+ return [{"role": "system", "content": note}] + msgs
456
+
457
+
458
+ @app.get("/")
459
+ def index(request: Request):
460
+ base = str(request.base_url).rstrip("/")
461
+ return {"object": "onw.server", "name": "onw - 俺のNPUがこんなに動くわけない", "version": __version__, "model": MODEL_ID,
462
+ "openai_base_url": base + "/v1", "api_key_required": bool(API_KEY),
463
+ "endpoints": {"models": "/v1/models", "chat_completions": "/v1/chat/completions", "health": "/health",
464
+ "chat_ui": "/chat", "check_page": "/check", "info": "/api/info"},
465
+ "context": info.get("max_context"), "vision": info.get("vision")}
466
+
467
+
468
+ @app.get("/health")
469
+ def health():
470
+ return {"status": "ok", "model": MODEL_ID}
471
+
472
+
473
+ @app.get("/chat")
474
+ def chat_ui():
475
+ return HTMLResponse(i18n.page(os.path.join(WEB, "index.html")))
476
+
477
+
478
+ @app.get("/check")
479
+ def check_page():
480
+ return HTMLResponse(i18n.page(os.path.join(WEB, "check.html")))
481
+
482
+
483
+ @app.get("/api/info")
484
+ def api_info():
485
+ # the window's defaults for this model, read now (they change without a restart)
486
+ d, lvl = model_defaults(), default_think()
487
+ samp = default_sampling()
488
+ mode = d.get("sampling") or ("custom" if API_SAMPLING and os.environ.get("ONW_API_SAMPLING") != "recommended"
489
+ else "recommended" if API_SAMPLING else "greedy")
490
+ return {**info, "api_sampling": samp, "api_sampling_mode": mode,
491
+ "think_default": "off" if lvl is False else ("medium" if lvl is True else lvl)}
492
+
493
+
494
+ STARTED = int(time.time())
495
+
496
+
497
+ def _model_card():
498
+ return {"id": MODEL_ID, "object": "model", "created": STARTED, "owned_by": "local-npu"}
499
+
500
+
501
+ @app.get("/v1/models")
502
+ def models():
503
+ return {"object": "list", "data": [_model_card()]}
504
+
505
+
506
+ @app.get("/v1/models/{model_id:path}")
507
+ def model(model_id: str):
508
+ if model_id != MODEL_ID:
509
+ raise HTTPException(404, f"model {model_id!r} not found (this server runs {MODEL_ID!r})")
510
+ return _model_card()
511
+
512
+
513
+ def finalize(text, tools, finish):
514
+ """The answer's end: tool calls parsed out of the text (OpenAI tool_calls), finish_reason "tool_calls" when there
515
+ are any. -> (text, calls, finish_reason)."""
516
+ if not tools:
517
+ return text, [], finish
518
+ text, calls = tool_calls.parse(text, tools)
519
+ return text, calls, ("tool_calls" if calls else finish)
520
+
521
+
522
+ def usage_of(st):
523
+ return {"prompt_tokens": st["prompt_tokens"], "completion_tokens": st["completion_tokens"],
524
+ "total_tokens": st["prompt_tokens"] + st["completion_tokens"],
525
+ "prompt_tokens_details": {"cached_tokens": st.get("cached_tokens", 0)},
526
+ # (OpenAI's field: how many of the completion tokens were thinking)
527
+ "completion_tokens_details": {"reasoning_tokens": st.get("reasoning_tokens", 0)}}
528
+
529
+
530
+ def stats_of(st):
531
+ return {k: (round(v, 2) if isinstance(v, float) else v) for k, v in st.items() if k != "finish_reason"}
532
+
533
+
534
+ def worker_error(e):
535
+ """A generation error -> (HTTP status, OpenAI error type): the request's fault (a prompt too long, an image for a
536
+ text-only model) or the server's (the NPU, the driver)."""
537
+ return (400, "invalid_request_error") if isinstance(e, (ValueError, KeyError, TypeError)) else (500, "server_error")
538
+
539
+
540
+ @app.post("/v1/chat/completions")
541
+ async def chat(request: Request):
542
+ try:
543
+ body = await request.json()
544
+ except ValueError:
545
+ raise HTTPException(400, "the request body is not valid JSON")
546
+ if not isinstance(body, dict) or not isinstance(body.get("messages"), list):
547
+ raise HTTPException(400, "messages (a list) is required")
548
+ kw = gen_args(body)
549
+ loop = asyncio.get_running_loop()
550
+ try: # images may be downloaded: not on the event loop
551
+ msgs = await loop.run_in_executor(None, to_hf_messages, body["messages"])
552
+ except (KeyError, TypeError, AttributeError, ValueError) as e:
553
+ raise HTTPException(400, f"malformed messages: {e}")
554
+ msgs = with_format(msgs, body)
555
+ cid, created = f"chatcmpl-{uuid.uuid4().hex[:24]}", int(time.time())
556
+ tools = kw["tools"]
557
+ if body.get("stream"):
558
+ def chunk(delta, finish=None, **extra):
559
+ d = {"id": cid, "object": "chat.completion.chunk", "created": created, "model": MODEL_ID,
560
+ "choices": [{"index": 0, "delta": delta, "logprobs": None, "finish_reason": finish}], **extra}
561
+ return f"data: {json.dumps(d, ensure_ascii=False)}\n\n"
562
+
563
+ async def events():
564
+ full, sent = "", 0 # with tools: text is held back once a call may start
565
+ # (started here, not before the response: one whose stream never began would generate on to max_tokens)
566
+ q, stop = start_generation(msgs, kw)
567
+ next_item = reader(q, stop)
568
+ try:
569
+ yield chunk({"role": "assistant", "content": ""})
570
+ while True:
571
+ d = await next_item()
572
+ if d is None:
573
+ break
574
+ if isinstance(d, Exception):
575
+ status, typ = worker_error(d)
576
+ err = {"error": {"message": str(d), "type": typ, "param": None,
577
+ "code": "context_length_exceeded" if TOO_LONG.match(str(d)) else None}}
578
+ yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
579
+ elif isinstance(d, dict) and "reasoning" in d:
580
+ yield chunk({"reasoning_content": d["reasoning"]})
581
+ elif isinstance(d, dict):
582
+ rest, calls, finish = finalize(full, tools, d["finish_reason"])
583
+ if tools and rest.startswith(full[:sent]) and rest[sent:].strip(): # text after the calls
584
+ yield chunk({"content": rest[sent:]})
585
+ for i, tc in enumerate(calls):
586
+ yield chunk({"tool_calls": [dict(tc, index=i)]})
587
+ yield chunk({}, finish, onw_stats=stats_of(d))
588
+ if (body.get("stream_options") or {}).get("include_usage"): # OpenAI: its own last chunk
589
+ yield f"data: {json.dumps({'id': cid, 'object': 'chat.completion.chunk', 'created': created, 'model': MODEL_ID, 'choices': [], 'usage': usage_of(d)})}\n\n"
590
+ elif tools:
591
+ full += d
592
+ upto = tool_calls.safe_upto(full)
593
+ if upto > sent:
594
+ yield chunk({"content": full[sent:upto]})
595
+ sent = upto
596
+ else:
597
+ yield chunk({"content": d})
598
+ yield "data: [DONE]\n\n"
599
+ finally: # client disconnected or finished: stop the NPU loop so the lock is released
600
+ stop.set()
601
+ return StreamingResponse(events(), media_type="text/event-stream",
602
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
603
+
604
+ parts, thought, st = [], [], None
605
+ q, stop = start_generation(msgs, kw)
606
+ next_item = reader(q, stop, request) # (a client that leaves stops the generation)
607
+ try:
608
+ while True:
609
+ d = await next_item()
610
+ if d is None:
611
+ break
612
+ if isinstance(d, Exception):
613
+ status, typ = worker_error(d)
614
+ return oai_error(status, str(d), typ)
615
+ if isinstance(d, dict) and "reasoning" in d:
616
+ thought.append(d["reasoning"])
617
+ elif isinstance(d, dict):
618
+ st = d
619
+ else:
620
+ parts.append(d)
621
+ finally:
622
+ stop.set()
623
+ if st is None:
624
+ return oai_error(500, "the generation ended without a result", "server_error")
625
+ text, calls, finish = finalize("".join(parts), tools, st["finish_reason"])
626
+ msg = {"role": "assistant", "content": (text.strip() or None) if tools else text}
627
+ if thought:
628
+ msg["reasoning_content"] = "".join(thought)
629
+ if calls:
630
+ msg["tool_calls"] = calls
631
+ return JSONResponse({
632
+ "id": cid, "object": "chat.completion", "created": created, "model": MODEL_ID,
633
+ "choices": [{"index": 0, "message": msg, "logprobs": None, "finish_reason": finish}],
634
+ "usage": usage_of(st),
635
+ "onw_stats": stats_of(st),
636
+ })
637
+
638
+
639
+ def recommended_sampling(arch, engine_dir=None):
640
+ """The chat page's starting sampling values ({temperature, top_p, top_k, presence_penalty?, think?, source}) or
641
+ None (greedy): the model's own generation_config.json first (Gemma 4, Granite 4.2), else onw/sampling.json by
642
+ model_type prefix. The API itself stays greedy unless asked."""
643
+ keys = ("temperature", "top_p", "top_k")
644
+ try:
645
+ with open(os.path.join(engine_dir, "generation_config.json"), encoding="utf8") as f:
646
+ g = json.load(f)
647
+ if g.get("do_sample") and any(k in g for k in keys):
648
+ return {"source": "model", "temperature": g.get("temperature", 1.0), "top_p": g.get("top_p", 1.0),
649
+ "top_k": g.get("top_k", 0)}
650
+ except (OSError, ValueError, TypeError):
651
+ pass
652
+ try:
653
+ with open(os.path.join(WEB, "..", "sampling.json"), encoding="utf8") as f:
654
+ table = {k: v for k, v in json.load(f).items() if not k.startswith("_")}
655
+ except (OSError, ValueError):
656
+ return None
657
+ hit = max((k for k in table if arch.startswith(k)), key=len, default=None)
658
+ if hit is None:
659
+ return None
660
+ return {k: v for k, v in table[hit].items() if k != "note"}
661
+
662
+
663
+ from onw import compat # noqa: E402,F401 (/v1/messages and /v1/responses on this app)
664
+
665
+
666
+ def set_priority(busy):
667
+ """Windows: the server's priority class - normal while loading and waiting, above normal while it generates
668
+ (busy). Windows puts a below-normal process on the low-power cores at low clocks (onw 1.4.5 and earlier started
669
+ the server below normal: Lunar Lake, Qwen3.5-9B NF4 with MTP ~12 tok/s, 16 above normal); generating takes one or two
670
+ cores, so the rest of the PC hardly notices. ONW_PRIORITY: the class while generating, normal | above (default) |
671
+ off (leave it as started)."""
672
+ want = os.environ.get("ONW_PRIORITY", "above")
673
+ if os.name != "nt" or want not in ("normal", "above"):
674
+ return
675
+ import ctypes
676
+ k32 = ctypes.WinDLL("kernel32")
677
+ k32.GetCurrentProcess.restype = ctypes.c_void_p
678
+ k32.SetPriorityClass.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
679
+ k32.SetPriorityClass(k32.GetCurrentProcess(), 0x8000 if busy and want == "above" else 0x20)
680
+
681
+
682
+ def main(argv=None):
683
+ global pipe, MODEL_ID, API_KEY, API_SAMPLING
684
+ ap = argparse.ArgumentParser(prog="onw serve", description="chat UI + OpenAI-compatible API on the Intel NPU")
685
+ ap.add_argument("model", help="onw model folder, or a Hugging Face repo id (downloaded into ./models)")
686
+ ap.add_argument("--host", default="127.0.0.1")
687
+ ap.add_argument("--port", type=int, default=8000)
688
+ ap.add_argument("--device", default="NPU", help="run every component on this device (CPU works without an NPU driver)")
689
+ ap.add_argument("--name", help="model id / title (default: engine directory name)")
690
+ ap.add_argument("--context", type=int, default=None,
691
+ help="context length in tokens (default 32768, at most the model's maximum); no recompile")
692
+ ap.add_argument("--open", action="store_true", help="open the chat UI in the default browser once loaded")
693
+ ap.add_argument("--no-pld", action="store_true", help="disable prompt lookup decoding")
694
+ ap.add_argument("--api-key", help="require this key as a Bearer token on /v1 from other machines "
695
+ "(e.g. with --host 0.0.0.0)")
696
+ a = ap.parse_args(argv)
697
+ API_KEY = a.api_key or os.environ.get("ONW_API_KEY") or None
698
+ from onw.hub import resolve
699
+ a.engine = resolve(a.model)
700
+ import openvino as ov
701
+ from . import npukit
702
+ from onw.runtime import npu_info
703
+ print(f"[onw] {__version__} | OpenVINO {ov.__version__} | {npu_info(npukit.core())}", flush=True)
704
+ if a.device == "NPU" and "NPU" not in npukit.core().available_devices:
705
+ print("WARNING: no NPU found (is the NPU driver installed?) - running everything on the CPU instead.", flush=True)
706
+ a.device = "CPU"
707
+ from onw.chat import ChatEngine
708
+ pipe = ChatEngine(a.engine, a.device, context=a.context, pld=not a.no_pld)
709
+ set_priority(False)
710
+ MODEL_ID = a.name or os.path.basename(os.path.normpath(a.engine))
711
+ v = pipe.vision
712
+ info.update(model=MODEL_ID, title=MODEL_ID, devices={"lm": a.device}, fast=False, vision=bool(v),
713
+ image_px=v.get("image_px") if v else None,
714
+ think=pipe.think_style is not None, max_context=pipe.max_context,
715
+ always_thinks=pipe.think_style == "harmony", # (gpt-oss, llm-jp: the thinking counts against max_tokens)
716
+ note=f"文脈は約{pipe.max_context}トークン", context=pipe.max_context, pld=pipe.pld,
717
+ mtp=getattr(pipe.model, "mtp", None) is not None)
718
+ try:
719
+ with open(os.path.join(a.engine, "config.json"), encoding="utf8") as f:
720
+ arch = json.load(f).get("model_type") or ""
721
+ except (OSError, ValueError):
722
+ arch = ""
723
+ # greedy Gemma on the NPU can fall into repeating one word (fp16 on the NPU tips a near tie; the same happens with
724
+ # any onw version): the pages suggest a repetition penalty, the API default stays 1.0 (OpenAI behaviour)
725
+ info.update(arch=arch, rep_hint=1.2 if arch.startswith("gemma") else None, sampling=recommended_sampling(arch, a.engine))
726
+ want = os.environ.get("ONW_API_SAMPLING")
727
+ if want == "recommended":
728
+ API_SAMPLING = {k: v for k, v in (info["sampling"] or {}).items() if k != "source"} or None
729
+ elif want:
730
+ try:
731
+ API_SAMPLING = json.loads(want)
732
+ except ValueError:
733
+ API_SAMPLING = None
734
+ info.update(api_sampling=API_SAMPLING, api_sampling_mode="recommended" if want == "recommended" else
735
+ "custom" if API_SAMPLING else "greedy")
736
+ if API_SAMPLING:
737
+ print(f"[api] requests without sampling values use {API_SAMPLING}", flush=True)
738
+ if a.open:
739
+ import webbrowser
740
+ threading.Timer(1.5, webbrowser.open, [f"http://{'localhost' if a.host in ('0.0.0.0', '127.0.0.1') else a.host}:{a.port}/chat"]).start()
741
+ print(f"\n OpenAI API: http://localhost:{a.port}/v1 (model id: {MODEL_ID})"
742
+ f"\n Chat UI: http://localhost:{a.port}/chat\n Check page: http://localhost:{a.port}/check\n", flush=True)
743
+ uvicorn.run(app, host=a.host, port=a.port, log_level="warning")
744
+
745
+
746
+ if __name__ == "__main__":
747
+ # (python -m onw.server: run the module that onw.compat put its routes on - this one is a second copy of it)
748
+ from onw.server import main as _main
749
+ _main()
onw/update.py CHANGED
@@ -86,8 +86,27 @@ def apply(tag, progress=lambda text: None):
86
  d = install_dir()
87
  if d is None:
88
  raise RuntimeError("this onw is not an installer-made install (update it with git / hf instead)")
89
- info = json.loads(_get(f"https://huggingface.co/api/models/{REPO}/revision/{tag}"))
90
  files = [s["rfilename"] for s in info["siblings"] if s["rfilename"] != ".gitattributes"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  stage = os.path.join(d, ".update")
92
  shutil.rmtree(stage, ignore_errors=True)
93
 
@@ -118,6 +137,12 @@ def apply(tag, progress=lambda text: None):
118
  break
119
  except PermissionError:
120
  if i == 9:
 
 
 
 
 
 
121
  raise
122
  time.sleep(0.5)
123
  # modules the new version no longer has: stale code must not be imported by accident
 
86
  d = install_dir()
87
  if d is None:
88
  raise RuntimeError("this onw is not an installer-made install (update it with git / hf instead)")
89
+ info = json.loads(_get(f"https://huggingface.co/api/models/{REPO}/revision/{tag}?blobs=true"))
90
  files = [s["rfilename"] for s in info["siblings"] if s["rfilename"] != ".gitattributes"]
91
+ # [3.0] the NPU kit of the other system is not downloaded (Windows: the DLLs, Linux: npukit/linux)
92
+ if os.name == "nt":
93
+ files = [f for f in files if not f.startswith("onw/npukit/linux/")]
94
+ else:
95
+ files = [f for f in files if not (f.startswith("onw/npukit/") and f.endswith(".dll"))]
96
+ # [3.0] large (LFS) files that are already here unchanged - the NPU kit's ~92 MB - are not downloaded again
97
+ lfs = {s["rfilename"]: s["lfs"] for s in info["siblings"] if s.get("lfs")}
98
+
99
+ def same(f):
100
+ meta, dst = lfs.get(f), os.path.join(d, f)
101
+ if not meta or not os.path.exists(dst) or os.path.getsize(dst) != meta.get("size"):
102
+ return False
103
+ import hashlib
104
+ h = hashlib.sha256()
105
+ with open(dst, "rb") as fh:
106
+ for chunk in iter(lambda: fh.read(1 << 22), b""):
107
+ h.update(chunk)
108
+ return h.hexdigest() == meta.get("sha256")
109
+ files = [f for f in files if not same(f)]
110
  stage = os.path.join(d, ".update")
111
  shutil.rmtree(stage, ignore_errors=True)
112
 
 
137
  break
138
  except PermissionError:
139
  if i == 9:
140
+ if f.lower().endswith(".dll") and os.path.exists(dst):
141
+ # a DLL loaded by a running onw process (the NPU kit): it can be renamed, not replaced -
142
+ # moved aside (removed at the next start, see npukit) and the new one put in its place
143
+ os.replace(dst, f"{dst}.old-{os.getpid()}-{int(time.time())}")
144
+ os.replace(os.path.join(stage, f), dst)
145
+ break
146
  raise
147
  time.sleep(0.5)
148
  # modules the new version no longer has: stale code must not be imported by accident
pyproject.toml CHANGED
@@ -4,13 +4,14 @@ build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "onw"
7
- version = "1.3.0"
8
  description = "Ore no NPU ga konna ni ugoku wake nai - LLMs entirely on the Intel NPU"
9
  readme = "README.md"
10
  license = {text = "Apache-2.0"}
11
  requires-python = ">=3.10"
12
  # openvino: from https://storage.openvinotoolkit.org/simple/wheels/pre-release until 2026.5 is on PyPI
13
- dependencies = ["openvino>=2026.5.0b1,<2026.6", "transformers>=5.5.4,<6", "numpy>=1.26", "pillow", "fastapi", "uvicorn",
 
14
  "huggingface_hub>=1.0,<2", "jinja2"]
15
 
16
  [project.optional-dependencies]
@@ -24,4 +25,4 @@ onw = "onw.__main__:main"
24
  include = ["onw*"]
25
 
26
  [tool.setuptools.package-data]
27
- onw = ["web/*"]
 
4
 
5
  [project]
6
  name = "onw"
7
+ version = "3.0.0"
8
  description = "Ore no NPU ga konna ni ugoku wake nai - LLMs entirely on the Intel NPU"
9
  readme = "README.md"
10
  license = {text = "Apache-2.0"}
11
  requires-python = ">=3.10"
12
  # openvino: from https://storage.openvinotoolkit.org/simple/wheels/pre-release until 2026.5 is on PyPI
13
+ # [3.0] exactly the OpenVINO build onw's NPU kit (onw/npukit) is made for (2026.5.0b1 = 2026.5.0-23160-6db412d0a27)
14
+ dependencies = ["openvino==2026.5.0b1", "transformers>=5.5.4,<6", "numpy>=1.26", "pillow", "fastapi", "uvicorn",
15
  "huggingface_hub>=1.0,<2", "jinja2"]
16
 
17
  [project.optional-dependencies]
 
25
  include = ["onw*"]
26
 
27
  [tool.setuptools.package-data]
28
+ onw = ["web/*", "npukit/*", "npukit/patches/*", "npukit/linux/*"]
requirements.txt CHANGED
@@ -1,7 +1,7 @@
1
  # Same dependencies as pyproject.toml (pip install --pre -e . is what start.bat / start.sh do).
2
  # OpenVINO is a pre-release build (2026.5.0b1, from OpenVINO's pre-release index):
3
  # pip install -r requirements.txt --extra-index-url https://storage.openvinotoolkit.org/simple/wheels/pre-release
4
- openvino>=2026.5.0b1,<2026.6
5
  transformers>=5.5.4,<6
6
  numpy>=1.26
7
  pillow
 
1
  # Same dependencies as pyproject.toml (pip install --pre -e . is what start.bat / start.sh do).
2
  # OpenVINO is a pre-release build (2026.5.0b1, from OpenVINO's pre-release index):
3
  # pip install -r requirements.txt --extra-index-url https://storage.openvinotoolkit.org/simple/wheels/pre-release
4
+ openvino==2026.5.0b1
5
  transformers>=5.5.4,<6
6
  numpy>=1.26
7
  pillow