ryugyosoft commited on
Commit
e3fc7be
·
verified ·
1 Parent(s): 5937810

onw 0.10.5: the compile cache prunes itself after a load; attention graphs in onw blob store

Browse files
CHANGELOG.md CHANGED
@@ -5,6 +5,15 @@
5
  日付は日本時間です。各版は Hugging Face の `ryugyosoft/onw` にタグ(`v0.4.2` 以降)付きで残っています。
6
  onw ウィンドウは、新しい版があるとこのファイルから変更点を表示します(`## v…` の見出しで区切っています)。
7
 
 
 
 
 
 
 
 
 
 
8
  ## v0.10.4 (2026-09-29)
9
  - **Lunar Lake:0.10.3 で NPUW のコンパイルは通るのに読み込みが終わらない(最初の推論でプロセスが落ちる)問題を修正**。
10
  NPUW の重みバンクはコンパイル後もモデルの .bin を参照し続けるのに、読み込み後に解放していました。.bin はメモリに
 
5
  日付は日本時間です。各版は Hugging Face の `ryugyosoft/onw` にタグ(`v0.4.2` 以降)付きで残っています。
6
  onw ウィンドウは、新しい版があるとこのファイルから変更点を表示します(`## v…` の見出しで区切っています)。
7
 
8
+ ## v0.10.5 (2026-09-29)
9
+ - **コンパイルキャッシュの自動掃除**。モデルを読み込んだあと、その読み込みで使わなかったコンパイル結果(別の経路・
10
+ 別のブロックサイズ・以前の版の切り分け方のもの)を `npu_cache` から消します。NPUW を使わない機種では OpenVINO 側の
11
+ キャッシュ(コンパイル確認の子プロセスや旧版が残していた複製)も空にします。手元では Gemma 4 の 32.6 GB → 8.1 GB、
12
+ LFM2-8B の 2.0 GB → 0.9 GB。`ONW_CACHE_PRUNE=0` で止められます。
13
+ - アテンションのグラフも onw 自身の blob 置き場(`npu_cache/onw_blobs/attn_*`)に保存するように(OpenVINO のキャッシュを
14
+ onw のもので占めないため)。コンパイル確認の子プロセスも、onw の blob に書き出すときは OpenVINO のキャッシュに複製を
15
+ 残しません。
16
+
17
  ## v0.10.4 (2026-09-29)
18
  - **Lunar Lake:0.10.3 で NPUW のコンパイルは通るのに読み込みが終わらない(最初の推論でプロセスが落ちる)問題を修正**。
19
  NPUW の重みバンクはコンパイル後もモデルの .bin を参照し続けるのに、読み込み後に解放していました。.bin はメモリに
CHANGELOG_en.md CHANGED
@@ -4,6 +4,14 @@
4
 
5
  Dates are JST. Every version is on Hugging Face `ryugyosoft/onw`, tagged from `v0.4.2` on.
6
 
 
 
 
 
 
 
 
 
7
  ## v0.10.4 (2026-09-29)
8
  - **Lunar Lake: with 0.10.3 the NPUW compile finished but the load never did (the process died at the first
9
  inference).** NPUW's weights bank keeps reading the model's .bin after compiling, and onw released it after loading.
 
4
 
5
  Dates are JST. Every version is on Hugging Face `ryugyosoft/onw`, tagged from `v0.4.2` on.
6
 
7
+ ## v0.10.5 (2026-09-29)
8
+ - **The compile cache cleans itself.** After a model is loaded, the compiled files that load did not use (other
9
+ routes, other block sizes, earlier versions of the splitting) are removed from `npu_cache`; on machines without NPUW
10
+ OpenVINO's own cache (duplicates left by the compile probes and older versions) is emptied too. Here: Gemma 4
11
+ 32.6 -> 8.1 GB, LFM2-8B 2.0 -> 0.9 GB. `ONW_CACHE_PRUNE=0` turns it off.
12
+ - The attention graphs go to onw's own blob store as well (`npu_cache/onw_blobs/attn_*`), and a compile probe that
13
+ exports to onw's store no longer leaves a copy in OpenVINO's cache.
14
+
15
  ## v0.10.4 (2026-09-29)
16
  - **Lunar Lake: with 0.10.3 the NPUW compile finished but the load never did (the process died at the first
17
  inference).** NPUW's weights bank keeps reading the model's .bin after compiling, and onw released it after loading.
