Skip to content

Commit 119e684

Browse files
physicsrobclaude
andcommitted
OnnxTokenModule + load_onnx front door; repl slims to a front-end; delete token_model
- onnx_load.py gains OnnxTokenModule (token-I/O counterpart of OnnxHeadlessModule: same static-cache protocol, plus vocab tokenize/detokenize and an argmax generate loop ported from repl) and load_onnx(path), which dispatches on the sidecar format key. - repl.py drops its private _Vocab/_Model/_load/generate quartet and loads via load_onnx (it now depends on torch via onnx_load; the old standalone/no-torch docstring claim is gone). run_once/run_repl signatures unchanged; root onnx_repl.py untouched. - token_model.py (compile_token/CompiledToken) deleted — zero call sites in torchwright or torchwright_doom. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 396a927 commit 119e684

4 files changed

Lines changed: 290 additions & 444 deletions

File tree

tests/compile/forward/test_module.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ def test_token_onnx_decode_step_matches_full_prefill():
203203

204204

205205
def test_token_onnx_autoregressive_1digit():
206-
from torchwright.compiler.repl import _load, generate
206+
from torchwright.compiler.onnx_load import load_onnx
207207

208208
output_node, pos_encoding, embedding = _build_1digit()
209209

@@ -219,10 +219,10 @@ def test_token_onnx_autoregressive_1digit():
219219
verbose=False,
220220
)
221221

222-
model = _load(onnx_path)
222+
model = load_onnx(onnx_path)
223223
test_cases = [("1+1\n", "2"), ("2+3\n", "5"), ("4+5\n", "9")]
224224
for input_str, expected in test_cases:
225-
result = "".join(generate(model, input_str))
225+
result = "".join(model.generate(input_str))
226226
assert (
227227
result == expected
228228
), f"{input_str}: expected {expected!r}, got {result!r}"
@@ -234,7 +234,7 @@ def test_token_onnx_autoregressive_1digit():
234234

235235

236236
def test_token_onnx_autoregressive_3digit():
237-
from torchwright.compiler.repl import _load, generate
237+
from torchwright.compiler.onnx_load import load_onnx
238238

239239
output_node, pos_encoding, embedding = create_network_parts()
240240

@@ -250,7 +250,7 @@ def test_token_onnx_autoregressive_3digit():
250250
verbose=False,
251251
)
252252

253-
model = _load(onnx_path)
253+
model = load_onnx(onnx_path)
254254
test_cases = [
255255
("1+2\n", "3"),
256256
("12+34\n", "46"),
@@ -259,7 +259,7 @@ def test_token_onnx_autoregressive_3digit():
259259
("456+123\n", "579"),
260260
]
261261
for input_str, expected in test_cases:
262-
result = "".join(generate(model, input_str))
262+
result = "".join(model.generate(input_str))
263263
assert (
264264
result == expected
265265
), f"{input_str}: expected {expected!r}, got {result!r}"
@@ -271,7 +271,7 @@ def test_token_onnx_autoregressive_3digit():
271271

272272

273273
def test_token_onnx_sidecar_schema_and_metadata():
274-
from torchwright.compiler.repl import _load
274+
from torchwright.compiler.onnx_load import OnnxTokenModule, load_onnx
275275

276276
output_node, pos_encoding, embedding = _build_1digit()
277277

@@ -293,7 +293,8 @@ def test_token_onnx_sidecar_schema_and_metadata():
293293
assert meta["format"] == TOKEN_META_FORMAT
294294
assert meta["vocab"] == embedding.tokenizer.vocab
295295

