|
1 | | -"""Runtime loader for headless ONNX models. |
| 1 | +"""Runtime loaders for torchwright ONNX exports. |
2 | 2 |
|
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 |
8 | 16 | :class:`torchwright.compiler.export.CompiledHeadless`. |
9 | 17 |
|
10 | 18 | Two usage shapes: |
|
23 | 31 | import json |
24 | 32 | import os |
25 | 33 | from dataclasses import dataclass |
26 | | -from typing import List, Optional, Protocol, Tuple, Union |
| 34 | +from typing import Iterator, List, Optional, Protocol, Tuple, Union |
27 | 35 |
|
28 | 36 | import numpy as np |
29 | 37 | import torch |
30 | 38 |
|
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 | +) |
32 | 44 |
|
33 | 45 |
|
34 | 46 | def discover_cache_stride(inputs: dict, sidecar_stride, onnx_path) -> int: |
35 | 47 | """Resolve the full static slot count ``S`` for a cached-protocol model. |
36 | 48 |
|
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: |
39 | 52 |
|
40 | 53 | - ``past_K_0`` first dim is a static int — a pre-bucketing export |
41 | 54 | (old compile caches): use it. Such models can only bind the full |
@@ -287,3 +300,245 @@ def __call__(self, inputs: torch.Tensor) -> torch.Tensor: |
287 | 300 |
|
288 | 301 | def eval(self) -> "OnnxHeadlessModule": |
289 | 302 | 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