TECHNICAL.md CHANGED
@@ -81,6 +81,9 @@ NPU は形の決まったグラフしか扱えませんが、MoE はトークン
81
  で拒否するので、`NPUW_ONLINE_PIPELINE=REP`(繰り返しブロック。部品の中の Sliding 層を折り畳む)でコンパイルし、
82
  だめなら NONE(1 サブグラフ)、通常コンパイルの順に落ちます。LM ヘッドは NONE。NPUW の重みバンクはコンパイル後も
83
  モデルの .bin を参照するので、.bin は mmap で持ってモデルと同じ寿命にしています(0.10.4)。
 
 
 
84
 
85
  ## Gated DeltaNet(ハイブリッドモデル)を NPU で動かす
86
 
 
81
  で拒否するので、`NPUW_ONLINE_PIPELINE=REP`(繰り返しブロック。部品の中の Sliding 層を折り畳む)でコンパイルし、
82
  だめなら NONE(1 サブグラフ)、通常コンパイルの順に落ちます。LM ヘッドは NONE。NPUW の重みバンクはコンパイル後も
83
  モデルの .bin を参照するので、.bin は mmap で持ってモデルと同じ寿命にしています(0.10.4)。
84
+ 読み込みが終わると、その読み込みで取り込まなかった `onw_blobs/seg*` を消し、NPUW を使わない機種では OpenVINO 側の
85
+ キャッシュ(`npu_cache/*.blob`。onw の blob は全部自前の置き場にあるので不要)も空にします(0.10.5、`ONW_CACHE_PRUNE=0`
86
+ で無効)。アテンションのグラフと画像エンコーダーは必要になったときにコンパイルするので、掃除の対象外です。
87
 
88
  ## Gated DeltaNet(ハイブリッドモデル)を NPU で動かす
89
 
TECHNICAL_en.md CHANGED
@@ -80,6 +80,9 @@ NPUW's default online partitioner (and REG / COMPUTE / SPATIAL) rejects the spli
80
  so they compile with `NPUW_ONLINE_PIPELINE=REP` (repeated blocks: the sliding layers inside a part get folded), falling
81
  back to NONE (one subgraph) and then a plain compile. The LM head goes NONE. NPUW's weights bank keeps reading the
82
  model's .bin after compiling, so the .bin is a file-backed mapping that lives as long as the model (0.10.4).
 
 
 
83
 
84
  ## Gated DeltaNet (hybrid models) on the NPU
85
 
 
80
  so they compile with `NPUW_ONLINE_PIPELINE=REP` (repeated blocks: the sliding layers inside a part get folded), falling
81
  back to NONE (one subgraph) and then a plain compile. The LM head goes NONE. NPUW's weights bank keeps reading the
82
  model's .bin after compiling, so the .bin is a file-backed mapping that lives as long as the model (0.10.4).
83
+ After a load, the `onw_blobs/seg*` files it did not import are removed, and on machines without NPUW so is OpenVINO's
84
+ own cache (`npu_cache/*.blob`: every blob onw needs is in its own store there) - 0.10.5, `ONW_CACHE_PRUNE=0` disables
85
+ it. The attention graphs and the vision encoder compile on demand and are left alone.
86
 
87
  ## Gated DeltaNet (hybrid models) on the NPU
88
 
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__ = "0.10.4"
 
2
 
3
  A small engine that runs LLMs entirely on the Intel NPU: static segment graphs + host-managed state.
4
  """
5
+ __version__ = "0.10.5"
onw/kvcache.py CHANGED
@@ -11,6 +11,7 @@ Layout (measured fastest on NPU 3720): per block one K tensor [kv, C, D] and one
11
  INT8 in NPU host memory (bound zero-copy); the per-token scales sit outside the matmuls (K's on the scores, V's on the
12
  probabilities), so each head is a plain 2-D matmul against "weights".
13
  """
 
14
  import numpy as np
15
  import openvino as ov
16
  import openvino.opset13 as ops
@@ -77,10 +78,11 @@ class _Layer:
77
 
78
 
79
  class PagedKV:
80
- def __init__(self, core, device, layers, capacity, config=None, bits=8):
81
  """layers: {name: dict(kv, D, Dv, scale, G, owner)} - owner: the layer whose cache it reads (shared KV).
82
- bits: 8 (INT8 with per-token scales) or 16 (f16, scales of 1)."""
83
- self.core, self.device, self.config = core, device, config or {}
 
84
  self.kt = ov.Type.f16 if bits == 16 else ov.Type.i8
85
  self.capacity = capacity
86
  self.ctx = core.get_default_context(device) if device == "NPU" else None
@@ -146,7 +148,27 @@ class PagedKV:
146
  key = (R, C, L.kv, L.D, L.Dv, L.scale, str(self.kt))
147
  if key not in self.graphs:
148
  m = attention_graph(R, C, L.kv, L.D, L.Dv, L.scale, self.kt)
149
- self.graphs[key] = self.core.compile_model(m, self.device, self.config).create_infer_request()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  return self.graphs[key]
151
 
152
  def attend(self, name, q, pos, S, n, window=None):
 
11
  INT8 in NPU host memory (bound zero-copy); the per-token scales sit outside the matmuls (K's on the scores, V's on the
12
  probabilities), so each head is a plain 2-D matmul against "weights".
13
  """
14
+ import io, os
15
  import numpy as np
16
  import openvino as ov
17
  import openvino.opset13 as ops
 
78
 
79
 
80
  class PagedKV:
81
+ def __init__(self, core, device, layers, capacity, config=None, bits=8, blobs=None):
82
  """layers: {name: dict(kv, D, Dv, scale, G, owner)} - owner: the layer whose cache it reads (shared KV).
83
+ bits: 8 (INT8 with per-token scales) or 16 (f16, scales of 1). blobs: (cache dir, machine key) to keep the
84
+ compiled attention graphs in onw's own blob store (npu_cache/onw_blobs/attn_*) instead of OpenVINO's cache."""
85
+ self.core, self.device, self.config, self.blobs = core, device, config or {}, blobs
86
  self.kt = ov.Type.f16 if bits == 16 else ov.Type.i8
87
  self.capacity = capacity
88
  self.ctx = core.get_default_context(device) if device == "NPU" else None
 
148
  key = (R, C, L.kv, L.D, L.Dv, L.scale, str(self.kt))
149
  if key not in self.graphs:
150
  m = attention_graph(R, C, L.kv, L.D, L.Dv, L.scale, self.kt)
151
+ if self.blobs and self.device == "NPU":
152
+ from .runtime import blob_path, import_blob
153
+ bp = blob_path(self.blobs[0], m.get_friendly_name() + f"_Dv{L.Dv}", [self.blobs[1], *key])
154
+ cfg = {k: v for k, v in self.config.items() if k != "CACHE_DIR"}
155
+ cm = None
156
+ if os.path.exists(bp):
157
+ try:
158
+ cm = import_blob(self.core, bp, self.device, cfg)
159
+ except RuntimeError:
160
+ os.remove(bp)
161
+ if cm is None:
162
+ cm = self.core.compile_model(m, self.device, cfg)
163
+ os.makedirs(os.path.dirname(bp), exist_ok=True)
164
+ buf = io.BytesIO()
165
+ cm.export_model(buf)
166
+ with open(bp + ".tmp", "wb") as f:
167
+ f.write(buf.getbuffer())
168
+ os.replace(bp + ".tmp", bp)
169
+ self.graphs[key] = cm.create_infer_request()
170
+ else:
171
+ self.graphs[key] = self.core.compile_model(m, self.device, self.config).create_infer_request()
172
  return self.graphs[key]
173
 
174
  def attend(self, name, q, pos, S, n, window=None):
onw/runtime.py CHANGED
@@ -140,6 +140,38 @@ def blob_path(cache_dir, name, parts):
140
  return os.path.join(cache_dir, "onw_blobs", f"{os.path.splitext(os.path.basename(name))[0]}-{h.hexdigest()[:16]}.blob")
141
 
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  def import_blob(core, path, device, cfg):
144
  """Import a compiled blob we wrote ourselves. Unlike a CACHE_DIR hit - which OpenVINO keeps in memory next to the
145
  driver's own copy, so it could be exported again - an imported blob is released once the driver has loaded it:
@@ -369,10 +401,15 @@ class SegmentedModel:
369
  print(f"[segments] compiled {sum(len(v) for v in self.segs.values())} in {time.time()-t0:.1f}s", flush=True)