296-
model = _load(onnx_path)
296+
model = load_onnx(onnx_path)
297+
assert isinstance(model, OnnxTokenModule)
297298
assert model.n_layers > 0
298299
assert len(model.per_layer_n_heads) == model.n_layers
299300
assert all(nh <= D // D_HEAD for nh in model.per_layer_n_heads)

torchwright/compiler/onnx_load.py

Lines changed: 265 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1-
"""Runtime loader for headless ONNX models.
1+
"""Runtime loaders for torchwright ONNX exports.
22
3-
Provides ``OnnxHeadlessModule`` — an ``onnxruntime``-backed callable
4-
that speaks the static-cache prefill/decode protocol produced by
5-
:func:`torchwright.compiler.export.compile_headless_to_onnx` — plus a
6-
:class:`HeadlessRuntime` :class:`typing.Protocol` that describes the
7-
shared interface with the in-memory
3+
:func:`load_onnx` is the front door: it reads the ``<stem>.meta.json``
4+
sidecar and dispatches on its format key to one of two loaders, both
5+
``onnxruntime``-backed callables speaking the static-cache
6+
prefill/decode protocol:
7+
8+
- ``OnnxHeadlessModule`` — float I/O (``torchwright.headless.v1``),
9+
produced by :func:`torchwright.compiler.export.compile_headless_to_onnx`.
10+
- ``OnnxTokenModule`` — token I/O (``torchwright.token.v1``), produced
11+
by :func:`torchwright.compiler.export.compile_to_onnx`; adds the
12+
vocab tokenizer and an argmax :meth:`OnnxTokenModule.generate` loop.
13+
14+
A :class:`HeadlessRuntime` :class:`typing.Protocol` describes the
15+
interface shared with the in-memory
816
:class:`torchwright.compiler.export.CompiledHeadless`.
917
1018
Two usage shapes:
@@ -23,19 +31,24 @@
2331
import json
2432
import os
2533
from dataclasses import dataclass
26-
from typing import List, Optional, Protocol, Tuple, Union
34+
from typing import Iterator, List, Optional, Protocol, Tuple, Union
2735

2836
import numpy as np
2937
import torch
3038

31-
from torchwright.compiler.export import HEADLESS_META_FORMAT, meta_path_for
39+
from torchwright.compiler.export import (
40+
HEADLESS_META_FORMAT,
41+
TOKEN_META_FORMAT,
42+
meta_path_for,
43+
)
3244

3345

3446
def discover_cache_stride(inputs: dict, sidecar_stride, onnx_path) -> int:
3547
"""Resolve the full static slot count ``S`` for a cached-protocol model.
3648
37-
Shared by all three loaders (``OnnxHeadlessModule``, ``repl``, and
38-
torchwright_doom's ``OnnxTokenRuntime``). Dual path:
49+
Shared by all three loaders (``OnnxHeadlessModule``,
50+
``OnnxTokenModule``, and torchwright_doom's ``OnnxTokenRuntime``).
51+
Dual path:
3952
4053
- ``past_K_0`` first dim is a static int — a pre-bucketing export
4154
(old compile caches): use it. Such models can only bind the full
@@ -287,3 +300,245 @@ def __call__(self, inputs: torch.Tensor) -> torch.Tensor:
287300

288301
def eval(self) -> "OnnxHeadlessModule":
289302
return self
303+
304+
305+
class OnnxTokenModule:
306+
"""Loads a token-I/O cached ONNX model and exposes it as a callable.
307+
308+
The token counterpart of :class:`OnnxHeadlessModule`: same static-cache
309+
protocol, but the sequence input is ``token_ids`` (int64) instead of a
310+
float row, the sequence output is ``logits``, and the sidecar carries
311+
the vocab — so the module can tokenize/detokenize and run an argmax
312+
:meth:`generate` loop directly.
313+
314+
Args:
315+
onnx_path: Path to the ``.onnx`` file. A sidecar
316+
``<stem>.meta.json`` with format ``torchwright.token.v1``
317+
must exist alongside it.
318+
providers: ``onnxruntime`` execution providers list. Defaults
319+
to CPU.
320+
"""
321+
322+
def __init__(self, onnx_path: str, providers=None) -> None:
323+
import onnxruntime as ort
324+
325+
meta_path = meta_path_for(onnx_path)
326+
if not os.path.exists(meta_path):
327+
raise FileNotFoundError(
328+
f"Missing sidecar {meta_path}. Re-export with "
329+
f"compile_to_onnx to produce it."
330+
)
331+
with open(meta_path) as f:
332+
meta = json.load(f)
333+
334+
fmt = meta.get("format")
335+
if fmt != TOKEN_META_FORMAT:
336+
raise ValueError(
337+
f"{meta_path}: unexpected format {fmt!r}, "
338+
f"expected {TOKEN_META_FORMAT!r}"
339+
)
340+
self.vocab: List[str] = list(meta["vocab"])
341+
self._token_to_id = {t: i for i, t in enumerate(self.vocab)}
342+
self.metadata: dict = dict(meta.get("extra") or {})
343+
344+
self._session = ort.InferenceSession(
345+
onnx_path,
346+
providers=providers or ["CPUExecutionProvider"],
347+
)
348+
349+
# KV cache topology discovery — identical to OnnxHeadlessModule
350+
# (the two exports share the cached-protocol K/V plumbing).
351+
inputs = {inp.name: inp for inp in self._session.get_inputs()}
352+
self._n_layers = sum(1 for name in inputs if name.startswith("past_K_"))
353+
assert (
354+
self._n_layers > 0
355+
), f"{onnx_path}: no past_K_* inputs — is this a cached-protocol model?"
356+
self._cache_stride = discover_cache_stride(
357+
inputs, meta.get("cache_stride"), onnx_path
358+
)
359+
self._per_layer_n_heads = [
360+
int(inputs[f"past_K_{i}"].shape[1]) for i in range(self._n_layers)
361+
]
362+
self._d_head = int(inputs["past_K_0"].shape[2])
363+
364+
self._out_names = ["logits"]
365+
for i in range(self._n_layers):
366+
self._out_names += [f"delta_K_{i}", f"delta_V_{i}"]
367+
368+
@property
369+
def cache_stride(self) -> int:
370+
"""The static slot count ``S`` baked into the loaded model."""
371+
return self._cache_stride
372+
373+
@property
374+
def n_layers(self) -> int:
375+
return self._n_layers
376+
377+
@property
378+
def per_layer_n_heads(self) -> List[int]:
379+
return list(self._per_layer_n_heads)
380+
381+
@property
382+
def d_head(self) -> int:
383+
return self._d_head
384+
385+
def token_to_id(self, token: str) -> int:
386+
return self._token_to_id.get(token, 0) # 0 = <unk>
387+
388+
def id_to_token(self, token_id: int) -> str:
389+
if 0 <= token_id < len(self.vocab):
390+
return self.vocab[token_id]
391+
return self.vocab[0]
392+
393+
def empty_past(self) -> OnnxPast:
394+
"""Full-S zero-filled sequence-major cache buffers, length 0.
395+
396+
Zero (not garbage) is load-bearing: slots beyond the committed
397+
length are read by the attention with weight exactly 0.0, and
398+
``0 * NaN = NaN``.
399+
"""
400+
S = self._cache_stride
401+
past_K = tuple(
402+
torch.zeros(S, nh, self._d_head) for nh in self._per_layer_n_heads
403+
)
404+
past_V = tuple(
405+
torch.zeros(S, nh, self._d_head) for nh in self._per_layer_n_heads
406+
)
407+
return OnnxPast(k=past_K, v=past_V, length=0)
408+
409+
def step(
410+
self,
411+
token_ids: torch.Tensor,
412+
past: OnnxPast,
413+
past_len: Optional[int] = None,
414+
) -> Tuple[torch.Tensor, OnnxPast]:
415+
"""Run one cached-protocol call and return (logits, new_past).
416+
417+
Args:
418+
token_ids: ``(n_new,)`` int64 tensor of token ids.
419+
past: :class:`OnnxPast` from a prior step or
420+
:meth:`empty_past`.
421+
past_len: Optional explicit base position for the new rows.
422+
When ``None`` (default), uses ``past.length``. Must equal
423+
the committed cache length (see
424+
:meth:`OnnxHeadlessModule.step`).
425+
426+
Returns:
427+
``(logits, new_past)`` where ``logits`` is a
428+
``(n_new, vocab_size)`` torch tensor and ``new_past`` shares
429+
the (in-place updated) buffers with ``past`` at the advanced
430+
committed length.
431+
"""
432+
if not isinstance(past, OnnxPast):
433+
raise TypeError(
434+
"OnnxTokenModule.step requires an OnnxPast from empty_past() "
435+
f"(got {type(past).__name__}) — the static-cache protocol has "
436+
"no growable-tuple representation"
437+
)
438+
assert len(past.k) == self._n_layers
439+
assert len(past.v) == self._n_layers
440+
441+
base = past.length if past_len is None else int(past_len)
442+
assert base == past.length, (
443+
f"past_len {base} != committed length {past.length}: the static "
444+
f"cache derives mask AND pos from cache_position; a trimmed cache "
445+
f"with a larger absolute position is not expressible"
446+
)
447+
n_new = int(token_ids.shape[0])
448+
if base + n_new > self._cache_stride:
449+
raise RuntimeError(
450+
f"static cache overrun: length {base} + n_new {n_new} exceeds "
451+
f"cache_stride {self._cache_stride}; re-export with a larger "
452+
f"cache_stride"
453+
)
454+
455+
ids_np = token_ids.detach().cpu().numpy().astype(np.int64, copy=False)
456+
feeds: dict = {
457+
"token_ids": ids_np,
458+
"cache_position": np.arange(base, base + n_new, dtype=np.int64),
459+
}
460+
for i in range(self._n_layers):
461+
feeds[f"past_K_{i}"] = (
462+
past.k[i].detach().cpu().numpy().astype(np.float32, copy=False)
463+
)
464+
feeds[f"past_V_{i}"] = (
465+
past.v[i].detach().cpu().numpy().astype(np.float32, copy=False)
466+
)
467+
468+
results = self._session.run(self._out_names, feeds)
469+
logits = torch.from_numpy(results[0])
470+
471+
# Persist the per-layer deltas (new rows only) into the owned slots.
472+
for i in range(self._n_layers):
473+
past.k[i][base : base + n_new] = torch.from_numpy(results[1 + 2 * i])
474+
past.v[i][base : base + n_new] = torch.from_numpy(results[1 + 2 * i + 1])
475+
476+
return logits, OnnxPast(k=past.k, v=past.v, length=base + n_new)
477+
478+
def __call__(self, token_ids: torch.Tensor) -> torch.Tensor:
479+
"""Convenience: stateless prefill that discards the cache.
480+
481+
Equivalent to ``self.step(token_ids, self.empty_past())[0]``.
482+
"""
483+
logits, _ = self.step(token_ids, self.empty_past())
484+
return logits
485+
486+
def generate(
487+
self,
488+
input_text: str,
489+
max_new_tokens: int = 10,
490+
bos_token: str = "<bos",
491+
eos_token: str = "<eos>",
492+
) -> Iterator[str]:
493+
"""Autoregressive argmax generation over the cached protocol.
494+
495+
Prefills ``[bos_token] + list(input_text)``, then decodes one
496+
token per :meth:`step`, yielding each generated token string as
497+
it is produced. Stops at ``eos_token`` or ``max_new_tokens``.
498+
"""
499+
tokens = [bos_token] + list(input_text)
500+
ids = torch.tensor(
501+
[self.token_to_id(t) for t in tokens], dtype=torch.int64
502+
)
503+
504+
logits, past = self.step(ids, self.empty_past())
505+
for _ in range(max_new_tokens):
506+
next_id = int(logits[-1].argmax())
507+
next_token = self.id_to_token(next_id)
508+
if next_token == eos_token:
509+
break
510+
yield next_token
511+
logits, past = self.step(
512+
torch.tensor([next_id], dtype=torch.int64), past
513+
)
514+
515+
def eval(self) -> "OnnxTokenModule":
516+
return self
517+
518+
519+
def load_onnx(
520+
onnx_path: str, providers=None
521+
) -> Union[OnnxHeadlessModule, OnnxTokenModule]:
522+
"""Load any torchwright ONNX export, dispatching on its sidecar format.
523+
524+
Reads ``<stem>.meta.json`` next to ``onnx_path`` and returns an
525+
:class:`OnnxHeadlessModule` (``torchwright.headless.v1``) or an
526+
:class:`OnnxTokenModule` (``torchwright.token.v1``).
527+
"""
528+
meta_path = meta_path_for(onnx_path)
529+
if not os.path.exists(meta_path):
530+
raise FileNotFoundError(
531+
f"Missing sidecar {meta_path}. Re-export with compile_to_onnx / "
532+
f"compile_headless_to_onnx to produce it."
533+
)
534+
with open(meta_path) as f:
535+
meta = json.load(f)
536+
fmt = meta.get("format")
537+
if fmt == HEADLESS_META_FORMAT:
538+
return OnnxHeadlessModule(onnx_path, providers=providers)
539+
if fmt == TOKEN_META_FORMAT:
540+
return OnnxTokenModule(onnx_path, providers=providers)
541+
raise ValueError(
542+
f"{meta_path}: unknown format {fmt!r}; expected "
543+
f"{HEADLESS_META_FORMAT!r} or {TOKEN_META_FORMAT!r}"
544+
)

0 commit comments

Comments
 (0)