swactor/apps/pipeline-parallel-inference/pp_tinygrad_worker.py

1676 lines
66 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Pipeline-parallel tinygrad worker — stdin/stdout JSON protocol.
Each worker hosts one *stage* of a pipeline-parallel inference run. The
stage index, total stage count, and the model name are read from
environment variables at startup:
STAGE=0 NUM_STAGES=2 MODEL=llama3.2:1b
The worker runs in one of two modes:
* **stub** (``--stub`` flag or ``PP_WORKER_STUB=1`` env): skips loading
any real model. Stage-0 ops return deterministic pseudo-bf16 hidden
bytes derived from the inputs; the final-stage op returns a
deterministic ``token_id``. ``tokenize`` whitespace-splits and hashes;
``detokenize`` formats the token ids as text. This mode is used by
the Rust actor and integration tests so they never need a GPU.
* **real** (default): loads ``$MODEL`` via ``Transformer.from_gguf`` and
``SimpleTokenizer.from_gguf_kv`` and runs the per-stage forward pass
directly against ``model.blk[start:end]`` (bypassing
``Transformer.forward`` and ``forward_jit``). Hidden state is cast to
float16 on the wire (2 bytes per element); decoded back to float16
on the receiving stage. Stage-1's ``forward_and_sample`` follows the
block range with ``output_norm`` + ``output`` and argmax-samples the
last position's logits.
Protocol — one JSON line in, one JSON line out.
2026-05-20 07:41:30 +00:00
Each op is restricted to the role that owns it (First = stage 0,
Last = stage N-1, Middle = anything in between). Ops invoked on the
wrong role return ``{"error": ...}`` and the worker keeps serving.
First-stage ops (``STAGE == 0``)::
-> {"op": "embed_and_forward", "request_id": <int>,
"tokens": [<int>, ...], "position": <int>}
<- {"request_id": <int>, "hidden_b64": "<base64>", "seq_len": <int>}
-> {"op": "decode_step", "request_id": <int>,
"token_id": <int>, "position": <int>}
<- {"request_id": <int>, "hidden_b64": "<base64>", "seq_len": 1}
2026-05-20 07:41:30 +00:00
-> {"op": "tokenize", "request_id": <int>, "prompt": "<text>"}
<- {"request_id": <int>, "tokens": [<int>, ...]}
Middle-stage op (``0 < STAGE < NUM_STAGES - 1``)::
2026-05-20 07:41:30 +00:00
-> {"op": "forward_range", "request_id": <int>,
"hidden_b64": "<base64>", "position": <int>, "seq_len": <int>}
2026-05-20 07:41:30 +00:00
<- {"request_id": <int>, "hidden_b64": "<base64>", "seq_len": <int>}
2026-05-20 07:41:30 +00:00
Last-stage ops (``STAGE == NUM_STAGES - 1``)::
2026-05-20 07:41:30 +00:00
-> {"op": "forward_and_sample", "request_id": <int>,
"hidden_b64": "<base64>", "position": <int>, "seq_len": <int>}
<- {"request_id": <int>, "token_id": <int>}
-> {"op": "detokenize", "request_id": <int>, "tokens": [<int>, ...]}
<- {"request_id": <int>, "text": "<text>"}
Errors of any kind reply with::
<- {"request_id": <int>?, "error": "<message>"}
and the worker keeps serving. EOF on stdin causes a clean exit 0.
"""
from __future__ import annotations
import argparse
import base64
2026-05-28 07:05:41 +00:00
import functools
import hashlib
2026-05-28 07:05:41 +00:00
import io
import json
import os
import re
2026-05-25 18:19:06 +00:00
import signal
2026-05-28 07:05:41 +00:00
import struct
import sys
2026-05-25 18:19:06 +00:00
import threading
import time
2026-05-28 07:05:41 +00:00
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Sequence
# Stub-mode constants — small so test payloads stay tiny. Both values
# must match what the Rust stub-mode actor tests assume.
STUB_HIDDEN_DIM = 16
STUB_VOCAB_SIZE = 32
BYTES_PER_ELEM = 2 # bf16
# ASCII-only integer; rejects unicode digits and surrounding whitespace.
_INT_RE = re.compile(r"-?[0-9]+")
def compute_layer_range(stage: int, num_stages: int, total_blocks: int) -> tuple[int, int]:
"""Return the half-open ``[start, end)`` block range owned by ``stage``.
The split is ``total_blocks // num_stages`` per stage; the final stage
2026-05-20 07:41:30 +00:00
absorbs any remainder. ``num_stages`` must be ``>= 2`` — the example
does not serve single-node configurations (see
``examples/single-gpu-inference`` for that). Raises ``ValueError``
on out-of-range inputs.
"""
2026-05-20 07:41:30 +00:00
if num_stages < 2:
raise ValueError(f"num_stages must be >= 2, got {num_stages}")
if not (0 <= stage < num_stages):
raise ValueError(f"stage {stage} out of range [0, {num_stages})")
if total_blocks < num_stages:
raise ValueError(
f"total_blocks {total_blocks} cannot be split into {num_stages} stages"
)
k = total_blocks // num_stages
start = stage * k
end = (stage + 1) * k if stage < num_stages - 1 else total_blocks
return start, end
2026-05-28 07:05:41 +00:00
# ggml type tables for the sharded loader: quantized types map to
# (elements_per_block, bytes_per_block); native types map to byte width. These
# mirror tinygrad 0.12.0's ggml_data_to_tensor and let us size each tensor's raw
# byte slice so only the kept weights are copied off disk.
_GGML_QUANT_BLOCK = {2: (32, 18), 3: (32, 20), 8: (32, 34), 12: (256, 144), 14: (256, 210), 39: (32, 17)}
_GGML_NATIVE_ITEMSIZE = {0: 4, 1: 2, 16: 1, 17: 2, 18: 4}
def _ggml_tensor_nbytes(n_elements: int, ggml_type: int) -> int:
"""Raw byte size of an ``n_elements`` ggml tensor of ``ggml_type``."""
if ggml_type in _GGML_NATIVE_ITEMSIZE:
return _GGML_NATIVE_ITEMSIZE[ggml_type] * n_elements
if ggml_type in _GGML_QUANT_BLOCK:
elems_per_block, bytes_per_block = _GGML_QUANT_BLOCK[ggml_type]
return (n_elements // elems_per_block) * bytes_per_block
raise ValueError(f"unsupported ggml type {ggml_type}")
# ─── Sharded download (spec §4.1 / §4.2 / §4.7) ───────────────────────────
#
# The worker fetches only the byte ranges its stage actually needs
# (§4.1), caches the partial GGUF idempotently (§4.2), and emits
# `pp_download_progress` events with bounded latency while downloading
# (§4.7). The cached file is a SPARSE file with the same apparent size
# as the source — kept tensors live at their original byte offsets, so
# `_load_sharded_transformer` opens it unchanged. Filesystem holes
# absorb the non-kept regions so the actual disk usage is
# O(per-stage shard size), not O(full model size).
_PP_DOWNLOAD_READ_CHUNK = 256 * 1024
_PP_HEADER_INITIAL = 1024 * 1024
_PP_HEADER_MAX = 64 * 1024 * 1024
def _pp_round_up(n: int, align: int) -> int:
if align <= 0:
return n
return ((n + align - 1) // align) * align
class _PpHeaderTooShort(Exception):
"""Raised mid-parse when the header buffer ran out — caller grows it."""
def _pp_parse_gguf_header(buf: bytes) -> "tuple[list[tuple[str, tuple, int, int]], int, dict]":
"""Parse a GGUF header from `buf`. Returns (t_infos, data_start, kv).
`t_infos` is a list of ``(name, dims, ggml_type, offset)`` tuples
matching what ``_load_sharded_transformer``'s in-file parser
produces. `data_start` is the absolute byte offset where tensor
data begins. Raises `_PpHeaderTooShort` if the header is larger
than `buf` — caller should re-fetch with a larger buffer.
Format reference: tinygrad 0.12.0's gguf reader. GGUF versions 2
and 3 share the parse shape; the file's u32 version is checked.
"""
bio = io.BytesIO(buf)
def _need(n: int) -> bytes:
start = bio.tell()
out = bio.read(n)
if len(out) != n:
raise _PpHeaderTooShort(f"need {n} bytes at {start}, got {len(out)}")
return out
def _unpack(fmt: str, nbytes: int):
return struct.unpack(fmt, _need(nbytes))[0]
def _read_u32() -> int:
return _unpack("<I", 4)
def _read_i32() -> int:
return _unpack("<i", 4)
def _read_u64() -> int:
return _unpack("<Q", 8)
def _read_str() -> str:
length = _read_u64()
return _need(length).decode("utf-8")
def _read_arr():
elem_type = _read_i32()
count = _read_u64()
return [_readers[elem_type]() for _ in range(count)]
_readers = {
0: lambda: _unpack("<b", 1),
1: lambda: _unpack("<B", 1),
2: lambda: _unpack("<h", 2),
3: lambda: _unpack("<H", 2),
4: _read_u32,
5: _read_i32,
6: lambda: _unpack("<f", 4),
7: lambda: _unpack("<?", 1),
8: _read_str,
9: _read_arr,
10: _read_u64,
11: lambda: _unpack("<q", 8),
12: lambda: _unpack("<d", 8),
}
magic = _need(4)
if magic != b"GGUF":
raise ValueError(f"not a GGUF artifact (magic={magic!r})")
version = _read_i32()
if version not in (2, 3):
raise ValueError(f"unsupported GGUF version {version}")
n_tensors = _read_u64()
n_kv = _read_u64()
kv: "dict[str, object]" = {}
for _ in range(n_kv):
key = _read_str()
typ = _read_i32()
kv[key] = _readers[typ]()
t_infos: "list[tuple[str, tuple, int, int]]" = []
for _ in range(n_tensors):
name = _read_str()
n_dims = _read_u32()
dims = tuple(_read_u64() for _ in range(n_dims))
ggml_type = _read_i32()
offset = _read_u64()
t_infos.append((name, dims, ggml_type, offset))
alignment = int(kv.get("general.alignment", 32))
data_start = _pp_round_up(bio.tell(), alignment)
return t_infos, data_start, kv
def _pp_kept_names(t_infos, stage: int, num_stages: int, kv: dict) -> "set[str]":
"""Return the set of tensor names this stage requires. Matches the
`_kept` predicate inside `_load_sharded_transformer` — both code
paths must agree on the kept set or the loader would try to realize
a tensor whose bytes were not fetched."""
arch = str(kv["general.architecture"])
total_blocks = int(kv[f"{arch}.block_count"])
start, end = compute_layer_range(stage, num_stages, total_blocks)
is_last = stage == num_stages - 1
names = {info[0] for info in t_infos}
tied_output = "output.weight" not in names
kept: "set[str]" = set()
for info in t_infos:
name = info[0]
keep = False
for i in range(start, end):
if name.startswith(f"blk.{i}."):
keep = True
break
if not keep:
if name == "token_embd.weight" and (stage == 0 or (is_last and tied_output)):
keep = True
elif name == "output_norm.weight" and is_last:
keep = True
elif name == "output.weight" and is_last and not tied_output:
keep = True
if keep:
kept.add(name)
return kept
def _pp_kept_byte_ranges(t_infos, data_start: int, kept_names: "set[str]") -> "list[tuple[int, int]]":
"""Return the ``[(absolute_offset, nbytes)]`` byte ranges this stage
keeps, ordered by offset (so a streamed download writes ascending
offsets and the filesystem allocates fewer fragmented holes)."""
out: list[tuple[int, int]] = []
for name, dims, ggml_type, offset in t_infos:
if name not in kept_names:
continue
n_elements = 1
for d in dims:
n_elements *= int(d)
nbytes = _ggml_tensor_nbytes(n_elements, ggml_type)
out.append((data_start + int(offset), nbytes))
out.sort()
return out
def _pp_cache_paths(url: str) -> "tuple[Path, Path, Path]":
"""Return (cache_path, meta_path, partial_path) for ``url``.
Cache root defaults to ``$PP_MODEL_CACHE_DIR`` then ``~/.cache/pp-pipeline``.
The filename is ``<short-url-hash>-<basename>`` so two URLs that share a
basename cannot collide.
"""
raw = os.environ.get("PP_MODEL_CACHE_DIR", "").strip()
cache_root = Path(raw).expanduser() if raw else Path.home() / ".cache" / "pp-pipeline"
cache_root.mkdir(parents=True, exist_ok=True)
url_hash = hashlib.sha256(url.encode("utf-8")).hexdigest()[:16]
basename = os.path.basename(urllib.parse.urlparse(url).path) or "model.gguf"
cache_path = cache_root / f"{url_hash}-{basename}"
meta_path = cache_path.with_name(cache_path.name + ".pp_meta")
partial_path = cache_path.with_name(cache_path.name + ".partial")
return cache_path, meta_path, partial_path
def _pp_head(url: str) -> "tuple[int, str]":
"""HEAD request; return (Content-Length, Accept-Ranges header lowercased)."""
req = urllib.request.Request(url, method="HEAD")
with urllib.request.urlopen(req, timeout=30) as resp:
total = int(resp.headers.get("Content-Length", "0"))
accept = (resp.headers.get("Accept-Ranges") or "").lower()
return total, accept
def _pp_range_get(url: str, start: int, end_inclusive: int) -> bytes:
"""Issue a Range GET; return the body bytes."""
req = urllib.request.Request(
url, headers={"Range": f"bytes={start}-{end_inclusive}"}
)
with urllib.request.urlopen(req, timeout=120) as resp:
return resp.read()
def _pp_emit_progress(stage: int, bytes_done: int, bytes_total: int, started_at_mono: float) -> None:
"""Emit one `pp_download_progress` event with the spec's field set
(§4.7). `started_at_mono` is `time.monotonic()` captured before the
first event so `elapsed_ms` is monotonic across the fetch."""
elapsed_ms = int((time.monotonic() - started_at_mono) * 1000)
mbps = round(((bytes_done * 8) / 1_000_000) / max(elapsed_ms / 1000, 1e-3), 1)
_emit_event(
"pp_download_progress",
stage_index=stage,
bytes_done=bytes_done,
bytes_total=bytes_total,
elapsed_ms=elapsed_ms,
mbps=mbps,
)
def _pp_fingerprint(f, offset: int, nbytes: int) -> str:
"""Hash the first 4KB + last 4KB of a kept tensor (or the whole
tensor if shorter). The §4.2 content-derived check: matches the
fingerprint recorded in the sidecar at download time."""
sample = 4096
f.seek(offset)
head = f.read(min(sample, nbytes))
if nbytes > sample:
f.seek(offset + nbytes - sample)
tail = f.read(sample)
else:
tail = b""
h = hashlib.sha256()
h.update(head)
h.update(tail)
h.update(nbytes.to_bytes(8, "little"))
return h.hexdigest()
def _pp_write_meta(
meta_path: Path,
cache_path: Path,
stage: int,
num_stages: int,
url: str,
total_size: int,
kept_ranges: "list[tuple[int, int]]",
) -> None:
fingerprints = []
with open(cache_path, "rb") as f:
for offset, nbytes in kept_ranges:
fingerprints.append({
"offset": offset,
"nbytes": nbytes,
"fingerprint": _pp_fingerprint(f, offset, nbytes),
})
meta_path.write_text(json.dumps({
"schema": 1,
"url": url,
"stage": stage,
"num_stages": num_stages,
"total_size": total_size,
"kept": fingerprints,
}))
def _pp_verify_cache(
cache_path: Path,
meta_path: Path,
stage: int,
num_stages: int,
) -> bool:
"""Spec §4.2 integrity check: file present at the expected apparent
size AND every recorded fingerprint re-matches the cached bytes.
Returns False on any discrepancy (including missing files, missing
sidecar, mismatched stage / num_stages, size mismatch, or any
fingerprint mismatch). A passing cache is used as-is — no refetch."""
if not cache_path.exists() or not meta_path.exists():
return False
try:
meta = json.loads(meta_path.read_text())
except (OSError, ValueError):
return False
if meta.get("schema") != 1:
return False
if meta.get("stage") != stage or meta.get("num_stages") != num_stages:
return False
if cache_path.stat().st_size != meta.get("total_size"):
return False
kept = meta.get("kept") or []
if not kept:
return False
try:
with open(cache_path, "rb") as f:
for entry in kept:
offset = int(entry["offset"])
nbytes = int(entry["nbytes"])
expected = entry["fingerprint"]
if _pp_fingerprint(f, offset, nbytes) != expected:
return False
except (OSError, KeyError, ValueError):
return False
return True
def _pp_download_sharded(url: str, stage: int, num_stages: int) -> str:
"""Spec §4.1 + §4.2 + §4.7: fetch only this stage's tensor bytes,
cache idempotently, emit progress events.
Returns the path to the on-disk file (sparse — apparent size matches
the source; only the kept ranges occupy disk blocks). On cache hit,
NO `pp_download_progress` events are emitted (spec §4.7).
"""
cache_path, meta_path, partial_path = _pp_cache_paths(url)
# §4.2: orphan-cleanup any leftover .partial from a previous killed
# fetch BEFORE any new fetch is initiated. Emit an event so the
# bundle reader can see that a stale temp was reaped.
if partial_path.exists():
try:
partial_path.unlink()
_emit_event(
"pp_cache_orphan_cleaned",
stage_index=stage,
path=str(partial_path),
)
except OSError:
pass
# §4.2 cache hit — return the cached file as-is.
if _pp_verify_cache(cache_path, meta_path, stage, num_stages):
_emit_event(
"pp_cache_hit",
stage_index=stage,
path=str(cache_path),
)
return str(cache_path)
# §4.1 fail-fast on no-range support.
total_size, accept_ranges = _pp_head(url)
if "bytes" not in accept_ranges:
_emit_event(
"pp_download_failed",
stage_index=stage,
reason="no_byte_range_support",
url=url,
accept_ranges=accept_ranges,
)
_die(f"source does not support byte-range requests: {url}")
if total_size <= 0:
_emit_event(
"pp_download_failed",
stage_index=stage,
reason="no_content_length",
url=url,
)
_die(f"source did not advertise Content-Length: {url}")
# Fetch the header in growing increments until we can parse it.
header_size = min(_PP_HEADER_INITIAL, total_size)
while True:
try:
header_bytes = _pp_range_get(url, 0, header_size - 1)
t_infos, data_start, kv = _pp_parse_gguf_header(header_bytes)
if data_start <= len(header_bytes):
break
# Parser succeeded structurally but data_start sits past our
# buffer — re-fetch enough to include the tensor data start.
header_size = min(data_start + 1024, total_size)
except _PpHeaderTooShort:
new_size = min(header_size * 2, total_size)
if new_size == header_size or new_size > _PP_HEADER_MAX:
_emit_event(
"pp_download_failed",
stage_index=stage,
reason="header_too_large",
header_size=header_size,
)
_die(f"GGUF header exceeded {_PP_HEADER_MAX} bytes")
header_size = new_size
kept_names = _pp_kept_names(t_infos, stage, num_stages, kv)
kept_ranges = _pp_kept_byte_ranges(t_infos, data_start, kept_names)
# bytes_total is the bytes this stage will pull from the network:
# header + kept-tensor regions. Not the full file (spec §4.1 means
# we never fetch the rest).
header_keep_bytes = data_start
kept_bytes_total = sum(nb for _, nb in kept_ranges)
bytes_total = header_keep_bytes + kept_bytes_total
interval_s_raw = os.environ.get("PP_DOWNLOAD_PROGRESS_INTERVAL_SECS", "").strip()
try:
interval_s = float(interval_s_raw) if interval_s_raw else 10.0
except ValueError:
interval_s = 10.0
if interval_s <= 0:
interval_s = 10.0
# Write the sparse output to `.partial`; rename on success. Opening
# with "wb" then truncate(total_size) creates a sparse file on
# Linux: only blocks we actually `write()` allocate disk.
started_at = time.monotonic()
with open(partial_path, "wb") as f:
f.truncate(total_size)
f.seek(0)
f.write(header_bytes[:data_start])
bytes_done = data_start
# Spec §4.7: first event MUST be at start of fetch, AFTER we
# know bytes_total. We have that now.
_pp_emit_progress(stage, bytes_done, bytes_total, started_at)
last_emit = time.monotonic()
for offset, nbytes in kept_ranges:
req = urllib.request.Request(
url,
headers={"Range": f"bytes={offset}-{offset + nbytes - 1}"},
)
try:
resp = urllib.request.urlopen(req, timeout=300)
except Exception as e:
_emit_event(
"pp_download_failed",
stage_index=stage,
reason="range_get_failed",
offset=offset,
nbytes=nbytes,
error=str(e),
)
# Spec §4.7: final event MUST be emitted on fetch failure.
_pp_emit_progress(stage, bytes_done, bytes_total, started_at)
_die(f"range GET failed at offset {offset}: {e}")
try:
f.seek(offset)
while True:
chunk = resp.read(_PP_DOWNLOAD_READ_CHUNK)
if not chunk:
break
f.write(chunk)
bytes_done += len(chunk)
now = time.monotonic()
if now - last_emit >= interval_s:
_pp_emit_progress(stage, bytes_done, bytes_total, started_at)
last_emit = now
finally:
resp.close()
# Spec §4.7: final event at completion.
_pp_emit_progress(stage, bytes_done, bytes_total, started_at)
# Sidecar before rename so a crash between rename + meta-write does
# not leave a "valid file, no sidecar" → would fail _pp_verify and
# refetch. Writing the sidecar first means a crash here leaves
# cache_path absent and partial_path present (which orphan-cleanup
# reaps on next boot).
_pp_write_meta(
meta_path, partial_path, stage, num_stages, url, total_size, kept_ranges
)
os.replace(partial_path, cache_path)
return str(cache_path)
def _load_sharded_transformer(gguf_path, stage: int, num_stages: int, max_context: int = 512):
"""Load only this stage's slice of the model onto the compute device.
Stock ``Transformer.from_gguf`` copies the *entire* GGUF onto the compute
device before any layer runs (it does ``gguf.to(None)``), so an 18 GB model
OOMs a 12 GB GPU no matter how the layers are split. Instead we parse the
GGUF header on the DISK device and copy only the tensors this stage needs —
``blk[start:end]`` plus ``token_embd`` (stage 0) and ``output_norm`` /
``output`` (last stage) — dequantizing each on the compute device. Returns
``(model, kv, start, end)``. Vendored against tinygrad 0.12.0's gguf format.
"""
import io
import struct
import functools
from tinygrad import Tensor, Device, nn
from tinygrad.helpers import prod, round_up, getenv
from tinygrad.nn.state import TensorIO, ggml_data_to_tensor
from tinygrad.apps.llm import Transformer
_t0 = time.monotonic()
2026-05-29 08:55:10 +00:00
gguf = Tensor(Path(gguf_path)) # device is DISK:<path> — nothing is copied to the GPU yet
2026-05-28 07:05:41 +00:00
# --- parse the GGUF header (kv metadata + tensor directory) off disk ---
reader = io.BufferedReader(TensorIO(gguf), 1_000_000)
def _unpack(fmt, nbytes):
return struct.unpack(fmt, reader.read(nbytes))[0]
def _read_str():
return str(reader.read(_read_u64()), "utf-8")
def _read_arr():
elem_reader, count = _readers[_read_i32()], _read_u64()
return [elem_reader() for _ in range(count)]
_readers = {8: _read_str, 9: _read_arr, **{t: functools.partial(_unpack, "<" + f, nb) for t, f, nb in
[(0, "c", 1), (1, "b", 1), (2, "H", 2), (3, "h", 2), (4, "I", 4), (5, "i", 4),
(6, "f", 4), (7, "?", 1), (10, "Q", 8), (11, "q", 8), (12, "d", 8)]}}
_read_u32, _read_i32, _read_u64 = _readers[4], _readers[5], _readers[10]
magic, version = reader.read(4), _read_i32()
n_tensors, n_kv = _read_u64(), _read_u64()
if magic != b"GGUF" or version not in (2, 3):
raise ValueError(f"invalid GGUF (magic={magic!r} version={version})")
kv = {}
for _ in range(n_kv):
key, typ = _read_str(), _read_i32()
kv[key] = _readers[typ]()
t_infos = [(_read_str(), tuple(_read_u64() for _ in range(_read_u32())), _read_i32(), _read_u64())
for _ in range(n_tensors)]
data_start = round_up(reader.tell(), kv.get("general.alignment", 32))
_t_header = time.monotonic()
arch = kv["general.architecture"]
total_blocks = int(kv[f"{arch}.block_count"])
start, end = compute_layer_range(stage, num_stages, total_blocks)
is_last = stage == num_stages - 1
names = {info[0] for info in t_infos}
tied_output = "output.weight" not in names # small models tie output to token_embd
def _kept(name: str) -> bool:
for i in range(start, end):
if name.startswith(f"blk.{i}."):
return True
if name == "token_embd.weight" and (stage == 0 or (is_last and tied_output)):
return True
if name == "output_norm.weight" and is_last:
return True
if name == "output.weight" and is_last and not tied_output:
return True
return False
half, device = getenv("HALF", 1), Device.DEFAULT
state_dict = {}
bytes_copied = 0
kept_count = 0
for name, dims, ggml_type, offset in t_infos:
n_elements = prod(dims)
if _kept(name):
nbytes = _ggml_tensor_nbytes(n_elements, ggml_type)
bytes_copied += nbytes
kept_count += 1
raw = gguf[data_start + offset: data_start + offset + nbytes].to(device)
tensor = ggml_data_to_tensor(raw, n_elements, ggml_type).reshape(*reversed(dims))
if arch == "llama": # interleaved -> half-split RoPE layout (llama-style only)
n_heads, n_kv_heads = kv[f"{arch}.attention.head_count"], kv[f"{arch}.attention.head_count_kv"]
if "attn_q.weight" in name:
tensor = tensor.rearrange("(n h two) d -> (n two h) d", n=n_heads, two=2)
if "attn_k.weight" in name:
tensor = tensor.rearrange("(n h two) d -> (n two h) d", n=n_kv_heads, two=2)
state_dict[name] = tensor.cast("float16") if half else tensor
else:
# DISK-rooted lazy tensor: only its .shape is read (model construction); never realized.
state_dict[name] = ggml_data_to_tensor(gguf[data_start + offset:], n_elements, ggml_type).reshape(*reversed(dims))
if tied_output and is_last:
state_dict["output.weight"] = state_dict["token_embd.weight"]
_t_statedict = time.monotonic()
n_heads = kv[f"{arch}.attention.head_count"]
model = Transformer(
num_blocks=total_blocks, dim=kv[f"{arch}.embedding_length"],
hidden_dim=kv.get(f"{arch}.expert_feed_forward_length", kv[f"{arch}.feed_forward_length"]),
n_heads=n_heads, n_kv_heads=kv[f"{arch}.attention.head_count_kv"],
norm_eps=kv[f"{arch}.attention.layer_norm_rms_epsilon"], vocab_size=len(kv["tokenizer.ggml.tokens"]),
head_dim=kv.get(f"{arch}.attention.key_length", kv[f"{arch}.embedding_length"] // n_heads),
rope_theta=kv[f"{arch}.rope.freq_base"], max_context=max_context,
qk_norm=int(state_dict["blk.0.attn_q_norm.weight"].shape[0]) if "blk.0.attn_q_norm.weight" in state_dict else 0,
num_experts=kv.get(f"{arch}.expert_count", 0), num_experts_per_tok=kv.get(f"{arch}.expert_used_count", 0))
_t_construct = time.monotonic()
# Provide only the kept weights; strict=False leaves the other blocks at their
# (lazy, never-run) init so they never touch the compute device.
kept = {name: tensor for name, tensor in state_dict.items()
if _kept(name) or (tied_output and is_last and name == "output.weight")}
# This is where the kept tensors are actually copied off disk and
# dequantized on the compute device — the dominant load cost.
nn.state.load_state_dict(model, kept, strict=False, verbose=False, consume=True, realize=True)
_t_realize = time.monotonic()
realize_ms = (_t_realize - _t_construct) * 1000
_emit_event(
"model_load_breakdown",
stage=stage,
resident_blocks=end - start,
total_blocks=total_blocks,
kept_tensors=kept_count,
bytes_copied=bytes_copied,
mb_copied=round(bytes_copied / 1_000_000, 1),
header_ms=round((_t_header - _t0) * 1000, 1),
statedict_build_ms=round((_t_statedict - _t_header) * 1000, 1),
construct_ms=round((_t_construct - _t_statedict) * 1000, 1),
realize_ms=round(realize_ms, 1),
realize_mb_per_s=round((bytes_copied / 1_000_000) / max(realize_ms / 1000, 1e-3), 1),
rss_mb=_rss_mb(),
)
return model, kv, start, end
def argmax_sample(logits: Sequence[float]) -> int:
"""Return the index of the maximum element in ``logits``.
On ties, the lowest index wins. Raises ``ValueError`` on empty input.
"""
if len(logits) == 0:
raise ValueError("argmax over empty logits")
best_idx = 0
best_val = logits[0]
for i in range(1, len(logits)):
if logits[i] > best_val:
best_val = logits[i]
best_idx = i
return best_idx
def _stub_hidden_bytes(tokens: Sequence[int], position: int) -> bytes:
"""Deterministic pseudo-bf16 bytes of length ``len(tokens) * hidden_dim * 2``.
Same ``(tokens, position)`` always produce the same bytes; different
inputs almost always differ. Bytes are derived from SHA-256 so the
payload is non-trivial (not all zeros, not monotonic).
"""
seed = hashlib.sha256(
b"pp-stub:"
+ str(position).encode()
+ b"|"
+ b",".join(str(int(t)).encode() for t in tokens)
).digest()
n_bytes = len(tokens) * STUB_HIDDEN_DIM * BYTES_PER_ELEM
out = bytearray()
counter = 0
while len(out) < n_bytes:
out.extend(hashlib.sha256(seed + counter.to_bytes(8, "little")).digest())
counter += 1
return bytes(out[:n_bytes])
2026-05-20 07:41:30 +00:00
def _stub_forward_range_bytes(hidden: bytes, position: int, seq_len: int) -> bytes:
"""Deterministic pseudo-bf16 bytes for a middle stage's ``forward_range``.
Output length is ``seq_len * STUB_HIDDEN_DIM * 2`` (matches the input
hidden's expected length). Same ``(hidden, position, seq_len)`` always
produce the same bytes; differing inputs almost always differ. Used
only in stub mode.
"""
n_bytes = seq_len * STUB_HIDDEN_DIM * BYTES_PER_ELEM
seed = hashlib.sha256(
b"pp-stub-fr:" + position.to_bytes(8, "little", signed=False) + hidden
).digest()
out = bytearray()
counter = 0
while len(out) < n_bytes:
out.extend(hashlib.sha256(seed + counter.to_bytes(8, "little")).digest())
counter += 1
return bytes(out[:n_bytes])
def _stub_token_id(hidden: bytes, position: int) -> int:
"""Deterministic stub token id in ``[0, STUB_VOCAB_SIZE)``."""
digest = hashlib.sha256(
b"pp-stub-sample:" + position.to_bytes(8, "little", signed=False) + hidden
).digest()
return int.from_bytes(digest[:4], "little") % STUB_VOCAB_SIZE
def _parse_env_int(name: str) -> int:
raw = os.environ.get(name, "")
stripped = raw.strip()
if not stripped:
_die(f"env {name} is required")
if not _INT_RE.fullmatch(stripped):
_die(f"env {name}={raw!r} is not an ASCII integer")
return int(stripped)
def _die(msg: str) -> "None":
print(f"pp_tinygrad_worker: {msg}", file=sys.stderr, flush=True)
raise SystemExit(2)
def _write(obj) -> None:
sys.stdout.write(json.dumps(obj) + "\n")
sys.stdout.flush()
2026-05-25 18:19:06 +00:00
# ─── Lifecycle event emission ─────────────────────────────────────────────
#
# The Rust StageActor parses every stdout line as JSON; any line carrying
# `"event": "<kind>"` is re-emitted as `Custom("worker_<kind>")` into the
# diagnostic bundle. The worker subprocess is otherwise opaque to the
# Rust side, so these are the only diagnostic signal the bundle ever sees
# from the Python layer (apart from exit code + stderr tail). We do NOT
# emit a `request_id` on event lines so the actor never confuses an event
# with an op reply.
_WORKER_START_MONOTONIC = time.monotonic()
_REQUESTS_SERVED = 0
def _emit_event(kind: str, **fields) -> None:
"""Emit a structured lifecycle event on stdout. The Rust actor folds
these into the diag bundle as `Custom("worker_<kind>")`."""
payload = {"event": kind, **fields}
try:
sys.stdout.write(json.dumps(payload) + "\n")
sys.stdout.flush()
except Exception:
# Best-effort: never let a logging failure crash the worker.
pass
def _uptime_ms() -> int:
return int((time.monotonic() - _WORKER_START_MONOTONIC) * 1000)
2026-05-28 07:05:41 +00:00
def _wall_ms() -> int:
"""Epoch milliseconds. Lets the bundle align worker events across nodes
and against the orchestrator's vast.ai create/lease timestamps — e.g.
(worker `starting`.wall_ms − instance create_ms) is the image-pull +
container-boot + worker-spawn cost the node can't see itself."""
return int(time.time() * 1000)
2026-05-25 18:19:06 +00:00
def _rss_mb() -> "int | None":
"""Resident-set size in MB, read from /proc/self/status (Linux).
Returns None on non-Linux or when the read fails — the field is
informational, never required."""
try:
with open("/proc/self/status", "r") as f:
for line in f:
if line.startswith("VmRSS:"):
parts = line.split()
# VmRSS: 12345 kB
return int(parts[1]) // 1024
except (OSError, ValueError, IndexError):
pass
return None
def _install_excepthook() -> None:
"""Catch every uncaught exception and emit a structured event before
the interpreter prints the traceback to stderr (which the actor's
ring buffer will also capture)."""
def _hook(exc_type, exc_value, exc_tb):
import traceback
tb_text = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
_emit_event(
"uncaught_exception",
type=exc_type.__name__,
value=str(exc_value),
traceback=tb_text,
uptime_ms=_uptime_ms(),
)
# Preserve the default behaviour so stderr still shows the trace
# (the actor's stderr ring buffer is a belt-and-braces backup).
sys.__excepthook__(exc_type, exc_value, exc_tb)
sys.excepthook = _hook
def _install_signal_handlers() -> None:
"""Emit `signal_received` and exit cleanly on SIGTERM/SIGINT.
SIGKILL and SIGSEGV cannot be caught — the Rust side relies on the
exit code / signal field of the eventual `worker_exited` Custom
event for those."""
def _on_signal(signum, _frame):
try:
name = signal.Signals(signum).name
except ValueError:
name = f"signal-{signum}"
_emit_event(
"signal_received",
signum=signum,
name=name,
uptime_ms=_uptime_ms(),
)
# 128 + signum is the conventional exit code for signal-driven
# termination; matches what /bin/sh reports.
sys.exit(128 + signum)
for s in (signal.SIGTERM, signal.SIGINT):
try:
signal.signal(s, _on_signal)
except (ValueError, OSError):
# Some environments (e.g. non-main thread) don't allow
# signal install — silently skip rather than crash here.
pass
def _start_heartbeat(interval_s: float = 30.0) -> None:
"""Daemon thread emitting `heartbeat` events. Lets the post-processor
distinguish *hung* (heartbeats stop but process alive — no exit event)
from *dead* (no heartbeat AND no exit — likely SIGKILL/SIGSEGV)."""
def _loop():
while True:
time.sleep(interval_s)
_emit_event(
"heartbeat",
uptime_ms=_uptime_ms(),
rss_mb=_rss_mb(),
requests_served=_REQUESTS_SERVED,
)
t = threading.Thread(target=_loop, name="pp-worker-heartbeat", daemon=True)
t.start()
def _is_nonneg_int(x) -> bool:
# ``bool`` is a subclass of ``int`` in Python; reject it explicitly so
# ``{"position": true}`` doesn't sneak through.
return isinstance(x, int) and not isinstance(x, bool) and x >= 0
class _RealModelState:
"""Holds the loaded tinygrad model + tokenizer for real mode.
All tinygrad imports live inside the loader so stub-mode never pays
the import cost. Once loaded, the state is reused for every request.
"""
def __init__(self, model_name: str, stage: int, num_stages: int):
2026-05-25 18:19:06 +00:00
# Import tinygrad lazily so stub-mode never touches it. This is
# the most likely crash site in real mode — emit lifecycle
# events around the import so the bundle records exactly when
# the worker started loading and how long it took.
2026-05-28 07:05:41 +00:00
_emit_event("importing_tinygrad", stage=stage, wall_ms=_wall_ms())
2026-05-25 18:19:06 +00:00
_import_start = time.monotonic()
import numpy as np
2026-05-28 07:05:41 +00:00
from tinygrad import Tensor, Device
from tinygrad.helpers import fetch, getenv
from tinygrad.apps.llm import SimpleTokenizer, models
import_ms = int((time.monotonic() - _import_start) * 1000)
_emit_event("tinygrad_imported", stage=stage, elapsed_ms=import_ms)
# Device + precision context: which backend the shard lands on and the
# toggles (HALF/JIT/BEAM) that dominate load + inference cost. Correlate
# with model_load_breakdown / op events to attribute time to dequant vs
# kernel compile vs steady-state matmul.
2026-05-25 18:19:06 +00:00
_emit_event(
2026-05-28 07:05:41 +00:00
"device",
2026-05-25 18:19:06 +00:00
stage=stage,
2026-05-28 07:05:41 +00:00
default_device=str(Device.DEFAULT),
half=getenv("HALF", 1),
jit=getenv("JIT", 1),
beam=getenv("BEAM", 0),
cuda_visible=os.environ.get("CUDA_VISIBLE_DEVICES"),
2026-05-25 18:19:06 +00:00
)
if model_name not in models:
available = ", ".join(sorted(models.keys()))
_die(f"unknown MODEL {model_name!r}; available: {available}")
url = models[model_name]
2026-05-28 07:05:41 +00:00
_emit_event("fetching_model", stage=stage, model=model_name, url=url, wall_ms=_wall_ms())
print(
f"pp_tinygrad_worker: stage={stage}/{num_stages} fetching {model_name}",
file=sys.stderr,
flush=True,
)
2026-05-25 18:19:06 +00:00
_fetch_start = time.monotonic()
2026-05-28 07:05:41 +00:00
# Spec §4.1: download only this stage's tensor byte ranges.
# Spec §4.2: cache idempotently with an integrity check; a
# complete-and-valid cache MUST NOT trigger a network fetch.
# Spec §4.7: `pp_download_progress` events are emitted by
# `_pp_download_sharded` while the fetch is in progress and
# NEVER on a cache hit. The unused `fetch` import remains as
# documentation of the prior code path; the sharded fetcher
# replaces it.
_ = fetch # silence the linter; kept for the diff reader
gguf_path = _pp_download_sharded(url, stage, num_stages)
fetch_ms = int((time.monotonic() - _fetch_start) * 1000)
# `gguf_bytes` is the file's apparent size (matches the source's
# total_size); actual on-disk usage is O(per-stage shard). Bundle
# readers reading `model_fetched.gguf_bytes` see the same value
# they did before the §4.1 change — the per-stage usage shows up
# in `pp_download_progress.bytes_total` (header + kept ranges).
try:
gguf_bytes = os.path.getsize(gguf_path)
except OSError:
gguf_bytes = 0
fetch_mb = gguf_bytes / 1_000_000
# A near-instant return with a non-zero apparent size means the
# sharded cache hit short-circuited the fetch. Distinguishable
# in the bundle from a real download via the presence (or not)
# of `pp_download_progress` events.
cache_hit = gguf_bytes > 0 and fetch_ms < 2000
download_mb_per_s = None if cache_hit else round(fetch_mb / max(fetch_ms / 1000, 1e-3), 1)
2026-05-25 18:19:06 +00:00
_emit_event(
"model_fetched",
stage=stage,
2026-05-28 07:05:41 +00:00
elapsed_ms=fetch_ms,
2026-05-25 18:19:06 +00:00
gguf_path=str(gguf_path),
2026-05-28 07:05:41 +00:00
gguf_bytes=gguf_bytes,
gguf_mb=round(fetch_mb, 1),
download_mb_per_s=download_mb_per_s,
cache_hit=cache_hit,
2026-05-25 18:19:06 +00:00
)
print(
f"pp_tinygrad_worker: stage={stage} loading model from {gguf_path}",
file=sys.stderr,
flush=True,
)
2026-05-28 07:05:41 +00:00
_emit_event("loading_model", stage=stage, model=model_name, wall_ms=_wall_ms())
2026-05-25 18:19:06 +00:00
_load_start = time.monotonic()
2026-05-28 07:05:41 +00:00
# Shard at load time: only this stage's block range (+ embed/output on the
# end stages) is copied to the compute device, so an 18 GB model fits on a
# 12 GB GPU. See _load_sharded_transformer for why stock from_gguf can't.
# The loader emits its own `model_load_breakdown` (header/dequant/realize).
model, kv, start, end = _load_sharded_transformer(
gguf_path, stage, num_stages, max_context=512
2026-05-25 18:19:06 +00:00
)
2026-05-28 07:05:41 +00:00
load_ms = int((time.monotonic() - _load_start) * 1000)
tokenizer = SimpleTokenizer.from_gguf_kv(kv)
arch = kv["general.architecture"]
hidden_dim = int(kv[f"{arch}.embedding_length"])
total_blocks = int(kv[f"{arch}.block_count"])
vocab_size = len(kv["tokenizer.ggml.tokens"])
2026-05-28 07:05:41 +00:00
_emit_event(
"model_loaded",
stage=stage,
elapsed_ms=load_ms,
rss_mb=_rss_mb(),
blocks_resident=end - start,
total_blocks=total_blocks,
)
# Stash the cold-start breakdown so main() can emit one `boot_profile`
# summary once the worker is ready (import + fetch + load + total).
self.timing = {
"import_ms": import_ms,
"fetch_ms": fetch_ms,
"fetch_cache_hit": cache_hit,
"gguf_bytes": gguf_bytes,
"download_mb_per_s": download_mb_per_s,
"load_ms": load_ms,
}
# Pin EOS token ids (best-effort) for callers that want to detect
# end-of-text from the sampled stream. We don't enforce stop here
# — the Rust Stage-1 actor owns the EOS decision — but exposing
# them in the ready line lets the orchestrator configure itself.
tokens_list = kv.get("tokenizer.ggml.tokens", [])
eos_ids: list[int] = []
for i, tok in enumerate(tokens_list):
if tok in ("<|end_of_text|>", "<|eot_id|>", "</s>", "<|endoftext|>"):
eos_ids.append(i)
# Tinygrad and numpy are kept as instance attributes so the
# per-request handlers don't re-import them.
self._np = np
self._Tensor = Tensor
self.model = model
self.tokenizer = tokenizer
self.hidden_dim = hidden_dim
self.vocab_size = vocab_size
self.total_blocks = total_blocks
self.stage = stage
self.num_stages = num_stages
self.start = start
self.end = end
self.eos_ids = eos_ids
2026-05-28 07:05:41 +00:00
# Per-op compute breakdown (deserialize / compute / host-copy ms), set by
# the forward ops and folded into the serve loop's `op` timing event.
self._last_compute: "dict | None" = None
print(
f"pp_tinygrad_worker: stage={stage} ready "
f"(blocks={total_blocks}, range=[{start},{end}), "
f"hidden_dim={hidden_dim}, vocab={vocab_size}, eos={eos_ids})",
file=sys.stderr,
flush=True,
)
# --- per-stage forward ops -------------------------------------------
def embed_and_forward(self, tokens: Sequence[int], position: int) -> tuple[bytes, int]:
Tensor = self._Tensor
2026-05-28 07:05:41 +00:00
t0 = time.monotonic()
t = Tensor([list(tokens)], dtype="int32")
x = self.model.token_embd(t)
for block in self.model.blk[self.start : self.end]:
x = block(x, position)
# Cast to half (2 bytes/elem) for the wire format, matching the
# stub. The model's weights are float16; the op output may have
2026-05-28 07:05:41 +00:00
# promoted to float32, so an explicit cast normalises this. tinygrad is
# lazy: embed + blocks + cast all *execute* at .realize() below (incl.
# JIT kernel compile on the first call), so compute_ms captures them.
t1 = time.monotonic()
x = x.cast("half").realize()
2026-05-28 07:05:41 +00:00
t2 = time.monotonic()
arr = x.numpy() # shape (1, seq_len, hidden_dim), dtype float16
2026-05-28 07:05:41 +00:00
out = arr.tobytes()
t3 = time.monotonic()
self._last_compute = {
"build_ms": round((t1 - t0) * 1000, 2),
"compute_ms": round((t2 - t1) * 1000, 2),
"host_copy_ms": round((t3 - t2) * 1000, 2),
"out_bytes": len(out),
}
return out, int(arr.shape[1])
2026-05-20 07:41:30 +00:00
def forward_range(
self, hidden_bytes: bytes, position: int, seq_len: int
) -> tuple[bytes, int]:
"""Run this middle stage's block range over an incoming hidden state.
Input is a flat float16 buffer of shape ``(1, seq_len, hidden_dim)``;
output is the same shape after applying ``model.blk[start:end]``
with KV-cache ``position``. Returns ``(bytes, out_seq_len)``.
"""
np = self._np
Tensor = self._Tensor
expected = seq_len * self.hidden_dim * BYTES_PER_ELEM
if len(hidden_bytes) != expected:
raise ValueError(
f"hidden length {len(hidden_bytes)} does not match "
f"seq_len*hidden_dim*2 ({seq_len}*{self.hidden_dim}*{BYTES_PER_ELEM} "
f"= {expected})"
)
2026-05-28 07:05:41 +00:00
t0 = time.monotonic()
2026-05-20 07:41:30 +00:00
arr = (
np.frombuffer(hidden_bytes, dtype=np.float16)
.reshape((1, seq_len, self.hidden_dim))
.copy()
)
x = Tensor(arr)
2026-05-28 07:05:41 +00:00
t1 = time.monotonic()
2026-05-20 07:41:30 +00:00
for block in self.model.blk[self.start : self.end]:
x = block(x, position)
x = x.cast("half").realize()
2026-05-28 07:05:41 +00:00
t2 = time.monotonic()
out_arr = x.numpy()
out = out_arr.tobytes()
t3 = time.monotonic()
self._last_compute = {
"deserialize_ms": round((t1 - t0) * 1000, 2),
"compute_ms": round((t2 - t1) * 1000, 2),
"host_copy_ms": round((t3 - t2) * 1000, 2),
"in_bytes": len(hidden_bytes),
"out_bytes": len(out),
}
return out, int(out_arr.shape[1])
2026-05-20 07:41:30 +00:00
def forward_and_sample(
self, hidden_bytes: bytes, position: int, seq_len: int
) -> int:
np = self._np
Tensor = self._Tensor
expected = seq_len * self.hidden_dim * BYTES_PER_ELEM
if len(hidden_bytes) != expected:
raise ValueError(
f"hidden length {len(hidden_bytes)} does not match "
f"seq_len*hidden_dim*2 ({seq_len}*{self.hidden_dim}*{BYTES_PER_ELEM} "
f"= {expected})"
)
2026-05-28 07:05:41 +00:00
t0 = time.monotonic()
arr = (
np.frombuffer(hidden_bytes, dtype=np.float16)
.reshape((1, seq_len, self.hidden_dim))
.copy()
)
x = Tensor(arr)
2026-05-28 07:05:41 +00:00
t1 = time.monotonic()
for block in self.model.blk[self.start : self.end]:
x = block(x, position)
x = self.model.output_norm(x)
logits = self.model.output(x)
# Argmax on the last position's logits. Matches what
2026-05-28 07:05:41 +00:00
# ``Transformer.forward`` does at llm.py:178. The .item() forces the
# blocks + output projection (over the full vocab) to execute here.
token_id = int(logits[0, -1, :].argmax().item())
2026-05-28 07:05:41 +00:00
t2 = time.monotonic()
self._last_compute = {
"deserialize_ms": round((t1 - t0) * 1000, 2),
"compute_ms": round((t2 - t1) * 1000, 2),
"in_bytes": len(hidden_bytes),
"token_id": token_id,
}
return token_id
def generate_full(self, prompt: str, max_tokens: int) -> list[int]:
"""End-to-end inference over the full block range.
Used as the reference for the sliced-vs-full equivalence tests.
Runs the same unjitted block iteration that the per-stage ops use
(``embed_and_forward`` + ``forward_and_sample``), but on all
``model.blk`` blocks within a single process — so the result is
bit-identical to a correctly-sliced pipeline with argmax sampling.
Returns the ``max_tokens`` sampled token ids (excluding the
prompt). Each invocation starts a fresh prefill at ``position=0``;
the lazily-allocated per-block KV cache is overwritten in place
as positions are revisited, so the same worker can serve multiple
independent prompts.
"""
2026-05-28 07:05:41 +00:00
if self.start != 0 or self.end != self.total_blocks:
raise RuntimeError(
"generate_full needs the whole model resident, but this stage only "
f"holds blk[{self.start}:{self.end}) of {self.total_blocks}. Use the "
"per-stage ops (embed_and_forward / forward_range / forward_and_sample)."
)
Tensor = self._Tensor
if max_tokens <= 0:
return []
prompt_tokens = self.tokenizer.encode(prompt)
if len(prompt_tokens) == 0:
raise ValueError("prompt tokenized to an empty list")
def _forward_sample(token_ids: Sequence[int], position: int) -> int:
t = Tensor([list(token_ids)], dtype="int32")
x = self.model.token_embd(t)
for block in self.model.blk[0 : self.total_blocks]:
x = block(x, position)
x = self.model.output_norm(x)
logits = self.model.output(x)
return int(logits[0, -1, :].argmax().item())
out: list[int] = []
# Prefill: full prompt at position 0; sample at the last position.
next_id = _forward_sample(prompt_tokens, 0)
out.append(next_id)
# Autoregressive decode: feed the newly-sampled token at the
# next position. This mirrors what ``Stage0Actor`` does over the
# wire when ``NextToken { position, token_id }`` arrives.
pos = len(prompt_tokens)
for _ in range(max_tokens - 1):
next_id = _forward_sample([next_id], pos)
out.append(next_id)
pos += 1
return out
def _validate_tokens_list(tokens) -> str | None:
"""Return None if ``tokens`` is a list of non-bool ints, else an error msg."""
if not isinstance(tokens, list):
return "'tokens' must be a list of ints"
for t in tokens:
if not isinstance(t, int) or isinstance(t, bool) or t < 0:
return "'tokens' must be a list of non-negative ints"
return None
def _handle_request(
req: dict,
stage: int,
num_stages: int,
real_state: "_RealModelState | None" = None,
) -> dict:
rid = req.get("request_id")
if "op" not in req:
return {"request_id": rid, "error": "missing 'op' field"}
op = req["op"]
is_first = stage == 0
is_last = stage == num_stages - 1
2026-05-20 07:41:30 +00:00
is_middle = not is_first and not is_last
2026-05-20 07:41:30 +00:00
# Tokenize lives on the first stage; detokenize on the last. Routing
# both through their natural roles avoids ambiguity when an N-stage
# cluster has a tokenizer-bearing worker on every node (real mode).
if op == "tokenize":
2026-05-20 07:41:30 +00:00
if not is_first:
return {
"request_id": rid,
"error": f"op {op!r} is only valid on stage 0 "
f"(this worker is stage {stage} of {num_stages})",
}
prompt = req.get("prompt")
if not isinstance(prompt, str):
return {"request_id": rid, "error": "'prompt' must be a string"}
if real_state is not None:
tokens = real_state.tokenizer.encode(prompt)
else:
tokens = [
int(t) for t in _stub_tokenize(prompt)
]
return {"request_id": rid, "tokens": tokens}
if op == "detokenize":
2026-05-20 07:41:30 +00:00
if not is_last:
return {
"request_id": rid,
"error": f"op {op!r} is only valid on the final stage "
f"(this worker is stage {stage} of {num_stages})",
}
tokens = req.get("tokens")
err = _validate_tokens_list(tokens)
if err is not None:
return {"request_id": rid, "error": err}
if real_state is not None:
text = real_state.tokenizer.decode(tokens)
else:
text = " ".join(str(t) for t in tokens)
return {"request_id": rid, "text": text}
if op in ("embed_and_forward", "decode_step"):
if not is_first:
return {
"request_id": rid,
"error": f"op {op!r} is only valid on stage 0 "
f"(this worker is stage {stage} of {num_stages})",
}
if op == "embed_and_forward":
tokens = req.get("tokens")
position = req.get("position", 0)
err = _validate_tokens_list(tokens)
if err is not None:
return {"request_id": rid, "error": err}
if len(tokens) == 0:
return {"request_id": rid, "error": "'tokens' must be non-empty"}
if not _is_nonneg_int(position):
return {"request_id": rid, "error": "'position' must be a non-negative int"}
if real_state is not None:
hidden, out_seq_len = real_state.embed_and_forward(tokens, position)
else:
hidden = _stub_hidden_bytes(tokens, position)
out_seq_len = len(tokens)
return {
"request_id": rid,
"hidden_b64": base64.b64encode(hidden).decode("ascii"),
"seq_len": out_seq_len,
}
# decode_step
token_id = req.get("token_id")
position = req.get("position")
if not _is_nonneg_int(token_id):
return {"request_id": rid, "error": "'token_id' must be a non-negative int"}
if not _is_nonneg_int(position):
return {"request_id": rid, "error": "'position' must be a non-negative int"}
if real_state is not None:
hidden, out_seq_len = real_state.embed_and_forward([token_id], position)
else:
hidden = _stub_hidden_bytes([token_id], position)
out_seq_len = 1
return {
"request_id": rid,
"hidden_b64": base64.b64encode(hidden).decode("ascii"),
"seq_len": out_seq_len,
}
2026-05-20 07:41:30 +00:00
if op == "forward_range":
if not is_middle:
return {
"request_id": rid,
"error": f"op {op!r} is only valid on a middle stage "
f"(this worker is stage {stage} of {num_stages})",
}
hidden_b64 = req.get("hidden_b64")
position = req.get("position")
seq_len = req.get("seq_len")
if not isinstance(hidden_b64, str):
return {"request_id": rid, "error": "'hidden_b64' must be a string"}
if not _is_nonneg_int(position):
return {"request_id": rid, "error": "'position' must be a non-negative int"}
if not _is_nonneg_int(seq_len) or seq_len == 0:
return {"request_id": rid, "error": "'seq_len' must be a positive int"}
try:
hidden = base64.b64decode(hidden_b64, validate=True)
except (base64.binascii.Error, ValueError) as e:
return {"request_id": rid, "error": f"invalid base64 in hidden_b64: {e}"}
hidden_dim = real_state.hidden_dim if real_state is not None else STUB_HIDDEN_DIM
expected = seq_len * hidden_dim * BYTES_PER_ELEM
if len(hidden) != expected:
return {
"request_id": rid,
"error": (
f"hidden length {len(hidden)} does not match "
f"seq_len*hidden_dim*2 ({seq_len}*{hidden_dim}*{BYTES_PER_ELEM} "
f"= {expected})"
),
}
if real_state is not None:
hidden_out, out_seq_len = real_state.forward_range(hidden, position, seq_len)
else:
hidden_out = _stub_forward_range_bytes(hidden, position, seq_len)
out_seq_len = seq_len
return {
"request_id": rid,
"hidden_b64": base64.b64encode(hidden_out).decode("ascii"),
"seq_len": out_seq_len,
}
if op == "generate_full":
# Reference path used by the sliced-vs-full equivalence tests. Always
# runs over the full block range, so the worker's STAGE/NUM_STAGES
2026-05-20 07:41:30 +00:00
# are ignored here — any ``NUM_STAGES >= 2`` works since the op
# iterates ``model.blk`` directly. The example does not boot at
# ``NUM_STAGES=1`` (single-node configurations belong to
# ``examples/single-gpu-inference``).
if real_state is None:
return {
"request_id": rid,
"error": "op 'generate_full' requires real mode (stub mode has no full model)",
}
prompt = req.get("prompt")
max_tokens = req.get("max_tokens")
if not isinstance(prompt, str):
return {"request_id": rid, "error": "'prompt' must be a string"}
if not _is_nonneg_int(max_tokens) or max_tokens == 0:
return {"request_id": rid, "error": "'max_tokens' must be a positive int"}
try:
tokens = real_state.generate_full(prompt, max_tokens)
except Exception as e:
return {"request_id": rid, "error": f"generate_full: {e}"}
return {"request_id": rid, "tokens": tokens}
if op == "forward_and_sample":
if not is_last:
return {
"request_id": rid,
"error": f"op {op!r} is only valid on the final stage "
f"(this worker is stage {stage} of {num_stages})",
}
hidden_b64 = req.get("hidden_b64")
position = req.get("position")
seq_len = req.get("seq_len")
if not isinstance(hidden_b64, str):
return {"request_id": rid, "error": "'hidden_b64' must be a string"}
if not _is_nonneg_int(position):
return {"request_id": rid, "error": "'position' must be a non-negative int"}
if not _is_nonneg_int(seq_len) or seq_len == 0:
return {"request_id": rid, "error": "'seq_len' must be a positive int"}
try:
hidden = base64.b64decode(hidden_b64, validate=True)
except (base64.binascii.Error, ValueError) as e:
return {"request_id": rid, "error": f"invalid base64 in hidden_b64: {e}"}
if real_state is not None:
hidden_dim = real_state.hidden_dim
else:
hidden_dim = STUB_HIDDEN_DIM
expected = seq_len * hidden_dim * BYTES_PER_ELEM
if len(hidden) != expected:
return {
"request_id": rid,
"error": (
f"hidden length {len(hidden)} does not match "
f"seq_len*hidden_dim*2 ({seq_len}*{hidden_dim}*{BYTES_PER_ELEM} "
f"= {expected})"
),
}
if real_state is not None:
token_id = real_state.forward_and_sample(hidden, position, seq_len)
else:
token_id = _stub_token_id(hidden, position)
return {"request_id": rid, "token_id": token_id}
return {"request_id": rid, "error": f"unknown op {op!r}"}
def _stub_tokenize(prompt: str) -> list[int]:
"""Whitespace-split a prompt into deterministic small integer ids.
Only used in stub mode. The exact mapping is not part of the
worker's contract — callers just need a list of ints whose length
equals the number of whitespace-separated words.
"""
out: list[int] = []
for i, word in enumerate(prompt.split()):
s = sum(ord(c) for c in word)
out.append((s % 1024) + i)
return out
def main(argv: Sequence[str] | None = None) -> int:
2026-05-25 18:19:06 +00:00
# Install diagnostic hooks first thing so any failure during arg
# parsing or env validation still produces a structured event.
_install_excepthook()
_install_signal_handlers()
parser = argparse.ArgumentParser(description="pipeline-parallel tinygrad worker")
parser.add_argument(
"--stub",
action="store_true",
help="Stub mode: skip model loading, use deterministic in-memory ops",
)
parser.add_argument(
"--model",
default=None,
help="Model name (defaults to $MODEL or llama3.2:1b in real mode)",
)
args = parser.parse_args(argv)
stub_mode = args.stub or os.environ.get("PP_WORKER_STUB", "").strip() == "1"
stage = _parse_env_int("STAGE")
num_stages = _parse_env_int("NUM_STAGES")
2026-05-25 18:19:06 +00:00
_emit_event(
"starting",
pid=os.getpid(),
stage=stage,
num_stages=num_stages,
stub=stub_mode,
model=(args.model or os.environ.get("MODEL", "")).strip() or None,
python_version=sys.version.split()[0],
argv=list(sys.argv),
2026-05-28 07:05:41 +00:00
# wall_ms anchors this worker's boot against the orchestrator's vast.ai
# instance create/lease time — the only way to measure image-pull +
# container-boot latency, which the worker can't observe directly.
wall_ms=_wall_ms(),
host=os.uname().nodename,
2026-05-25 18:19:06 +00:00
)
_start_heartbeat()
2026-05-20 07:41:30 +00:00
if num_stages < 2:
_die(
f"NUM_STAGES must be >= 2 (single-node configurations are not "
f"served by this example), got {num_stages}"
)
if not (0 <= stage < num_stages):
_die(f"STAGE {stage} out of range [0, {num_stages})")
real_state: _RealModelState | None = None
if not stub_mode:
model_name = (args.model or os.environ.get("MODEL", "")).strip() or "llama3.2:1b"
try:
real_state = _RealModelState(model_name, stage, num_stages)
except SystemExit:
raise
except Exception as e:
import traceback
2026-05-25 18:19:06 +00:00
tb_text = traceback.format_exc()
_emit_event(
"model_load_failed",
stage=stage,
model=model_name,
type=type(e).__name__,
value=str(e),
traceback=tb_text,
)
print(tb_text, file=sys.stderr, flush=True)
_die(f"failed to load model {model_name!r}: {e}")
ready: dict = {"status": "ready", "pid": os.getpid(), "stage": stage}
if real_state is not None:
ready["hidden_dim"] = real_state.hidden_dim
ready["vocab_size"] = real_state.vocab_size
ready["total_blocks"] = real_state.total_blocks
ready["layer_range"] = [real_state.start, real_state.end]
ready["eos_token_ids"] = real_state.eos_ids
_write(ready)
2026-05-28 07:05:41 +00:00
# Mirror ready as a structured event under the spec's event name
# (§4.6 / §6.9: per-stage filter on `pp_worker_ready`). The
# `pp_*` event kind is passed through unchanged by the Rust actor
# so the bundle records `Custom("pp_worker_ready")`, matching the
# name the orchestrator-side wired check filters on. The protocol
# `status: "ready"` line above is unchanged for the actor's ready
# signal.
2026-05-25 18:19:06 +00:00
_emit_event(
2026-05-28 07:05:41 +00:00
"pp_worker_ready",
2026-05-25 18:19:06 +00:00
pid=os.getpid(),
2026-05-28 07:05:41 +00:00
stage_index=stage,
2026-05-25 18:19:06 +00:00
uptime_ms=_uptime_ms(),
rss_mb=_rss_mb(),
)
2026-05-28 07:05:41 +00:00
# One-stop cold-start breakdown so a single event answers "where did
# bring-up time go" per node: import + (fetch|cache) + load == time-to-ready.
if real_state is not None:
_emit_event(
"boot_profile",
stage=stage,
total_to_ready_ms=_uptime_ms(),
rss_mb=_rss_mb(),
blocks_resident=real_state.end - real_state.start,
**real_state.timing,
)
2026-05-25 18:19:06 +00:00
global _REQUESTS_SERVED
2026-05-28 07:05:41 +00:00
seen_ops: set = set()
last_op_end = time.monotonic()
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
except (json.JSONDecodeError, ValueError) as e:
_write({"error": f"invalid JSON: {e}"})
continue
if not isinstance(req, dict):
_write({"error": f"request must be a JSON object, got {type(req).__name__}"})
continue
2026-05-28 07:05:41 +00:00
op = req.get("op")
if real_state is not None:
real_state._last_compute = None
t_start = time.monotonic()
# idle_ms_before is the pipeline bubble: how long this worker sat
# blocked on its upstream stage between finishing the last op and
# receiving this one. High idle => the bottleneck is elsewhere.
idle_ms = round((t_start - last_op_end) * 1000, 2)
try:
reply = _handle_request(req, stage, num_stages, real_state=real_state)
except Exception as e: # last-ditch safety net so the worker stays up
import traceback
print(traceback.format_exc(), file=sys.stderr, flush=True)
reply = {"request_id": req.get("request_id"), "error": f"internal: {e}"}
_write(reply)
2026-05-28 07:05:41 +00:00
t_end = time.monotonic()
# Per-op trace — the granular signal for end-to-end latency. `first_call`
# flags the JIT-compile-bearing first invocation of each op (kernels are
# compiled once, then cached). `rid` (not `request_id`) keeps the Rust
# actor from ever mistaking this event line for an op reply.
is_first = op not in seen_ops
seen_ops.add(op)
_emit_event(
"op",
op=op,
rid=req.get("request_id"),
stage=stage,
duration_ms=round((t_end - t_start) * 1000, 2),
idle_ms_before=idle_ms,
first_call=is_first,
ok=isinstance(reply, dict) and "error" not in reply,
in_tokens=len(req["tokens"]) if isinstance(req.get("tokens"), list) else None,
in_seq_len=req.get("seq_len"),
out_seq_len=reply.get("seq_len") if isinstance(reply, dict) else None,
compute=(real_state._last_compute if real_state is not None else None),
uptime_ms=_uptime_ms(),
)
last_op_end = t_end
2026-05-25 18:19:06 +00:00
_REQUESTS_SERVED += 1
2026-05-25 18:19:06 +00:00
_emit_event(
"exiting",
reason="eof",
uptime_ms=_uptime_ms(),
requests_served=_REQUESTS_SERVED,
)
return 0
if __name__ == "__main__":
sys.exit(main())