370
  self.kv = None
371
  if kv_dims:
 
 
372
  self.kv = PagedKV(core, os.environ.get("ONW_KV_DEVICE", device), kv_dims, self.T,
373
  {k: v for k, v in (config or {}).items() if k == "CACHE_DIR"},
374
- bits=int(os.environ.get("ONW_KV_BITS", "8")))
375
  print(f"[kv] paged INT8 cache for {len(kv_dims)} attention layers, up to {self.T} tokens", flush=True)
 
 
 
376
  self._inputs = {n for lst in self.segs.values() for _, _, names in lst for n in names}
377
  self.state, owner = {}, {}
378
  for req, m, names in self.segs[min(self.segs)]:
@@ -432,6 +469,7 @@ class SegmentedModel:
432
  if own and os.path.exists(bp):
433
  try:
434
  req = import_blob(core, bp, device, nocache).create_infer_request()
 
435
  tried.set(name, how)
436
  return req
437
  except RuntimeError as e:
@@ -441,11 +479,13 @@ class SegmentedModel:
441
  if (how != known or own) and device == "NPU": # compile out of process first (and, own, export there)
442
  tried.set(name, "trying:" + how) # a crash of this process too leaves a mark to skip it next time
443
  tried.save()
444
- # the child keeps CACHE_DIR: an existing OpenVINO cache entry makes this fast (it only exports)
445
- r = _probe(d, name, cfg, npuw, T, export=bp)
 
446
  if r is True and own and os.path.exists(bp):
447
  try:
448
  req = import_blob(core, bp, device, nocache).create_infer_request()
 
449
  if how != known:
450
  print(f"[compile] {name}: {how}", flush=True)
451
  tried.set(name, how)
@@ -460,7 +500,7 @@ class SegmentedModel:
460
  err = RuntimeError(f"{name}: {how}: {r}")
461
  continue
462
  try:
463
- req = core.compile_model(model, device, cfg).create_infer_request()
464
  if how != known:
465
  print(f"[compile] {name}: {how}", flush=True)
466
  tried.set(name, how)
@@ -512,6 +552,7 @@ class SegmentedModel:
512
  if own and os.path.exists(bp):
513
  try:
514
  req = import_blob(core, bp, device, nocache).create_infer_request()
 
515
  tried.set(pname, how)
516
  return req
517
  except RuntimeError:
@@ -526,6 +567,7 @@ class SegmentedModel:
526
  with open(bp + ".tmp", "wb") as f:
527
  f.write(buf.getbuffer())
528
  os.replace(bp + ".tmp", bp)
 
529
  if how != known:
530
  print(f"[compile] {pname}: {how}", flush=True)
531
  tried.set(pname, how)
 
140
  return os.path.join(cache_dir, "onw_blobs", f"{os.path.splitext(os.path.basename(name))[0]}-{h.hexdigest()[:16]}.blob")
141
 
142
 
143
+ USED_BLOBS = set() # onw_blobs files this process imported or wrote (see prune_cache)
144
+
145
+
146
+ def prune_cache(cache_dir, plain_only):
147
+ """Drop compiled files a load did not use: onw_blobs/seg* not in USED_BLOBS (other routes, other block sizes,
148
+ earlier versions of the splitting), and - when every route is plain, so nothing of ours lives in OpenVINO's own
149
+ cache - the *.blob entries in the cache root (compile probes and older versions left them). Attention graphs and
150
+ the vision encoder (compiled later, on demand) are keyed by the machine and left alone."""
151
+ gone, n, busy = 0, 0, 0
152
+ stale = []
153
+ try:
154
+ blobs = os.path.join(cache_dir, "onw_blobs")
155
+ stale += [os.path.join(blobs, f) for f in os.listdir(blobs) if f.startswith("seg") or f.endswith(".tmp")]
156
+ if plain_only:
157
+ stale += [os.path.join(cache_dir, f) for f in os.listdir(cache_dir) if f.endswith(".blob")]
158
+ except OSError:
159
+ return
160
+ for p in stale:
161
+ if p in USED_BLOBS or not os.path.isfile(p):
162
+ continue
163
+ try:
164
+ size = os.path.getsize(p)
165
+ os.chmod(p, 0o666) # OpenVINO writes its cache entries read-only
166
+ os.remove(p)
167
+ gone, n = gone + size, n + 1
168
+ except OSError: # mapped by another process (a server still running on it)
169
+ busy += 1
170
+ if n or busy:
171
+ print(f"[cache] removed {n} unused compiled files ({gone / 2**30:.1f} GB)"
172
+ + (f", {busy} in use by another process" if busy else ""), flush=True)
173
+
174
+
175
  def import_blob(core, path, device, cfg):
176
  """Import a compiled blob we wrote ourselves. Unlike a CACHE_DIR hit - which OpenVINO keeps in memory next to the
177
  driver's own copy, so it could be exported again - an imported blob is released once the driver has loaded it:
 
401
  print(f"[segments] compiled {sum(len(v) for v in self.segs.values())} in {time.time()-t0:.1f}s", flush=True)
402
  self.kv = None
403
  if kv_dims:
404
+ cache = (config or {}).get("CACHE_DIR")
405
+ own = device == "NPU" and cache and os.environ.get("ONW_OWN_BLOBS") != "0"
406
  self.kv = PagedKV(core, os.environ.get("ONW_KV_DEVICE", device), kv_dims, self.T,
407
  {k: v for k, v in (config or {}).items() if k == "CACHE_DIR"},
408
+ bits=int(os.environ.get("ONW_KV_BITS", "8")), blobs=(cache, tried.key) if own else None)
409
  print(f"[kv] paged INT8 cache for {len(kv_dims)} attention layers, up to {self.T} tokens", flush=True)
410
+ if device == "NPU" and (config or {}).get("CACHE_DIR") and os.environ.get("ONW_CACHE_PRUNE") != "0":
411
+ # everything this load did not touch is stale (or belongs to a route / version no longer taken)
412
+ prune_cache(config["CACHE_DIR"], plain_only=not npuw and os.environ.get("ONW_OWN_BLOBS") != "0")
413
  self._inputs = {n for lst in self.segs.values() for _, _, names in lst for n in names}
414
  self.state, owner = {}, {}
415
  for req, m, names in self.segs[min(self.segs)]:
 
469
  if own and os.path.exists(bp):
470
  try:
471
  req = import_blob(core, bp, device, nocache).create_infer_request()
472
+ USED_BLOBS.add(bp)
473
  tried.set(name, how)
474
  return req
475
  except RuntimeError as e:
 
479
  if (how != known or own) and device == "NPU": # compile out of process first (and, own, export there)
480
  tried.set(name, "trying:" + how) # a crash of this process too leaves a mark to skip it next time
481
  tried.save()
482
+ # exporting to our store: the child compiles without CACHE_DIR (no second copy in OpenVINO's cache);
483
+ # otherwise it keeps CACHE_DIR and the parent's compile below is a cache hit
484
+ r = _probe(d, name, nocache if own else cfg, npuw, T, export=bp)
485
  if r is True and own and os.path.exists(bp):
486
  try:
487
  req = import_blob(core, bp, device, nocache).create_infer_request()
488
+ USED_BLOBS.add(bp)
489
  if how != known:
490
  print(f"[compile] {name}: {how}", flush=True)
491
  tried.set(name, how)
 
500
  err = RuntimeError(f"{name}: {how}: {r}")
501
  continue
502
  try:
503
+ req = core.compile_model(model, device, nocache if own else cfg).create_infer_request()
504
  if how != known:
505
  print(f"[compile] {name}: {how}", flush=True)
506
  tried.set(name, how)
 
552
  if own and os.path.exists(bp):
553
  try:
554
  req = import_blob(core, bp, device, nocache).create_infer_request()
555
+ USED_BLOBS.add(bp)
556
  tried.set(pname, how)
557
  return req
558
  except RuntimeError:
 
567
  with open(bp + ".tmp", "wb") as f:
568
  f.write(buf.getbuffer())
569
  os.replace(bp + ".tmp", bp)
570
+ USED_BLOBS.add(bp)
571
  if how != known:
572
  print(f"[compile] {pname}: {how}", flush=True)
573
  tried.set(pname, how)
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "onw"
7
- version = "0.10.4"
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"}
 
4
 
5
  [project]
6
  name = "onw"
7
+ version = "0.10.5"
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"}