CoolDip00 commited on
Commit
d656a30
·
verified ·
1 Parent(s): 3866ae1

tiny-llm 75e4579: 6.5b step 5: 5a Space created, 5b secrets added

Browse files
.dockerignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Allow-list: the image needs only the package and the tokenizer (see Dockerfile).
2
+ # Never data, checkpoints, runs, wandb, .venv, notes or secrets.
3
+ *
4
+ !src/tinyllm/
5
+ !tokenizer/tinystories-bpe8192.json
6
+ **/__pycache__
7
+ **/*.pyc
Dockerfile ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # The tinyllm server on CPU (6.5b, D-033): the image behind the Hugging Face Space, and a local `docker run`.
2
+ #
3
+ # docker build -t tinyllm --build-arg GIT_COMMIT=$(git rev-parse --short HEAD) .
4
+ # docker run --rm -p 7860:7860 tinyllm
5
+ # curl localhost:7860/v1/completions -H 'content-type: application/json' \
6
+ # -d '{"prompt": "Once upon a time", "max_tokens": 60}'
7
+ #
8
+ # CPU only: no CUDA, Triton or CUDA graphs here, so the model runs the PyTorch code paths
9
+ # (/stats reports norm_backend "torch"). The speed numbers in the README come from the laptop GPU.
10
+ # No weights in the image: the model is downloaded from the Hub at start into the HF cache
11
+ # (MODEL / REVISION below), or mounted as a folder: -v /path/to/export:/model -e MODEL=/model.
12
+
13
+ FROM python:3.12-slim
14
+
15
+ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 \
16
+ PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
17
+
18
+ # CPU build of the torch version in pyproject.toml (as in CI), then only what the server imports.
19
+ # Versions match uv.lock; training-only packages (datasets, wandb) stay out of the image.
20
+ RUN pip install torch==2.14.1 --index-url https://download.pytorch.org/whl/cpu \
21
+ && pip install numpy==2.5.4 tokenizers==0.23.3 huggingface-hub==2.4.0 \
22
+ fastapi==0.162.0 starlette==1.10.0 pydantic==2.14.2 uvicorn==0.54.0 \
23
+ && python -c "import torch; assert not torch.cuda.is_available() and torch.version.cuda is None"
24
+
25
+ # Spaces run the container as uid 1000; the HF cache must be writable by it.
26
+ RUN useradd -m -u 1000 user
27
+ USER user
28
+ ENV HOME=/home/user HF_HOME=/home/user/.cache/huggingface PYTHONPATH=/app/src
29
+ WORKDIR /app
30
+
31
+ COPY --chown=user src/tinyllm ./src/tinyllm
32
+ COPY --chown=user tokenizer ./tokenizer
33
+
34
+ ARG GIT_COMMIT=75e4579
35
+ # REVISION pins the Hub commit of the model upload (scripts/upload_model.py), so a rebuild serves the same weights.
36
+ ENV TINYLLM_GIT_COMMIT=$GIT_COMMIT \
37
+ MODEL=CoolDip00/tiny-llm-tinystories-30m REVISION=b11100b189f9d5085ce8c0fd775ca71a6eaf287c \
38
+ SLOTS=8 PORT=7860 SLO_TTFT_S=2 LOG_DATASET="" \
39
+ MAX_QUEUE=4 RATE_LIMIT=20 RATE_BURST=5 PROXY_HOPS=1
40
+
41
+ EXPOSE 7860
42
+ # Shell form so every ENV above can be overridden with -e (or a Space variable); exec keeps uvicorn as
43
+ # PID 1 (SIGTERM → graceful drain, then the last log upload). --revision only for a repo id: a mounted
44
+ # folder has none. SLO_TTFT_S: the status page's TTFT SLO threshold, 2 s on the shared CPU.
45
+ # MAX_QUEUE / RATE_LIMIT (requests/min per visitor, 0 = off) / RATE_BURST: load shedding (docs/SERVING.md).
46
+ # SLOTS / MAX_QUEUE / RATE_*: from the 2-CPU load test (docs/SERVING.md "Space defaults"): 8 slots,
47
+ # queue ≈ 2 s TTFT target × throughput, 20 requests/min per visitor with a burst of 5.
48
+ # --trust-proxy only on a Space (HF sets SPACE_ID): there HF's proxy adds the visitor's address to
49
+ # X-Forwarded-For; a plain `docker run` has no proxy, so the header is the client's own and is ignored.
50
+ # PROXY_HOPS: proxies in front that append to that header (the client is that many entries from the right).
51
+ # LOG_DATASET (off when empty): a private HF dataset for the request logs (docs/SERVING.md "Privacy");
52
+ # needs HF_TOKEN with write access to it, and TINYLLM_LOG_SALT to count visitors across restarts.
53
+ CMD exec python -m tinyllm.server --hf-model "$MODEL" \
54
+ $( [ -d "$MODEL" ] || echo --revision "$REVISION" ) \
55
+ $( [ -z "$LOG_DATASET" ] || echo --log-dir /tmp/tinyllm-logs --log-dataset "$LOG_DATASET" ) \
56
+ $( [ -z "$SPACE_ID" ] || echo --trust-proxy --proxy-hops "$PROXY_HOPS" ) \
57
+ --device cpu --host 0.0.0.0 --port "$PORT" --slots "$SLOTS" --slo-ttft-s "$SLO_TTFT_S" \
58
+ --max-queue "$MAX_QUEUE" --rate-limit "$RATE_LIMIT" --rate-burst "$RATE_BURST"
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cooldip00
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR THE OPTIONS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,10 +1,30 @@
1
  ---
2
- title: Tiny Llm
3
- emoji: 💻
4
- colorFrom: blue
5
- colorTo: yellow
6
  sdk: docker
 
 
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: tiny-llm
3
+ emoji: 📖
4
+ colorFrom: indigo
5
+ colorTo: gray
6
  sdk: docker
7
+ app_port: 7860
8
+ license: mit
9
+ short_description: A 30M-parameter story model, served by a from-scratch engine
10
  pinned: false
11
  ---
12
 
13
+ # tiny-llm
14
+
15
+ A 30M-parameter language model trained from scratch on TinyStories, served by the project's own inference server (continuous batching, streaming, load shedding) on HF's free 2-vCPU CPU tier.
16
+
17
+ - **Demo:** this page. Type the start of a story and it carries on.
18
+ - **Status:** `/status` shows live latency, throughput and the SLOs (time to first token p95 ≤ 2 s, 99% availability).
19
+ - **API:** `POST /v1/completions` (OpenAI-style), e.g.
20
+ `curl https://cooldip00-tiny-llm.hf.space/v1/completions -H 'content-type: application/json' -d '{"prompt": "Once upon a time", "max_tokens": 60}'`
21
+ - **Code, design notes and benchmarks:** https://github.com/cooldip00/tiny-llm (the Dockerfile here is the one in the repo; the exact commit is shown on `/stats`).
22
+ - **Model:** https://huggingface.co/CoolDip00/tiny-llm-tinystories-30m
23
+
24
+ ## Limits
25
+
26
+ It is a small model on a shared CPU: stories are simple and sometimes odd. Each visitor may send 20 requests per minute (bursts of 5); when the server is full it answers 429 at once instead of making everyone wait. The Space sleeps after 48 hours without visitors; the first visit after that takes a while to start.
27
+
28
+ ## What is logged
29
+
30
+ Request metadata only: timings, token counts, status and finish reason. **Never the prompt or the generated text.** Visitors are counted by a salted hash of the IP address; the IP itself is never stored. The logs go to a private dataset and are kept for 30 days. Details: [docs/SERVING.md](https://github.com/cooldip00/tiny-llm/blob/main/docs/SERVING.md#privacy-rules).
src/tinyllm/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """tinyllm: a small Llama-style LLM built from scratch."""
src/tinyllm/attention/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """Attention variants. Importing this package registers every built-in variant."""
2
+
3
+ from tinyllm.attention.base import ATTENTION, Attention, build_attention, register
4
+ from tinyllm.attention import mha # noqa: F401 (registers "mha")
5
+ from tinyllm.attention import gqa # noqa: F401 (registers "gqa")
6
+
7
+ __all__ = ["ATTENTION", "Attention", "build_attention", "register"]
src/tinyllm/attention/base.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The swappable attention contract and the registry (docs/ARCHITECTURE.md).
2
+
3
+ Every variant (mha, gqa, mla, gdn) subclasses `Attention`, registers itself under
4
+ a name, and is picked per layer through `ModelConfig.layer_types`. The rest of
5
+ the model never needs to know which one it got.
6
+ """
7
+
8
+ from torch import Tensor, nn
9
+
10
+ from tinyllm.config import ModelConfig
11
+
12
+ ATTENTION: dict[str, type["Attention"]] = {}
13
+
14
+
15
+ def register(name: str):
16
+ """Class decorator: @register("mha") adds the class to ATTENTION."""
17
+ def deco(cls: type["Attention"]) -> type["Attention"]:
18
+ if name in ATTENTION:
19
+ raise ValueError(f"attention variant {name!r} already registered")
20
+ ATTENTION[name] = cls
21
+ return cls
22
+ return deco
23
+
24
+
25
+ class Attention(nn.Module):
26
+ def __init__(self, cfg: ModelConfig, layer_idx: int):
27
+ super().__init__()
28
+ self.cfg = cfg
29
+ self.layer_idx = layer_idx
30
+
31
+ def forward(self, x: Tensor, rope: tuple[Tensor, Tensor], cache=None) -> Tensor:
32
+ """x: (B, T, d_model) -> (B, T, d_model).
33
+
34
+ rope: (cos, sin) for the T positions in x.
35
+ cache: per-layer state for incremental decoding. None during training.
36
+ """
37
+ raise NotImplementedError
38
+
39
+ def cache_bytes_per_token(self, dtype_bytes: int = 2) -> int:
40
+ """Decode-time state this layer adds per generated token (bf16 = 2 bytes)."""
41
+ raise NotImplementedError
42
+
43
+
44
+ def build_attention(cfg: ModelConfig, layer_idx: int) -> Attention:
45
+ name = cfg.layer_types[layer_idx]
46
+ if name not in ATTENTION:
47
+ raise KeyError(f"unknown attention {name!r}; registered: {sorted(ATTENTION)}")
48
+ return ATTENTION[name](cfg, layer_idx)
src/tinyllm/attention/gqa.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Grouped-query attention (Ainslie et al. 2023), step 4.9a. TinyLlama: 32 query heads, 4 KV heads.
2
+
3
+ Query heads are split into n_kv_heads groups of g = n_heads / n_kv_heads; every head in group j
4
+ attends with the same key/value head j. So q_proj stays d -> n_heads·hd while k_proj / v_proj
5
+ shrink to d -> n_kv_heads·hd, and the KV cache stores n_kv_heads heads: g× less decode state.
6
+
7
+ Head order matches HF Llama's `repeat_kv` (query head i uses KV head i // g), which is what
8
+ TinyLlama's weights expect (4.9b / 4.9c).
9
+
10
+ With n_kv_heads == n_heads this is exactly `mha` (same parameter names and shapes, so an `mha`
11
+ state dict loads into it).
12
+ """
13
+
14
+ import torch.nn.functional as F
15
+ from torch import Tensor, nn
16
+
17
+ from tinyllm.attention.base import Attention, register
18
+ from tinyllm.config import ModelConfig
19
+ from tinyllm.model import apply_rope
20
+
21
+
22
+ def repeat_kv(x: Tensor, groups: int) -> Tensor:
23
+ """(B, kv, T, hd) -> (B, kv·groups, T, hd): KV head j is copied to query heads j·g .. j·g+g-1.
24
+
25
+ The explicit form of HF's grouping. Used as the reference in tests; forward uses enable_gqa.
26
+ """
27
+ if groups == 1:
28
+ return x
29
+ B, kv, T, hd = x.shape
30
+ return x[:, :, None].expand(B, kv, groups, T, hd).reshape(B, kv * groups, T, hd)
31
+
32
+
33
+ @register("gqa")
34
+ class GQA(Attention):
35
+ def __init__(self, cfg: ModelConfig, layer_idx: int):
36
+ super().__init__(cfg, layer_idx)
37
+ d, h, kv, hd = cfg.d_model, cfg.n_heads, cfg.n_kv_heads, cfg.head_dim
38
+ self.n_heads, self.n_kv_heads, self.head_dim = h, kv, hd
39
+ self.groups = h // kv
40
+ self.q_proj = nn.Linear(d, h * hd, bias=False)
41
+ self.k_proj = nn.Linear(d, kv * hd, bias=False)
42
+ self.v_proj = nn.Linear(d, kv * hd, bias=False)
43
+ self.o_proj = nn.Linear(h * hd, d, bias=False)
44
+
45
+ def forward(self, x: Tensor, rope: tuple[Tensor, Tensor], cache=None) -> Tensor:
46
+ """cache: a kv_cache.LayerKV / SlotLayerKV / paged layer view. It stores n_kv_heads heads."""
47
+ B, T, _ = x.shape
48
+ q = self.q_proj(x).view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
49
+ k = self.k_proj(x).view(B, T, self.n_kv_heads, self.head_dim).transpose(1, 2)
50
+ v = self.v_proj(x).view(B, T, self.n_kv_heads, self.head_dim).transpose(1, 2)
51
+ cos, sin = rope
52
+ q, k = apply_rope(q, cos, sin), apply_rope(k, cos, sin)
53
+ mask = {"is_causal": True}
54
+ if cache is not None:
55
+ k, v = cache.update(k, v) # (B, kv, S, hd): the cache holds only kv heads
56
+ mask = cache.attn_kwargs
57
+ # enable_gqa: the kernel maps query head i to KV head i // g itself, without copying K/V.
58
+ # Measured in 4.9a (torch 2.14, TinyLlama heads, bf16): 0.07-0.74x the time of
59
+ # repeat_kv + SDPA, masks included. repeat_kv at decode copies the whole cache every
60
+ # step (B=32, 512 positions: 1.09 ms vs 0.08 ms). repeat_kv stays as the test reference.
61
+ y = F.scaled_dot_product_attention(q, k, v, enable_gqa=self.groups > 1, **mask)
62
+ return self.o_proj(y.transpose(1, 2).reshape(B, T, -1))
63
+
64
+ def cache_bytes_per_token(self, dtype_bytes: int = 2) -> int:
65
+ return 2 * self.n_kv_heads * self.head_dim * dtype_bytes # one K and one V vector per KV head
src/tinyllm/attention/mha.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Baseline multi-head causal self-attention (D-010: PyTorch SDPA)."""
2
+
3
+ import torch.nn.functional as F
4
+ from torch import Tensor, nn
5
+
6
+ from tinyllm.attention.base import Attention, register
7
+ from tinyllm.config import ModelConfig
8
+ from tinyllm.model import apply_rope
9
+
10
+
11
+ @register("mha")
12
+ class MHA(Attention):
13
+ """softmax(q·kᵀ / sqrt(head_dim) + causal mask) · v, with every head having its own k and v."""
14
+
15
+ def __init__(self, cfg: ModelConfig, layer_idx: int):
16
+ super().__init__(cfg, layer_idx)
17
+ if cfg.n_kv_heads != cfg.n_heads:
18
+ raise ValueError("mha needs n_kv_heads == n_heads; use 'gqa' for fewer KV heads")
19
+ d, h, hd = cfg.d_model, cfg.n_heads, cfg.head_dim
20
+ self.n_heads, self.head_dim = h, hd
21
+ # Separate projections with HF Llama names (export / TinyLlama loading).
22
+ self.q_proj = nn.Linear(d, h * hd, bias=False)
23
+ self.k_proj = nn.Linear(d, h * hd, bias=False)
24
+ self.v_proj = nn.Linear(d, h * hd, bias=False)
25
+ self.o_proj = nn.Linear(h * hd, d, bias=False)
26
+
27
+ def forward(self, x: Tensor, rope: tuple[Tensor, Tensor], cache=None) -> Tensor:
28
+ """cache: a kv_cache.LayerKV. x then holds only the new tokens (positions cache.start..)."""
29
+ B, T, _ = x.shape
30
+ # (B, T, h*hd) -> (B, h, T, hd)
31
+ q = self.q_proj(x).view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
32
+ k = self.k_proj(x).view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
33
+ v = self.v_proj(x).view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
34
+ cos, sin = rope
35
+ q, k = apply_rope(q, cos, sin), apply_rope(k, cos, sin)
36
+ mask = {"is_causal": True}
37
+ if cache is not None:
38
+ k, v = cache.update(k, v) # all cached positions, new ones included
39
+ mask = cache.attn_kwargs # the cache knows positions and padding
40
+ # Fused kernel (FlashAttention / memory-efficient), never materialises the T×T matrix.
41
+ y = F.scaled_dot_product_attention(q, k, v, **mask)
42
+ return self.o_proj(y.transpose(1, 2).reshape(B, T, -1))
43
+
44
+ def cache_bytes_per_token(self, dtype_bytes: int = 2) -> int:
45
+ return 2 * self.n_heads * self.head_dim * dtype_bytes # one K and one V vector per head
src/tinyllm/config.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model configuration and named presets (docs/ARCHITECTURE.md)."""
2
+
3
+ from dataclasses import asdict, dataclass, field, replace
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class ModelConfig:
8
+ # Sizes
9
+ vocab_size: int = 8192
10
+ max_seq_len: int = 512
11
+ d_model: int = 512
12
+ n_layers: int = 8
13
+ n_heads: int = 8
14
+ n_kv_heads: int | None = None # None = n_heads (plain MHA); fewer = GQA (Phase 4.9a)
15
+ ffn_hidden: int = 1408
16
+ # Numerics
17
+ norm_eps: float = 1e-5
18
+ rope_theta: float = 10000.0
19
+ init_std: float = 0.02
20
+ tie_embeddings: bool = True
21
+ # Attention variant per layer, e.g. ("gdn", "gdn", "gdn", "mha") * 2 for a hybrid.
22
+ # None = all "mha", or all "gqa" when n_kv_heads < n_heads (4.9a).
23
+ layer_types: tuple[str, ...] | None = field(default=None)
24
+
25
+ def __post_init__(self) -> None:
26
+ # frozen dataclass: fill defaults through object.__setattr__
27
+ if self.n_kv_heads is None:
28
+ object.__setattr__(self, "n_kv_heads", self.n_heads)
29
+ if self.layer_types is None:
30
+ default = "gqa" if self.n_kv_heads < self.n_heads else "mha"
31
+ object.__setattr__(self, "layer_types", (default,) * self.n_layers)
32
+ else:
33
+ object.__setattr__(self, "layer_types", tuple(self.layer_types))
34
+
35
+ if self.d_model % self.n_heads:
36
+ raise ValueError(f"d_model {self.d_model} not divisible by n_heads {self.n_heads}")
37
+ if self.n_heads % self.n_kv_heads:
38
+ raise ValueError(f"n_heads {self.n_heads} not divisible by n_kv_heads {self.n_kv_heads}")
39
+ if self.head_dim % 2:
40
+ raise ValueError(f"head_dim {self.head_dim} must be even for RoPE")
41
+ if len(self.layer_types) != self.n_layers:
42
+ raise ValueError(f"{len(self.layer_types)} layer_types for {self.n_layers} layers")
43
+
44
+ @property
45
+ def head_dim(self) -> int:
46
+ return self.d_model // self.n_heads
47
+
48
+ def estimate_params(self) -> int:
49
+ """Parameter count for softmax-attention layers (mha / gqa).
50
+
51
+ embeddings V·d (tied with the LM head)
52
+ + per layer: q,o 2·d² + k,v 2·d·kv_dim + MLP 3·d·ffn + 2 RMSNorms 2·d
53
+ + final RMSNorm d
54
+ With n_kv_heads == n_heads this is the ARCHITECTURE.md formula
55
+ V·d + L·(4·d² + 3·d·ffn) + norms.
56
+ """
57
+ d, kv_dim = self.d_model, self.n_kv_heads * self.head_dim
58
+ per_layer = 2 * d * d + 2 * d * kv_dim + 3 * d * self.ffn_hidden + 2 * d
59
+ emb = self.vocab_size * d * (1 if self.tie_embeddings else 2)
60
+ return emb + self.n_layers * per_layer + d
61
+
62
+ def flops_per_token(self, seq_len: int) -> int:
63
+ """Training FLOPs per token (forward + backward), the PaLM MFU convention
64
+ (Chowdhery et al. 2022, App. B): 6·N + 12·L·(n_heads·head_dim)·T.
65
+
66
+ N counts only weights used in matmuls: per-layer projections plus the LM head (V·d).
67
+ The input embedding lookup and the RMSNorm weights do no matmul work. The attention
68
+ term counts the full T×T score matrix (no causal discount), as PaLM does, so numbers
69
+ compare with published MFU. Softmax-attention layers (mha / gqa) only.
70
+ """
71
+ d, kv_dim = self.d_model, self.n_kv_heads * self.head_dim
72
+ matmul = self.n_layers * (2 * d * d + 2 * d * kv_dim + 3 * d * self.ffn_hidden)
73
+ matmul += self.vocab_size * d
74
+ attn = 12 * self.n_layers * self.n_heads * self.head_dim * seq_len
75
+ return 6 * matmul + attn
76
+
77
+ def to_dict(self) -> dict:
78
+ return asdict(self)
79
+
80
+ @classmethod
81
+ def from_dict(cls, d: dict) -> "ModelConfig":
82
+ return cls(**d)
83
+
84
+
85
+ PRESETS: dict[str, ModelConfig] = {
86
+ "smoke": ModelConfig(d_model=384, n_layers=6, n_heads=6, ffn_hidden=1024),
87
+ "main": ModelConfig(d_model=512, n_layers=8, n_heads=8, ffn_hidden=1408),
88
+ }
89
+
90
+
91
+ def get_config(name: str, **overrides) -> ModelConfig:
92
+ """A preset with optional overrides, e.g. get_config("main", n_kv_heads=2)."""
93
+ cfg = PRESETS[name]
94
+ if overrides:
95
+ if ("n_layers" in overrides or "n_kv_heads" in overrides) and "layer_types" not in overrides:
96
+ overrides["layer_types"] = None # re-derive for the new depth / head layout
97
+ cfg = replace(cfg, **overrides)
98
+ return cfg
src/tinyllm/cuda_graph.py ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CUDA-graph decode (4.11, docs/INFERENCE.md).
2
+
3
+ A decode step of a small model is launch-bound: ~10 kernels per layer, each a few µs of
4
+ GPU work, and the CPU needs about as long to launch each one. A CUDA graph records the
5
+ whole step once and replays it with a single launch.
6
+
7
+ A graph replays fixed kernels on fixed addresses, so the step must have static shapes:
8
+ - StaticKVCache keeps the position as a device tensor. K/V are written with index_copy_
9
+ at that position, RoPE is gathered at it, and every decode step attends over a fixed
10
+ window of W keys, with keys past the position masked out.
11
+ - W comes from length buckets (128, 256, ..., max_len). Each bucket has its own graph;
12
+ the step uses the smallest bucket that holds pos + 1 keys. All graphs share one memory
13
+ pool and the same static buffers (cache, input token, position).
14
+ - Prefill stays eager: it runs once per request and has a different shape each time.
15
+ Sampling stays eager too.
16
+
17
+ Not bit-identical to eager generate(): eager decode attends over exactly pos + 1 keys
18
+ with no mask; the graph attends over W keys with a mask, and SDPA picks a different
19
+ kernel for that. Graph replay *is* bit-identical to the same static step run eagerly.
20
+
21
+ Autocast runs with cache_enabled=False: its weight-cast cache doesn't survive capture.
22
+ """
23
+
24
+ import time
25
+
26
+ import torch
27
+ from torch import Tensor
28
+
29
+ from tinyllm.kv_cache import causal_kwargs, padded_causal_mask
30
+ from tinyllm.model import Transformer
31
+
32
+
33
+ def default_buckets(max_len: int, first: int = 128) -> tuple[int, ...]:
34
+ """first, 2*first, ... below max_len, then max_len itself."""
35
+ out, b = [], first
36
+ while b < max_len:
37
+ out.append(b)
38
+ b *= 2
39
+ return (*out, max_len)
40
+
41
+
42
+ class StaticKVCache:
43
+ """Shared-position cache with fixed shapes (B rows, max_len positions).
44
+
45
+ Same model protocol as kv_cache.KVCache. Two modes:
46
+ - prefill (eager, position 0): as KVCache, writes and returns positions 0..T-1.
47
+ - decode (`window` set, T=1): position from the device tensor `pos`; returns the first
48
+ `window` keys with a mask, so shapes don't depend on the position.
49
+ first_valid (B,) is the left-pad count per row; zeros when unpadded.
50
+ """
51
+
52
+ def __init__(self, n_layers: int, batch: int, max_len: int, device):
53
+ self.n_layers, self.batch, self.max_len = n_layers, batch, max_len
54
+ self.k: list[Tensor | None] = [None] * n_layers
55
+ self.v: list[Tensor | None] = [None] * n_layers
56
+ self.pos = torch.zeros(1, dtype=torch.long, device=device) # device copy: graph input
57
+ self.first_valid = torch.zeros(batch, dtype=torch.long, device=device)
58
+ self.padded = False
59
+ self.n = 0 # host copy of pos, for bounds and bucket choice (no sync)
60
+ self.window: int | None = None # None = prefill mode
61
+ self.attn_kwargs: dict = {}
62
+
63
+ def reset(self, pad_lens: Tensor | None = None) -> None:
64
+ self.n, self.window = 0, None
65
+ self.pos.zero_()
66
+ self.padded = pad_lens is not None
67
+ self.first_valid.copy_(pad_lens if self.padded else torch.zeros_like(self.first_valid))
68
+
69
+ def begin_step(self, T: int, rope) -> tuple[Tensor, Tensor]:
70
+ if self.window is None: # eager prefill from position 0
71
+ if self.n != 0:
72
+ raise ValueError("StaticKVCache prefills only from position 0")
73
+ cos, sin = rope(T, 0)
74
+ self.attn_kwargs = ({"attn_mask": padded_causal_mask(0, T, self.first_valid)}
75
+ if self.padded else causal_kwargs(0, T, cos.device))
76
+ return cos, sin
77
+ if T != 1:
78
+ raise ValueError("decode mode takes one token per row")
79
+ k_pos = torch.arange(self.window, device=self.pos.device)
80
+ valid = (k_pos <= self.pos) & (k_pos >= self.first_valid[:, None]) # (B, W)
81
+ self.attn_kwargs = {"attn_mask": valid[:, None, None, :]} # (B, 1, 1, W)
82
+ return rope.cos.index_select(0, self.pos), rope.sin.index_select(0, self.pos)
83
+
84
+ def end_step(self, T: int) -> None:
85
+ if self.window is None:
86
+ self.n = T
87
+ self.pos.fill_(T)
88
+ else:
89
+ self.pos.add_(1) # captured into the graph; the host copy moves in step()
90
+
91
+ def layer(self, i: int) -> "StaticLayerKV":
92
+ return StaticLayerKV(self, i)
93
+
94
+ def nbytes(self) -> int:
95
+ return sum(t.numel() * t.element_size() for t in self.k + self.v if t is not None)
96
+
97
+
98
+ class StaticLayerKV:
99
+ def __init__(self, cache: StaticKVCache, layer_idx: int):
100
+ self.cache = cache
101
+ self.i = layer_idx
102
+
103
+ @property
104
+ def attn_kwargs(self) -> dict:
105
+ return self.cache.attn_kwargs
106
+
107
+ def update(self, k: Tensor, v: Tensor) -> tuple[Tensor, Tensor]:
108
+ c, i = self.cache, self.i
109
+ B, H, T, D = k.shape
110
+ if c.k[i] is None:
111
+ shape = (c.batch, H, c.max_len, D)
112
+ c.k[i] = torch.zeros(shape, dtype=k.dtype, device=k.device)
113
+ c.v[i] = torch.zeros(shape, dtype=v.dtype, device=v.device)
114
+ elif c.k[i].dtype != k.dtype:
115
+ raise ValueError(f"cache holds {c.k[i].dtype}, got {k.dtype}")
116
+ if c.window is None:
117
+ if T > c.max_len:
118
+ raise ValueError(f"KV cache full: position {T} > max_len {c.max_len}")
119
+ c.k[i][:, :, :T] = k
120
+ c.v[i][:, :, :T] = v
121
+ return c.k[i][:, :, :T], c.v[i][:, :, :T]
122
+ c.k[i].index_copy_(2, c.pos, k)
123
+ c.v[i].index_copy_(2, c.pos, v)
124
+ return c.k[i][:, :, :c.window], c.v[i][:, :, :c.window]
125
+
126
+
127
+ class GraphDecoder:
128
+ """Prefill eagerly, then decode one token per row with one CUDA-graph replay per step.
129
+
130
+ Reusable: prefill() resets the cache, and graphs captured for one request serve the
131
+ next (same batch size). use_graph=False runs the identical static step eagerly, the
132
+ reference that replay must match bit for bit.
133
+ """
134
+
135
+ def __init__(self, model: Transformer, batch: int, max_len: int | None = None,
136
+ buckets: tuple[int, ...] | None = None, use_graph: bool = True):
137
+ device = next(model.parameters()).device
138
+ if device.type != "cuda":
139
+ raise ValueError("CUDA graphs need a CUDA model")
140
+ self.model = model
141
+ self.max_len = max_len or model.cfg.max_seq_len
142
+ if self.max_len > model.cfg.max_seq_len:
143
+ raise ValueError(f"max_len {self.max_len} > max_seq_len {model.cfg.max_seq_len}")
144
+ self.buckets = tuple(sorted(buckets)) if buckets else default_buckets(self.max_len)
145
+ if self.buckets[-1] < self.max_len:
146
+ raise ValueError(f"largest bucket {self.buckets[-1]} < max_len {self.max_len}")
147
+ self.cache = StaticKVCache(model.cfg.n_layers, batch, self.max_len, device)
148
+ self.tok = torch.zeros(batch, 1, dtype=torch.long, device=device)
149
+ self.use_graph = use_graph
150
+ self.graphs: dict[int, tuple[torch.cuda.CUDAGraph, Tensor]] = {}
151
+ self.pool = torch.cuda.graph_pool_handle()
152
+
153
+ def _autocast(self):
154
+ return torch.autocast("cuda", dtype=torch.bfloat16, cache_enabled=False)
155
+
156
+ @torch.no_grad()
157
+ def prefill(self, idx: Tensor, pad_lens: Tensor | None = None) -> Tensor:
158
+ """idx (B, T), left-padded by pad_lens (B,) if given. Returns logits (B, T, V)."""
159
+ if idx.shape[0] != self.cache.batch:
160
+ raise ValueError(f"batch {idx.shape[0]} != decoder batch {self.cache.batch}")
161
+ self.cache.reset(pad_lens)
162
+ with self._autocast():
163
+ logits, _ = self.model(idx, cache=self.cache)
164
+ return logits
165
+
166
+ def _forward(self) -> Tensor:
167
+ with self._autocast():
168
+ logits, _ = self.model(self.tok, cache=self.cache)
169
+ return logits
170
+
171
+ def _capture(self, window: int) -> tuple[torch.cuda.CUDAGraph, Tensor]:
172
+ # Warm up on a side stream (lazy cuBLAS / SDPA setup must not happen during capture).
173
+ # The warm-up runs the real step: its K/V writes are the ones replay will make, and
174
+ # the position it advances is put back.
175
+ saved = self.cache.pos.clone()
176
+ side = torch.cuda.Stream()
177
+ side.wait_stream(torch.cuda.current_stream())
178
+ with torch.cuda.stream(side):
179
+ self._forward()
180
+ torch.cuda.current_stream().wait_stream(side)
181
+ self.cache.pos.copy_(saved)
182
+ graph = torch.cuda.CUDAGraph()
183
+ with torch.cuda.graph(graph, pool=self.pool):
184
+ out = self._forward()
185
+ return graph, out
186
+
187
+ def bucket(self, n_keys: int) -> int:
188
+ return next(b for b in self.buckets if b >= n_keys)
189
+
190
+ @torch.no_grad()
191
+ def step(self, tok: Tensor) -> Tensor:
192
+ """tok (B, 1) → logits (B, 1, V). With graphs, the result is a static buffer that
193
+ the next step overwrites: read it (or sample from it) first."""
194
+ c = self.cache
195
+ if c.n >= self.max_len:
196
+ raise ValueError(f"KV cache full: position {c.n + 1} > max_len {self.max_len}")
197
+ c.window = self.bucket(c.n + 1)
198
+ self.tok.copy_(tok)
199
+ if not self.use_graph:
200
+ out = self._forward()
201
+ else:
202
+ if c.window not in self.graphs:
203
+ self.graphs[c.window] = self._capture(c.window)
204
+ graph, out = self.graphs[c.window]
205
+ graph.replay()
206
+ c.n += 1
207
+ return out
208
+
209
+
210
+ class SlotGraphDecoder:
211
+ """The continuous engine's decode step (one token per SlotKVCache slot) as CUDA graphs
212
+ (4.11b). Admission, release and prefill stay eager in the engine.
213
+
214
+ Why it can be a graph: the batch is always n_slots rows (free slots compute too), and
215
+ slot lengths / pad starts / active flags live in fixed device tensors that admit() and
216
+ release() update in place. The one changing shape is the attention window, so the cache
217
+ gets a fixed window from the length buckets, with keys past each slot's length masked.
218
+
219
+ Each step writes the token into a static buffer and replays the graph for the smallest
220
+ bucket that holds the longest slot + 1. The replay advances the device lengths; the host
221
+ lengths move in advance_host(). use_graph=False runs the identical windowed step eagerly,
222
+ the reference that replay must match bit for bit.
223
+ """
224
+
225
+ def __init__(self, model: Transformer, slots, buckets: tuple[int, ...] | None = None,
226
+ use_graph: bool = True):
227
+ device = next(model.parameters()).device
228
+ if device.type != "cuda":
229
+ raise ValueError("CUDA graphs need a CUDA model")
230
+ if not hasattr(slots, "advance_host"):
231
+ raise ValueError("SlotGraphDecoder supports the slot cache (kv='slots') only")
232
+ self.model, self.slots = model, slots
233
+ self.buckets = tuple(sorted(buckets)) if buckets else default_buckets(slots.max_len)
234
+ if self.buckets[-1] < slots.max_len:
235
+ raise ValueError(f"largest bucket {self.buckets[-1]} < max_len {slots.max_len}")
236
+ self.tok = torch.zeros(slots.n_slots, 1, dtype=torch.long, device=device)
237
+ self.use_graph = use_graph
238
+ self.graphs: dict[int, tuple[torch.cuda.CUDAGraph, Tensor]] = {}
239
+ self.pool = torch.cuda.graph_pool_handle()
240
+
241
+ def _forward(self) -> Tensor:
242
+ with torch.autocast("cuda", dtype=torch.bfloat16, cache_enabled=False):
243
+ logits, _ = self.model(self.tok, cache=self.slots)
244
+ return logits
245
+
246
+ def _capture(self) -> tuple[torch.cuda.CUDAGraph, Tensor]:
247
+ # The warm-up runs the real step (its K/V writes are the ones replay will make) and
248
+ # advances the lengths; capture runs the host half again. Put both back each time.
249
+ c = self.slots
250
+ lens, lens_dev = list(c.lens), c._lens_dev.clone()
251
+ side = torch.cuda.Stream()
252
+ side.wait_stream(torch.cuda.current_stream())
253
+ with torch.cuda.stream(side):
254
+ self._forward()
255
+ torch.cuda.current_stream().wait_stream(side)
256
+ c.lens[:] = lens
257
+ c._lens_dev.copy_(lens_dev)
258
+ graph = torch.cuda.CUDAGraph()
259
+ with torch.cuda.graph(graph, pool=self.pool):
260
+ out = self._forward()
261
+ c.lens[:] = lens
262
+ return graph, out
263
+
264
+ def bucket(self, n_keys: int) -> int:
265
+ return next(b for b in self.buckets if b >= n_keys)
266
+
267
+ @torch.no_grad()
268
+ def capture(self, windows: tuple[int, ...] | None = None) -> float:
269
+ """Capture graphs ahead of time (default: every bucket), so no timed step pays for it.
270
+ Safe between steps: the warm-up's K/V writes land at each slot's next position, which
271
+ the next real step overwrites. Returns the seconds it took."""
272
+ t0 = time.perf_counter()
273
+ c = self.slots
274
+ try:
275
+ for w in windows or self.buckets:
276
+ if w not in self.graphs:
277
+ c.window = w
278
+ self.graphs[w] = self._capture()
279
+ finally:
280
+ c.window = None
281
+ torch.cuda.synchronize()
282
+ return time.perf_counter() - t0
283
+
284
+ @torch.no_grad()
285
+ def step(self, tok: Tensor) -> Tensor:
286
+ """tok (n_slots, 1) → logits (n_slots, 1, V). With graphs, the result is a static
287
+ buffer that the next step overwrites: sample from it first."""
288
+ c = self.slots
289
+ need = max(c.lens) + 1
290
+ if need > c.max_len:
291
+ raise ValueError(f"a slot is full (max_len {c.max_len})")
292
+ c.window = self.bucket(need)
293
+ self.tok.copy_(tok)
294
+ try:
295
+ if not self.use_graph:
296
+ return self._forward()
297
+ if c.window not in self.graphs:
298
+ self.graphs[c.window] = self._capture()
299
+ graph, out = self.graphs[c.window]
300
+ graph.replay()
301
+ c.advance_host()
302
+ return out
303
+ finally:
304
+ c.window = None
src/tinyllm/data.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dataset loading and text cleanup (D-008, D-009, D-018).
2
+
3
+ Every step that reads TinyStories (tokenizer training, tokenization) goes
4
+ through `load_clean`, so they all see exactly the same text.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import torch
11
+ from datasets import Dataset, load_dataset
12
+
13
+ DATASET = "roneneldan/TinyStories"
14
+ DATA_DIR = Path(__file__).resolve().parents[2] / "data" # git-ignored
15
+ TOKEN_DTYPE = np.uint16 # vocab 8192 < 65536 (D-009)
16
+
17
+ # D-018: TinyStories has UTF-8 punctuation that was decoded as Windows-1252
18
+ # ("mojibake"), e.g. “ -> "“". Map it back to plain ASCII.
19
+ # Order matters: the 3-character sequences must run before the bare "â€".
20
+ _REPLACEMENTS: list[tuple[str, str]] = [
21
+ ("“", '"'), # “ left double quote
22
+ ("â€\x9d", '"'), # ” right double quote (when the 3rd byte survived)
23
+ ("’", "'"), # ’ apostrophe / right single quote
24
+ ("‘", "'"), # ‘ left single quote
25
+ ("–", "-"), # – en dash
26
+ ("—", "-"), # — em dash
27
+ ("…", "..."), # … ellipsis
28
+ ("â€", '"'), # ” right double quote whose 3rd byte (0x9D) was lost
29
+ ("Â\xa0", " "), # no-break space
30
+ ("Â\xad", ""), # soft hyphen
31
+ # Real (correctly encoded) typographic punctuation -> ASCII.
32
+ ("“", '"'), ("”", '"'), ("‘", "'"), ("’", "'"),
33
+ ("–", "-"), ("—", "-"), ("…", "..."), ("\xa0", " "), ("\xad", ""),
34
+ ]
35
+
36
+
37
+ def clean_text(text: str) -> str | None:
38
+ """Return cleaned ASCII text, or None if the story should be dropped.
39
+
40
+ Dropped: empty stories, and stories that still contain non-ASCII
41
+ characters after the replacements (rare leftovers such as 'é').
42
+ """
43
+ if not text.isascii():
44
+ for bad, good in _REPLACEMENTS:
45
+ if bad in text:
46
+ text = text.replace(bad, good)
47
+ text = text.strip()
48
+ if not text or not text.isascii():
49
+ return None
50
+ return text
51
+
52
+
53
+ def _clean_batch(batch: dict) -> dict:
54
+ return {"text": [clean_text(t) or "" for t in batch["text"]]}
55
+
56
+
57
+ def _keep_batch(batch: dict) -> list[bool]:
58
+ return [t != "" for t in batch["text"]]
59
+
60
+
61
+ def load_clean(split: str, num_proc: int = 4) -> Dataset:
62
+ """Load a TinyStories split ('train' or 'validation') with D-018 cleanup applied.
63
+
64
+ `datasets` caches the result, so only the first call does the work.
65
+ """
66
+ ds = load_dataset(DATASET, split=split)
67
+ # With split=... (and no streaming) this is always a Dataset. The check narrows
68
+ # the declared Union return type for type checkers, and fails loudly otherwise.
69
+ assert isinstance(ds, Dataset), f"expected Dataset, got {type(ds).__name__}"
70
+ ds = ds.map(_clean_batch, batched=True, num_proc=num_proc, desc=f"clean {split}")
71
+ return ds.filter(_keep_batch, batched=True, num_proc=num_proc, desc=f"drop {split}")
72
+
73
+
74
+ def bin_path(split: str) -> Path:
75
+ """'train' -> data/train.bin, 'validation' -> data/val.bin"""
76
+ return DATA_DIR / f"{'val' if split == 'validation' else split}.bin"
77
+
78
+
79
+ def write_tokens(split: str, tok, eos_id: int, batch_size: int = 10_000) -> tuple[int, int]:
80
+ """Encode a cleaned split into one flat uint16 file: story EOS story EOS ...
81
+
82
+ Streams batch by batch, so RAM use stays small. Writes to a .tmp file and
83
+ renames at the end, so a crash never leaves a half-written .bin behind.
84
+ Returns (n_stories, n_tokens).
85
+ """
86
+ ds = load_clean(split)
87
+ out = bin_path(split)
88
+ out.parent.mkdir(exist_ok=True)
89
+ tmp = out.with_suffix(".tmp")
90
+ n_tokens = 0
91
+ with open(tmp, "wb") as f:
92
+ for i in range(0, len(ds), batch_size):
93
+ encs = tok.encode_batch(ds[i: i + batch_size]["text"]) # Rust, multi-threaded
94
+ ids = np.fromiter(
95
+ (t for e in encs for t in (*e.ids, eos_id)), dtype=np.int64
96
+ )
97
+ assert ids.max() < np.iinfo(TOKEN_DTYPE).max
98
+ ids.astype(TOKEN_DTYPE).tofile(f)
99
+ n_tokens += len(ids)
100
+ tmp.replace(out)
101
+ return len(ds), n_tokens
102
+
103
+
104
+ def load_tokens(split: str) -> np.memmap:
105
+ """Memory-map a .bin file: no RAM cost, the OS pages it in on demand."""
106
+ return np.memmap(bin_path(split), dtype=TOKEN_DTYPE, mode="r")
107
+
108
+
109
+ def get_batch(
110
+ tokens: np.ndarray,
111
+ batch_size: int,
112
+ seq_len: int,
113
+ rng: np.random.Generator,
114
+ device: str | torch.device = "cpu",
115
+ ) -> tuple[torch.Tensor, torch.Tensor]:
116
+ """Random training windows: x = tokens[s : s+T], y = tokens[s+1 : s+T+1].
117
+
118
+ Windows start anywhere and cross story boundaries; EOS tells the model where a
119
+ story ends. Sampling with replacement from a seeded `rng` makes runs reproducible,
120
+ and saving `rng.bit_generator.state` in a checkpoint makes resume exact.
121
+ On CUDA the batch goes through pinned memory so the copy can overlap with compute.
122
+ """
123
+ if len(tokens) < seq_len + 1:
124
+ raise ValueError(f"need at least {seq_len + 1} tokens, got {len(tokens)}")
125
+ starts = rng.integers(0, len(tokens) - seq_len, size=batch_size) # last valid start: len-T-1
126
+ idx = starts[:, None] + np.arange(seq_len + 1) # (B, T+1)
127
+ chunk = torch.from_numpy(tokens[idx].astype(np.int64)) # gather, then widen for embedding
128
+ if torch.device(device).type == "cuda":
129
+ chunk = chunk.pin_memory().to(device, non_blocking=True)
130
+ return chunk[:, :-1], chunk[:, 1:]
src/tinyllm/engine.py ADDED
@@ -0,0 +1,431 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 4.4: serving many requests: static batching vs continuous batching.
2
+
3
+ Workload model: offline, every request is queued at t=0 (no arrival process yet).
4
+
5
+ - run_static: take requests in order, B at a time. Left-pad the prompts, prefill once,
6
+ decode until the batch's *longest* request is done. Rows that finish early keep
7
+ computing (wasted work), and the next batch waits for the slowest row.
8
+ - run_continuous: S slots (SlotKVCache). Every step, queued requests are admitted into
9
+ free slots (one batched, padded prefill for the newcomers), then every slot decodes
10
+ one token together. A finished request frees its slot at once.
11
+
12
+ Both return per-request Results (generated tokens, TTFT, latency) and Stats (throughput,
13
+ wasted decode rows, KV memory reserved vs used). Correctness: each request's tokens must
14
+ equal its solo generate() output (tests/test_engine.py).
15
+ """
16
+
17
+ import math
18
+ import time
19
+ from collections import deque
20
+ from dataclasses import dataclass, field
21
+
22
+ import torch
23
+ from torch import Tensor
24
+
25
+ from tinyllm.generate import sample_next
26
+ from tinyllm.kv_cache import KVCache, SlotKVCache
27
+ from tinyllm.model import Transformer
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Sampling:
32
+ """How to pick tokens (sample_next's settings). Frozen, so equal settings group together."""
33
+ temperature: float = 0.0 # 0 = greedy
34
+ top_k: int | None = None
35
+ top_p: float | None = None
36
+
37
+ def __post_init__(self):
38
+ if not self.temperature >= 0:
39
+ raise ValueError(f"temperature must be >= 0, got {self.temperature}")
40
+ if self.top_k is not None and self.top_k < 1:
41
+ raise ValueError(f"top_k must be >= 1, got {self.top_k}")
42
+ if self.top_p is not None and not 0.0 < self.top_p <= 1.0:
43
+ raise ValueError(f"top_p must be in (0, 1], got {self.top_p}")
44
+
45
+
46
+ @dataclass
47
+ class Request:
48
+ prompt: list[int]
49
+ max_new_tokens: int
50
+ eos_id: int | None = None
51
+ sampling: Sampling | None = None # ContinuousEngine only; None = the engine's settings
52
+
53
+
54
+ @dataclass
55
+ class Result:
56
+ tokens: list[int] # generated tokens only (EOS included if it was produced)
57
+ ttft_s: float # engine start → first generated token
58
+ latency_s: float # engine start → last generated token
59
+
60
+
61
+ @dataclass
62
+ class Stats:
63
+ wall_s: float = 0.0
64
+ useful_tokens: int = 0 # generated tokens that belong to a request
65
+ decode_steps: int = 0
66
+ computed_rows: int = 0 # rows run through decode (incl. finished rows / free slots)
67
+ useful_rows: int = 0 # rows whose output was kept
68
+ kv_reserved_tokens: int = 0 # KV capacity held (max over the run)
69
+ kv_used_frac: list[float] = field(default_factory=list) # per decode step: real tokens / capacity
70
+ kv_alloc_frac: list[float] = field(default_factory=list) # per decode step: real tokens / claimed
71
+ kv_bytes_per_token: int = 0
72
+ preemptions: int = 0
73
+ peak_active: int = 0
74
+ kv_allocated_at_end: int = 0 # leak check: must be 0 once every request is done
75
+
76
+ def summary(self, results: list[Result]) -> dict:
77
+ lat = sorted(r.latency_s for r in results)
78
+ ttft = sorted(r.ttft_s for r in results)
79
+ p95 = lambda xs: xs[min(len(xs) - 1, math.ceil(0.95 * len(xs)) - 1)]
80
+ return {
81
+ "requests": len(results),
82
+ "wall_s": self.wall_s,
83
+ "useful_tokens": self.useful_tokens,
84
+ "tok_s": self.useful_tokens / self.wall_s,
85
+ "decode_steps": self.decode_steps,
86
+ "row_util": self.useful_rows / max(1, self.computed_rows),
87
+ "latency_mean_s": sum(lat) / len(lat),
88
+ "latency_p95_s": p95(lat),
89
+ "ttft_mean_s": sum(ttft) / len(ttft),
90
+ "ttft_p95_s": p95(ttft),
91
+ "kv_reserved_mib": self.kv_reserved_tokens * self.kv_bytes_per_token / 2**20,
92
+ "kv_used_mean": sum(self.kv_used_frac) / max(1, len(self.kv_used_frac)),
93
+ "kv_used_peak": max(self.kv_used_frac, default=0.0),
94
+ "kv_alloc_used_mean": (sum(self.kv_alloc_frac) / len(self.kv_alloc_frac)
95
+ if self.kv_alloc_frac else None),
96
+ "preemptions": self.preemptions,
97
+ "peak_active": self.peak_active,
98
+ "kv_allocated_at_end": self.kv_allocated_at_end,
99
+ }
100
+
101
+
102
+ def _autocast(device: torch.device):
103
+ return torch.autocast(device.type, dtype=torch.bfloat16, enabled=device.type == "cuda")
104
+
105
+
106
+ def _kv_bytes_per_token(model: Transformer, device: torch.device) -> int:
107
+ elem = 2 if device.type == "cuda" else 4 # bf16 under autocast, else fp32
108
+ return sum(layer.self_attn.cache_bytes_per_token(elem) for layer in model.layers)
109
+
110
+
111
+ @torch.no_grad()
112
+ def prefill_padded(model: Transformer, prompts: list[list[int]], max_len: int,
113
+ pad_id: int = 0) -> tuple[Tensor, KVCache, list[int]]:
114
+ """Left-pad prompts to one length, run one prefill. Returns last-position logits (B, V),
115
+ the filled cache (pos = padded length) and each row's pad count."""
116
+ device = next(model.parameters()).device
117
+ L = max(len(p) for p in prompts)
118
+ pad = [L - len(p) for p in prompts]
119
+ idx = torch.tensor([[pad_id] * n + p for n, p in zip(pad, prompts)], device=device)
120
+ cache = KVCache(model.cfg.n_layers, max_len, pad_lens=torch.tensor(pad, device=device))
121
+ with _autocast(device):
122
+ logits, _ = model(idx, cache=cache)
123
+ return logits[:, -1], cache, pad
124
+
125
+
126
+ def _done(req: Request, toks: list[int]) -> bool:
127
+ return len(toks) >= req.max_new_tokens or (req.eos_id is not None and toks[-1] == req.eos_id)
128
+
129
+
130
+ @torch.no_grad()
131
+ def run_static(model: Transformer, requests: list[Request], batch_size: int,
132
+ temperature: float = 0.0, top_k=None, top_p=None, generator=None,
133
+ ) -> tuple[list[Result], dict]:
134
+ if any(r.sampling is not None for r in requests):
135
+ raise ValueError("run_static uses one sampling setting; per-request sampling needs ContinuousEngine")
136
+ model.eval()
137
+ device = next(model.parameters()).device
138
+ pick = dict(temperature=temperature, top_k=top_k, top_p=top_p, generator=generator)
139
+ stats = Stats(kv_bytes_per_token=_kv_bytes_per_token(model, device))
140
+ results: list[Result | None] = [None] * len(requests)
141
+ t0 = time.perf_counter()
142
+ for b0 in range(0, len(requests), batch_size):
143
+ batch = requests[b0:b0 + batch_size]
144
+ L = max(len(r.prompt) for r in batch)
145
+ new_max = max(r.max_new_tokens for r in batch)
146
+ if L + new_max > model.cfg.max_seq_len:
147
+ raise ValueError(f"padded prompt {L} + new {new_max} exceeds max_seq_len")
148
+ logits, cache, _ = prefill_padded(model, [r.prompt for r in batch], L + new_max)
149
+ capacity = len(batch) * (L + new_max)
150
+ stats.kv_reserved_tokens = max(stats.kv_reserved_tokens, capacity)
151
+ outs: list[list[int]] = [[] for _ in batch]
152
+ ttft = [0.0] * len(batch)
153
+ done = [False] * len(batch)
154
+ while True:
155
+ nxt = sample_next(logits, **pick)
156
+ toks = nxt.squeeze(-1).tolist() # GPU sync: tokens to the host
157
+ now = time.perf_counter() - t0
158
+ for j, (r, t) in enumerate(zip(batch, toks)):
159
+ if done[j]:
160
+ continue
161
+ outs[j].append(t)
162
+ if len(outs[j]) == 1:
163
+ ttft[j] = now
164
+ if _done(r, outs[j]):
165
+ done[j] = True
166
+ results[b0 + j] = Result(outs[j], ttft[j], now)
167
+ if all(done):
168
+ break
169
+ live = [j for j in range(len(batch)) if not done[j]]
170
+ stats.decode_steps += 1
171
+ stats.computed_rows += len(batch)
172
+ stats.useful_rows += len(live)
173
+ stats.kv_used_frac.append(
174
+ sum(len(batch[j].prompt) + len(outs[j]) for j in live) / capacity)
175
+ with _autocast(device):
176
+ logits, _ = model(nxt, cache=cache)
177
+ logits = logits[:, -1]
178
+ stats.wall_s = time.perf_counter() - t0
179
+ stats.useful_tokens = sum(len(r.tokens) for r in results)
180
+ return results, stats.summary(results)
181
+
182
+
183
+ @dataclass
184
+ class Event:
185
+ """One generated token, as step() reports it (a server streams these)."""
186
+ rid: int
187
+ token: int
188
+ done: bool # the request finished with this token (EOS or max_new_tokens)
189
+
190
+
191
+ class ContinuousEngine:
192
+ """Step 4.13: run_continuous's loop as an object, so requests can arrive while others
193
+ decode. add_request() queues a request at any time; each step() admits queued requests
194
+ into free slots (one padded prefill), decodes one token for every active slot and
195
+ returns the new tokens as Events. A finished request's Result is in `results` (pop it).
196
+
197
+ Times are seconds on the engine clock (since construction). TTFT and latency count from
198
+ the request's arrival, so with every request added at once they match 4.4's numbers.
199
+ Sampling: the engine's settings are the default; a request's `sampling` overrides them.
200
+ Rows with equal settings are sampled in one call, so when every row uses the same
201
+ settings the result (and RNG use) is exactly as with per-engine settings. One generator
202
+ is shared by all rows. Not thread-safe: call add_request / step / abort from one thread.
203
+
204
+ kv="slots": SlotKVCache, every slot reserves max_len positions (4.4).
205
+ kv="paged": PagedKVCache with kv_blocks blocks of block_size tokens (4.5). Default
206
+ kv_blocks = the same memory as the slot cache. Sequences beyond what the blocks hold are
207
+ preempted and recomputed later.
208
+ cuda_graph (4.11b, CUDA, kv="slots" only): the decode step is replayed as a CUDA graph per
209
+ attention-window bucket (cuda_graph.SlotGraphDecoder); prefill stays eager. True builds a
210
+ decoder for this engine; pass a SlotGraphDecoder to reuse its cache and captured graphs.
211
+ """
212
+
213
+ def __init__(self, model: Transformer, n_slots: int, max_len: int | None = None,
214
+ temperature: float = 0.0, top_k=None, top_p=None, generator=None,
215
+ kv: str = "slots", block_size: int = 16, kv_blocks: int | None = None,
216
+ cuda_graph=False, buckets: tuple[int, ...] | None = None):
217
+ model.eval()
218
+ cfg = model.cfg
219
+ self.model = model
220
+ self.device = device = next(model.parameters()).device
221
+ self.n_slots = n_slots
222
+ self.max_len = max_len = max_len or cfg.max_seq_len
223
+ self.sampling = Sampling(temperature, top_k, top_p) # the default for every request
224
+ self.generator = generator
225
+ dtype = torch.bfloat16 if device.type == "cuda" else torch.float32
226
+ if cuda_graph is not False and kv != "slots":
227
+ raise ValueError("cuda_graph supports kv='slots' only (paged: not yet)")
228
+ self.decoder = None
229
+ if cuda_graph is not False and not isinstance(cuda_graph, bool): # a SlotGraphDecoder
230
+ self.decoder, slots = cuda_graph, cuda_graph.slots
231
+ if slots.n_slots != n_slots or slots.max_len != max_len or any(slots.active):
232
+ raise ValueError(f"decoder cache has {slots.n_slots} slots of {slots.max_len} "
233
+ f"(active: {sum(slots.active)}); need {n_slots} idle slots of {max_len}")
234
+ elif kv == "slots":
235
+ slots = SlotKVCache(cfg.n_layers, n_slots, cfg.n_kv_heads, cfg.head_dim, max_len, dtype, device)
236
+ elif kv == "paged":
237
+ from tinyllm.paged_kv import PagedKVCache
238
+ n_blocks = kv_blocks or n_slots * math.ceil(max_len / block_size)
239
+ slots = PagedKVCache(cfg.n_layers, n_slots, cfg.n_kv_heads, cfg.head_dim, n_blocks,
240
+ block_size, max_len, dtype, device)
241
+ else:
242
+ raise ValueError(f"kv must be 'slots' or 'paged', got {kv!r}")
243
+ if cuda_graph is True:
244
+ from tinyllm.cuda_graph import SlotGraphDecoder
245
+ self.decoder = SlotGraphDecoder(model, slots, buckets=buckets)
246
+ self.slots = slots
247
+ self.stats = Stats(kv_bytes_per_token=_kv_bytes_per_token(model, device),
248
+ kv_reserved_tokens=slots.reserved_tokens())
249
+ self.results: dict[int, Result] = {} # finished, until the caller pops them
250
+ self._req: dict[int, Request] = {} # unfinished requests only
251
+ self._arrival: dict[int, float] = {}
252
+ self._outs: dict[int, list[int]] = {}
253
+ self._ttft: dict[int, float] = {}
254
+ self._slot_req: list[int | None] = [None] * n_slots
255
+ self._last_tok = torch.zeros(n_slots, 1, dtype=torch.long, device=device)
256
+ self._queue: deque[int] = deque()
257
+ self._next_id = 0
258
+ self._t0 = time.perf_counter()
259
+
260
+ # -- public API ------------------------------------------------------------
261
+ def now(self) -> float:
262
+ return time.perf_counter() - self._t0
263
+
264
+ def add_request(self, req: Request) -> int:
265
+ """Queue a request; returns its id. Raises at once if it can never fit a slot."""
266
+ if not req.prompt or req.max_new_tokens < 1:
267
+ raise ValueError("a request needs a non-empty prompt and max_new_tokens >= 1")
268
+ if len(req.prompt) + req.max_new_tokens > self.max_len:
269
+ raise ValueError(f"prompt {len(req.prompt)} + new {req.max_new_tokens} "
270
+ f"exceeds slot max_len {self.max_len}")
271
+ rid, self._next_id = self._next_id, self._next_id + 1
272
+ self._req[rid] = req
273
+ self._arrival[rid] = self.now()
274
+ self._queue.append(rid)
275
+ return rid
276
+
277
+ def has_work(self) -> bool:
278
+ return bool(self._queue) or any(self.slots.active)
279
+
280
+ def load(self) -> tuple[int, int]:
281
+ """(requests waiting for a slot, slots decoding a request)."""
282
+ return len(self._queue), sum(self.slots.active)
283
+
284
+ def kv_used_frac(self) -> float:
285
+ return self.slots.used_tokens() / max(1, self.slots.reserved_tokens())
286
+
287
+ def abort(self, rid: int) -> bool:
288
+ """Drop an unfinished request (e.g. its client went away), freeing its slot.
289
+ Returns False if it is unknown or already finished. No Result is kept."""
290
+ if rid not in self._req:
291
+ return False
292
+ if rid in self._queue:
293
+ self._queue.remove(rid)
294
+ if rid in self._slot_req:
295
+ s = self._slot_req.index(rid)
296
+ self._slot_req[s] = None
297
+ self.slots.release(s)
298
+ self._forget(rid)
299
+ return True
300
+
301
+ @torch.no_grad()
302
+ def step(self) -> list[Event]:
303
+ """Admit what fits, decode one token per active slot. Returns the new tokens."""
304
+ events: list[Event] = []
305
+ slots = self.slots
306
+ free = slots.free_slots()
307
+ rids: list[int] = []
308
+ if free and self._queue: # admit newcomers: one padded prefill
309
+ q = self._queue
310
+ if not self._fits_padded([q[0]]):
311
+ r = q[0]
312
+ raise ValueError(f"request {r}: prompt {len(self._context(r))} + new {self._left(r)} "
313
+ f"exceeds slot max_len {self.max_len}")
314
+ while (q and len(rids) < len(free) and self._fits_padded(rids + [q[0]])
315
+ and slots.can_admit([len(self._context(r)) for r in rids + [q[0]]])):
316
+ rids.append(q.popleft())
317
+ if not rids and not any(slots.active):
318
+ raise ValueError(f"request {q[0]} does not fit in the KV cache even alone")
319
+ if rids:
320
+ prompts = [self._context(r) for r in rids]
321
+ L = max(len(p) for p in prompts)
322
+ logits, pre, pad = prefill_padded(self.model, prompts, max_len=L)
323
+ nxt = self._sample(logits, [self._sampling(r) for r in rids])
324
+ taken = free[:len(rids)]
325
+ slots.admit(taken, pre, pad)
326
+ self._last_tok[torch.tensor(taken, device=self.device)] = nxt
327
+ toks = nxt.squeeze(-1).tolist()
328
+ now = self.now()
329
+ for s, rid, t in zip(taken, rids, toks):
330
+ self._slot_req[s] = rid
331
+ self._outs.setdefault(rid, [])
332
+ events.append(self._record(s, rid, t, now))
333
+ for s in slots.reserve_step(): # paged: out of blocks → preempt newest
334
+ self._queue.appendleft(self._slot_req[s]) # recompute later from its context
335
+ self._slot_req[s] = None
336
+ self.stats.preemptions += 1
337
+ if not any(slots.active):
338
+ return events
339
+ st = self.stats
340
+ live = [s for s in range(self.n_slots) if slots.active[s]]
341
+ st.peak_active = max(st.peak_active, len(live))
342
+ st.decode_steps += 1
343
+ st.computed_rows += self.n_slots
344
+ st.useful_rows += len(live)
345
+ used = slots.used_tokens()
346
+ st.kv_used_frac.append(used / slots.reserved_tokens())
347
+ st.kv_alloc_frac.append(used / max(1, slots.allocated_tokens()))
348
+ if self.decoder is not None:
349
+ logits = self.decoder.step(self._last_tok) # static buffer: sample at once
350
+ else:
351
+ with _autocast(self.device):
352
+ logits, _ = self.model(self._last_tok, cache=slots) # one token per slot
353
+ rows = [self._sampling(r) if r is not None else self.sampling for r in self._slot_req]
354
+ self._last_tok = self._sample(logits[:, -1], rows) # idle rows: engine default
355
+ toks = self._last_tok.squeeze(-1).tolist()
356
+ now = self.now()
357
+ for s in live:
358
+ events.append(self._record(s, self._slot_req[s], toks[s], now))
359
+ return events
360
+
361
+ # -- internals -------------------------------------------------------------
362
+ def _context(self, rid: int) -> list[int]:
363
+ """Prompt plus tokens generated so far (non-empty only after a preemption)."""
364
+ return self._req[rid].prompt + self._outs.get(rid, [])
365
+
366
+ def _sampling(self, rid: int) -> Sampling:
367
+ return self._req[rid].sampling or self.sampling
368
+
369
+ def _sample(self, logits: Tensor, rows: list[Sampling]) -> Tensor:
370
+ """Next token per row (B, V) → (B, 1), each row with its own settings. One
371
+ sample_next call per distinct setting, in order of first appearance."""
372
+ groups: dict[Sampling, list[int]] = {}
373
+ for i, s in enumerate(rows):
374
+ groups.setdefault(s, []).append(i)
375
+ if len(groups) == 1:
376
+ (s,) = groups
377
+ return sample_next(logits, s.temperature, s.top_k, s.top_p, self.generator)
378
+ out = torch.empty(logits.size(0), 1, dtype=torch.long, device=logits.device)
379
+ for s, idx in groups.items():
380
+ ix = torch.tensor(idx, device=logits.device)
381
+ out[ix] = sample_next(logits[ix], s.temperature, s.top_k, s.top_p, self.generator)
382
+ return out
383
+
384
+ def _left(self, rid: int) -> int:
385
+ return self._req[rid].max_new_tokens - len(self._outs.get(rid, []))
386
+
387
+ def _fits_padded(self, rids: list[int]) -> bool:
388
+ """Every row of a padded prefill sits at positions up to L + its remaining tokens, so
389
+ that must fit max_len (the RoPE table, and slot storage). A preempted request comes back
390
+ with a longer context, so this can fail for a batch even when each request fits alone."""
391
+ return max(len(self._context(r)) for r in rids) + max(self._left(r) for r in rids) <= self.max_len
392
+
393
+ def _record(self, s: int, rid: int, tok: int, now: float) -> Event:
394
+ outs = self._outs[rid]
395
+ outs.append(tok)
396
+ if len(outs) == 1:
397
+ self._ttft[rid] = now
398
+ done = _done(self._req[rid], outs)
399
+ if done:
400
+ t_in = self._arrival[rid]
401
+ self.results[rid] = Result(outs, self._ttft[rid] - t_in, now - t_in)
402
+ self.stats.useful_tokens += len(outs)
403
+ self._slot_req[s] = None
404
+ self.slots.release(s)
405
+ self._forget(rid)
406
+ return Event(rid, tok, done)
407
+
408
+ def _forget(self, rid: int) -> None:
409
+ for d in (self._req, self._arrival, self._outs, self._ttft):
410
+ d.pop(rid, None)
411
+
412
+
413
+ @torch.no_grad()
414
+ def run_continuous(model: Transformer, requests: list[Request], n_slots: int,
415
+ max_len: int | None = None, temperature: float = 0.0, top_k=None,
416
+ top_p=None, generator=None, kv: str = "slots", block_size: int = 16,
417
+ kv_blocks: int | None = None, cuda_graph=False,
418
+ buckets: tuple[int, ...] | None = None) -> tuple[list[Result], dict]:
419
+ """Offline continuous batching (4.4): every request queued at t=0, run to completion.
420
+ A thin wrapper over ContinuousEngine; see it for kv / cuda_graph."""
421
+ eng = ContinuousEngine(model, n_slots, max_len, temperature, top_k, top_p, generator,
422
+ kv, block_size, kv_blocks, cuda_graph, buckets)
423
+ t0 = time.perf_counter()
424
+ rids = [eng.add_request(r) for r in requests]
425
+ while eng.has_work():
426
+ eng.step()
427
+ st = eng.stats
428
+ st.kv_allocated_at_end = eng.slots.allocated_tokens()
429
+ st.wall_s = time.perf_counter() - t0
430
+ results = [eng.results.pop(r) for r in rids]
431
+ return results, st.summary(results)
src/tinyllm/generate.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Text generation.
2
+
3
+ - sample(): naive, a full forward pass over the context per new token (no cache).
4
+ Used for training samples, and the reference that every faster path must match (4.1).
5
+ - generate(): KV-cached, one prefill pass then one new token per forward (4.3).
6
+ cuda_graph=True replays each decode step as one CUDA graph (4.11, tinyllm.cuda_graph).
7
+ Both choose tokens with sample_next (4.2).
8
+ """
9
+
10
+ import torch
11
+ from torch import Tensor
12
+
13
+ from tinyllm.kv_cache import KVCache
14
+ from tinyllm.model import Transformer
15
+
16
+
17
+ def sample_next(
18
+ logits: Tensor,
19
+ temperature: float = 1.0,
20
+ top_k: int | None = None,
21
+ top_p: float | None = None,
22
+ generator: torch.Generator | None = None,
23
+ ) -> Tensor:
24
+ """Pick the next token from last-position logits (B, V) → (B, 1). Step 4.2.
25
+
26
+ Order (same as HF's warpers): temperature, then top-k, then top-p.
27
+ - temperature 0 means greedy (argmax); top_k / top_p are then irrelevant.
28
+ - top_k keeps the k most likely tokens.
29
+ - top_p (nucleus) keeps the smallest set of most likely tokens whose total
30
+ probability is >= top_p. The most likely token is always kept.
31
+ """
32
+ if top_k is not None and top_k < 1:
33
+ raise ValueError(f"top_k must be >= 1, got {top_k}")
34
+ if top_p is not None and not 0.0 < top_p <= 1.0:
35
+ raise ValueError(f"top_p must be in (0, 1], got {top_p}")
36
+ logits = logits.float()
37
+ if temperature == 0:
38
+ return logits.argmax(-1, keepdim=True)
39
+ logits = logits / temperature
40
+ if top_k is not None:
41
+ kth = torch.topk(logits, min(top_k, logits.size(-1))).values[:, -1:]
42
+ logits = logits.masked_fill(logits < kth, float("-inf"))
43
+ if top_p is not None and top_p < 1.0:
44
+ sorted_logits, order = logits.sort(-1, descending=True)
45
+ probs = sorted_logits.softmax(-1)
46
+ mass_before = probs.cumsum(-1) - probs # probability of all strictly better tokens
47
+ drop_sorted = mass_before >= top_p # first token has mass_before 0: always kept
48
+ drop = drop_sorted.scatter(-1, order, drop_sorted)
49
+ logits = logits.masked_fill(drop, float("-inf"))
50
+ return torch.multinomial(logits.softmax(-1), 1, generator=generator)
51
+
52
+
53
+ @torch.no_grad()
54
+ def sample(
55
+ model: Transformer,
56
+ idx: Tensor,
57
+ max_new_tokens: int,
58
+ temperature: float = 1.0,
59
+ top_k: int | None = None,
60
+ eos_id: int | None = None,
61
+ generator: torch.Generator | None = None,
62
+ top_p: float | None = None,
63
+ ) -> Tensor:
64
+ """Append up to max_new_tokens to idx (1, T). Stops early when eos_id is sampled.
65
+
66
+ Token choice is sample_next (temperature, top_k, top_p).
67
+ """
68
+ was_training = model.training
69
+ model.eval()
70
+ max_len = model.cfg.max_seq_len
71
+ for _ in range(max_new_tokens):
72
+ context = idx[:, -max_len:] # no cache: the window slides once the context is full
73
+ with torch.autocast(idx.device.type, dtype=torch.bfloat16, enabled=idx.device.type == "cuda"):
74
+ logits, _ = model(context)
75
+ nxt = sample_next(logits[:, -1, :], temperature, top_k, top_p, generator)
76
+ idx = torch.cat([idx, nxt], dim=1)
77
+ if eos_id is not None and nxt.item() == eos_id:
78
+ break
79
+ model.train(was_training)
80
+ return idx
81
+
82
+
83
+ @torch.no_grad()
84
+ def generate(
85
+ model: Transformer,
86
+ idx: Tensor,
87
+ max_new_tokens: int,
88
+ temperature: float = 1.0,
89
+ top_k: int | None = None,
90
+ top_p: float | None = None,
91
+ eos_id: int | None = None,
92
+ generator: torch.Generator | None = None,
93
+ cuda_graph=False,
94
+ ) -> Tensor:
95
+ """KV-cached generation. Append up to max_new_tokens to idx (B, T).
96
+
97
+ Prefill runs the prompt once and fills the cache; each decode step feeds only the
98
+ newest token. Stops early once every row has produced eos_id (rows that finish
99
+ first keep generating; the caller trims after EOS).
100
+
101
+ Unlike sample(), the context can't slide: RoPE positions are baked into the cached
102
+ keys. So prompt + max_new_tokens must fit in max_seq_len.
103
+
104
+ cuda_graph: False (eager), True (build a GraphDecoder), or a GraphDecoder to reuse,
105
+ so the graphs it captured serve several calls with the same batch size (4.11).
106
+ """
107
+ B, T = idx.shape
108
+ max_len = model.cfg.max_seq_len
109
+ if T + max_new_tokens > max_len:
110
+ raise ValueError(f"prompt {T} + new {max_new_tokens} exceeds max_seq_len {max_len}")
111
+ was_training = model.training
112
+ model.eval()
113
+ if cuda_graph:
114
+ from tinyllm.cuda_graph import GraphDecoder
115
+ dec = cuda_graph if isinstance(cuda_graph, GraphDecoder) else GraphDecoder(model, B)
116
+ prefill, decode = dec.prefill, dec.step
117
+ else:
118
+ cache = KVCache(model.cfg.n_layers, max_len)
119
+ autocast = dict(device_type=idx.device.type, dtype=torch.bfloat16,
120
+ enabled=idx.device.type == "cuda")
121
+
122
+ def prefill(x: Tensor) -> Tensor:
123
+ with torch.autocast(**autocast):
124
+ return model(x, cache=cache)[0]
125
+ decode = prefill
126
+ done = torch.zeros(B, dtype=torch.bool, device=idx.device)
127
+ out = [idx]
128
+ logits = prefill(idx)
129
+ for i in range(max_new_tokens):
130
+ nxt = sample_next(logits[:, -1, :], temperature, top_k, top_p, generator)
131
+ out.append(nxt)
132
+ if eos_id is not None:
133
+ done |= nxt.squeeze(-1) == eos_id
134
+ if done.all():
135
+ break
136
+ if i + 1 == max_new_tokens:
137
+ break # no forward for a token we won't use
138
+ logits = decode(nxt) # one token per row
139
+ model.train(was_training)
140
+ return torch.cat(out, dim=1)
src/tinyllm/hf_llama.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Load a pretrained Hugging Face Llama checkpoint into our Transformer (step 4.9b, D-015 / D-026).
2
+
3
+ Only our code runs the model. Hugging Face pieces used here are file formats and downloads:
4
+ `huggingface_hub` fetches the files, our `safetensors_io` reads the weights, and `tokenizers`
5
+ reads `tokenizer.json`. HF Transformers is used only as the logits reference in tests (4.9c).
6
+
7
+ Our parameter names already follow HF Llama (embed_tokens, layers.N.self_attn.q_proj, ...,
8
+ input_layernorm, mlp.gate_proj, norm, lm_head), so the mapping just drops HF's "model." prefix.
9
+ RoPE uses the same rotate_half layout as HF, so q/k weights need no permutation.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+
17
+ import torch
18
+
19
+ from tinyllm.config import ModelConfig
20
+ from tinyllm.model import RotaryEmbedding, Transformer
21
+ from tinyllm.safetensors_io import load_file
22
+
23
+ TINYLLAMA = "TinyLlama/TinyLlama-1.1B-Chat-v1.0"
24
+ TINYLLAMA_REV = "fe8a4ea1ffedaf415f4da2f062534de366a451e6" # pinned (D-026)
25
+ FILES = ["config.json", "generation_config.json", "model.safetensors",
26
+ "tokenizer.json", "tokenizer_config.json", "special_tokens_map.json"]
27
+
28
+
29
+ def config_from_hf(hf: dict) -> ModelConfig:
30
+ """HF LlamaConfig dict -> ModelConfig. Raises on any feature our model doesn't implement."""
31
+ unsupported = []
32
+ if hf.get("model_type") != "llama":
33
+ unsupported.append(f"model_type={hf.get('model_type')!r}")
34
+ if hf.get("hidden_act", "silu") != "silu":
35
+ unsupported.append(f"hidden_act={hf['hidden_act']!r}")
36
+ if hf.get("rope_scaling"):
37
+ unsupported.append(f"rope_scaling={hf['rope_scaling']!r}")
38
+ # transformers >= 5 writes {"rope_parameters": {"rope_theta": ..., "rope_type": ...}} instead of
39
+ # a top-level rope_theta; scaled variants (linear, llama3, yarn, ...) are a rope_type there.
40
+ rp = hf.get("rope_parameters") or {}
41
+ if rp.get("rope_type", "default") != "default":
42
+ unsupported.append(f"rope_parameters={rp!r}")
43
+ rope_theta = rp.get("rope_theta", hf.get("rope_theta", 10000.0))
44
+ if hf.get("attention_bias") or hf.get("mlp_bias"):
45
+ unsupported.append("bias in attention / MLP")
46
+ if hf.get("pretraining_tp", 1) != 1:
47
+ unsupported.append(f"pretraining_tp={hf['pretraining_tp']}")
48
+ hd = hf.get("head_dim")
49
+ if hd is not None and hd != hf["hidden_size"] // hf["num_attention_heads"]:
50
+ unsupported.append(f"head_dim={hd} != hidden_size / num_attention_heads")
51
+ if unsupported:
52
+ raise ValueError("unsupported Llama config: " + ", ".join(unsupported))
53
+ return ModelConfig(
54
+ vocab_size=hf["vocab_size"],
55
+ max_seq_len=hf["max_position_embeddings"],
56
+ d_model=hf["hidden_size"],
57
+ n_layers=hf["num_hidden_layers"],
58
+ n_heads=hf["num_attention_heads"],
59
+ n_kv_heads=hf.get("num_key_value_heads", hf["num_attention_heads"]),
60
+ ffn_hidden=hf["intermediate_size"],
61
+ norm_eps=hf["rms_norm_eps"],
62
+ rope_theta=rope_theta,
63
+ init_std=hf.get("initializer_range", 0.02),
64
+ tie_embeddings=hf.get("tie_word_embeddings", False),
65
+ )
66
+
67
+
68
+ def hf_to_ours(name: str) -> str:
69
+ return name.removeprefix("model.")
70
+
71
+
72
+ def ours_to_hf(name: str) -> str:
73
+ return name if name == "lm_head.weight" else "model." + name
74
+
75
+
76
+ def download(repo: str = TINYLLAMA, revision: str = TINYLLAMA_REV, local_only: bool = False) -> Path:
77
+ """Snapshot directory in the HF cache (~/.cache/huggingface). Weights never go into the repo."""
78
+ from huggingface_hub import snapshot_download
79
+ return Path(snapshot_download(repo, revision=revision, allow_patterns=FILES, local_files_only=local_only))
80
+
81
+
82
+ def load_from_dir(path: str | Path, device: str = "cuda", dtype: torch.dtype = torch.bfloat16) -> Transformer:
83
+ """Build our Transformer from <path>/config.json and <path>/model.safetensors, strict.
84
+
85
+ The model is created on the meta device, so no fp32 copy is ever allocated (TinyLlama: 4.4 GB);
86
+ each weight is copied once from the memory-mapped file to `device` in `dtype` (D-023: bf16).
87
+ """
88
+ path = Path(path)
89
+ cfg = config_from_hf(json.loads((path / "config.json").read_text()))
90
+ state = {hf_to_ours(k): v for k, v in load_file(path / "model.safetensors", device, dtype).items()}
91
+ # Rotary inv_freq is a buffer in some HF exports; ours are recomputed and never stored.
92
+ state = {k: v for k, v in state.items() if not k.endswith("rotary_emb.inv_freq")}
93
+ if cfg.tie_embeddings:
94
+ state.pop("lm_head.weight", None) # tied: the embedding is the head
95
+ with torch.device("meta"):
96
+ model = Transformer(cfg)
97
+ expected = {k: v for k, v in model.state_dict().items() if not (cfg.tie_embeddings and k == "lm_head.weight")}
98
+ missing, unexpected = sorted(set(expected) - set(state)), sorted(set(state) - set(expected))
99
+ bad_shape = [k for k in expected.keys() & state.keys() if tuple(expected[k].shape) != tuple(state[k].shape)]
100
+ if missing or unexpected or bad_shape:
101
+ raise ValueError(f"checkpoint mismatch: missing {missing[:5]}, unexpected {unexpected[:5]}, "
102
+ f"shape {bad_shape[:5]}")
103
+ model.load_state_dict(state, strict=False, assign=True) # strictness checked above (tie aware)
104
+ if cfg.tie_embeddings:
105
+ model.lm_head.weight = model.embed_tokens.weight
106
+ # Non-persistent buffers were created on meta: rebuild the RoPE tables on the real device.
107
+ model.rope = RotaryEmbedding(cfg.head_dim, cfg.max_seq_len, cfg.rope_theta).to(device)
108
+ return model.eval()
109
+
110
+
111
+ def load_hf_llama(repo: str = TINYLLAMA, revision: str = TINYLLAMA_REV, device: str = "cuda",
112
+ dtype: torch.dtype = torch.bfloat16, local_only: bool = False) -> Transformer:
113
+ return load_from_dir(download(repo, revision, local_only), device, dtype)
114
+
115
+
116
+ def save_as_hf(model: Transformer, path: str | Path, eos_id: int | None = None, bos_id: int | None = None) -> None:
117
+ """Write our model as an HF-style Llama directory (config.json, generation_config.json,
118
+ model.safetensors). Used by tests and 4.8b.
119
+
120
+ The token ids are written explicitly, even as null: a config without them gets LlamaConfig's
121
+ defaults (bos 1, eos 2), and HF generate() would then stop on the wrong token.
122
+ """
123
+ from tinyllm.safetensors_io import save_file
124
+ c = model.cfg
125
+ other = sorted(set(c.layer_types) - {"mha", "gqa"})
126
+ if other:
127
+ raise ValueError(f"only mha / gqa layers map to Llama; this model has {other}")
128
+ path = Path(path)
129
+ path.mkdir(parents=True, exist_ok=True)
130
+ sd = model.state_dict()
131
+ if c.tie_embeddings:
132
+ sd.pop("lm_head.weight")
133
+ dtype = str(next(iter(sd.values())).dtype).removeprefix("torch.")
134
+ ids = {"bos_token_id": bos_id, "eos_token_id": eos_id, "pad_token_id": None}
135
+ hf = {"architectures": ["LlamaForCausalLM"], "model_type": "llama", "hidden_act": "silu",
136
+ "vocab_size": c.vocab_size, "max_position_embeddings": c.max_seq_len, "hidden_size": c.d_model,
137
+ "num_hidden_layers": c.n_layers, "num_attention_heads": c.n_heads,
138
+ "num_key_value_heads": c.n_kv_heads, "intermediate_size": c.ffn_hidden, "rms_norm_eps": c.norm_eps,
139
+ "rope_theta": c.rope_theta, "initializer_range": c.init_std, "tie_word_embeddings": c.tie_embeddings,
140
+ "attention_bias": False, "rope_scaling": None, "dtype": dtype, **ids}
141
+ (path / "config.json").write_text(json.dumps(hf, indent=2))
142
+ # Padding a batch with EOS (GPT-2 style); our model has no pad token.
143
+ (path / "generation_config.json").write_text(json.dumps({**ids, "pad_token_id": eos_id}, indent=2))
144
+ save_file({ours_to_hf(k): v for k, v in sd.items()}, path / "model.safetensors", {"format": "pt"})
145
+
146
+
147
+ def save_tokenizer_as_hf(path: str | Path, model_max_length: int | None = None) -> None:
148
+ """Our TinyStories BPE as an HF tokenizer directory (4.8b).
149
+
150
+ It was trained with the `tokenizers` library (D-007), so the file already is a tokenizer.json;
151
+ tokenizer_config.json only tells `AutoTokenizer` which class to use and which token is EOS.
152
+ Like our server: no BOS, nothing added on encode (the file has no post-processor), and no
153
+ "clean up" of spaces on decode, so decode(encode(text)) == text.
154
+ """
155
+ import shutil
156
+
157
+ from tinyllm.tokenizer import EOS, TOKENIZER_PATH
158
+ path = Path(path)
159
+ path.mkdir(parents=True, exist_ok=True)
160
+ shutil.copyfile(TOKENIZER_PATH, path / "tokenizer.json")
161
+ cfg = {"tokenizer_class": "PreTrainedTokenizerFast", "eos_token": EOS, "bos_token": None,
162
+ "unk_token": None, "pad_token": EOS, "add_bos_token": False, "add_eos_token": False,
163
+ "clean_up_tokenization_spaces": False, "model_max_length": model_max_length}
164
+ (path / "tokenizer_config.json").write_text(json.dumps(cfg, indent=2))
165
+
166
+
167
+ # --- tokenizer -------------------------------------------------------------------------------------
168
+
169
+ class HFTokenizer:
170
+ """tokenizer.json via the `tokenizers` library. encode() adds BOS (<s>) like the Llama tokenizer."""
171
+
172
+ def __init__(self, path: str | Path):
173
+ from tokenizers import Tokenizer
174
+ path = Path(path)
175
+ self.tok = Tokenizer.from_file(str(path / "tokenizer.json"))
176
+ cfg = json.loads((path / "tokenizer_config.json").read_text()) if (path / "tokenizer_config.json").exists() else {}
177
+ self.chat_template = cfg.get("chat_template")
178
+ self.bos_id = self.tok.token_to_id("<s>")
179
+ self.eos_id = self.tok.token_to_id("</s>")
180
+
181
+ def encode(self, text: str, bos: bool = True) -> list[int]:
182
+ ids = self.tok.encode(text, add_special_tokens=False).ids
183
+ return ([self.bos_id] if bos else []) + ids
184
+
185
+ def decode(self, ids: list[int]) -> str:
186
+ return self.tok.decode(ids, skip_special_tokens=True)
187
+
188
+ @staticmethod
189
+ def chat_prompt(user: str, system: str | None = None) -> str:
190
+ """TinyLlama-Chat v1.0 (Zephyr) template, with the assistant turn opened for generation."""
191
+ sys = f"<|system|>\n{system}</s>\n" if system else ""
192
+ return f"{sys}<|user|>\n{user}</s>\n<|assistant|>\n"
src/tinyllm/kernels/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """Triton kernels (PLAN 4.10, D-016): each has a PyTorch reference it must match (`allclose`).
2
+
3
+ Importing a kernel module needs Triton (Linux); callers fall back to PyTorch without it.
4
+ """
src/tinyllm/kernels/int8_gemv.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 4.10b (D-016): INT8 weight-only matmul for decode, reading the int8 weights directly.
2
+
3
+ The PyTorch path in tinyllm.quant (Int8Linear, backend "torch") runs three kernels per layer:
4
+ cast the int8 weight to bf16 (reads 1 byte and writes 2 per weight), matmul (reads those 2 bytes
5
+ again), then scale. Here one kernel reads each int8 weight once, converts it in registers (exact:
6
+ |q| <= 127), accumulates in fp32 and applies the per-row scale at the end:
7
+
8
+ y[m, n] = scale[n] * sum_k x[m, k] * q[n, k] x: (M, K) bf16/fp16, q: (N, K) int8
9
+
10
+ At decode, M (tokens in the step = batch) is small, so the step is bound by reading q. The tile
11
+ over M is padded to 16 rows for tl.dot (tensor cores); the wasted flops are cheap next to the
12
+ bytes. Each program owns BLOCK_N output columns and loops over K. Block sizes are autotuned per
13
+ (N, K, M bucket) on first use, which must happen outside CUDA-graph capture (GraphDecoder warms up
14
+ first). M above MAX_M (prefill) stays on the PyTorch path; see Int8Linear.forward.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import torch
20
+ import triton
21
+ import triton.language as tl
22
+ from torch import Tensor
23
+
24
+ MAX_M = 64 # rows per call this kernel takes; more (prefill) is a GEMM, left to cuBLAS
25
+
26
+ _CONFIGS = [triton.Config({"BLOCK_N": bn, "BLOCK_K": bk}, num_warps=w, num_stages=s)
27
+ for bn in (16, 32, 64) for bk in (128, 256) for w in (4,) for s in (3, 4)]
28
+ _CONFIGS += [triton.Config({"BLOCK_N": 32, "BLOCK_K": 512}, num_warps=8, num_stages=3),
29
+ triton.Config({"BLOCK_N": 64, "BLOCK_K": 128}, num_warps=2, num_stages=4)]
30
+
31
+
32
+ @triton.autotune(configs=_CONFIGS, key=["N", "K", "BLOCK_M"])
33
+ @triton.jit
34
+ def _int8_matmul_kernel(x_ptr, q_ptr, s_ptr, y_ptr, M, N, K, stride_xm, stride_qn, stride_ym,
35
+ BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr, BLOCK_K: tl.constexpr):
36
+ pid = tl.program_id(0)
37
+ offs_m = tl.arange(0, BLOCK_M)
38
+ offs_n = pid * BLOCK_N + tl.arange(0, BLOCK_N)
39
+ m_ok, n_ok = offs_m < M, offs_n < N
40
+ acc = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32)
41
+ for k0 in range(0, K, BLOCK_K):
42
+ offs_k = k0 + tl.arange(0, BLOCK_K)
43
+ k_ok = offs_k < K
44
+ x = tl.load(x_ptr + offs_m[:, None] * stride_xm + offs_k[None, :],
45
+ mask=m_ok[:, None] & k_ok[None, :], other=0.0)
46
+ q = tl.load(q_ptr + offs_n[:, None] * stride_qn + offs_k[None, :],
47
+ mask=n_ok[:, None] & k_ok[None, :], other=0)
48
+ acc = tl.dot(x, tl.trans(q.to(x.dtype)), acc) # int8 -> bf16 is exact
49
+ s = tl.load(s_ptr + offs_n, mask=n_ok, other=0.0)
50
+ y = acc * s[None, :]
51
+ tl.store(y_ptr + offs_m[:, None] * stride_ym + offs_n[None, :], y, mask=m_ok[:, None] & n_ok[None, :])
52
+
53
+
54
+ def int8_matmul(x: Tensor, q: Tensor, scale: Tensor) -> Tensor:
55
+ """x (M, K) bf16/fp16 CUDA, M <= MAX_M; q (N, K) int8; scale (N,) fp32 -> y (M, N) fp32.
56
+
57
+ Same values as F.linear(x, q.to(x.dtype)) * scale, except that the matmul result isn't rounded
58
+ to bf16 before scaling (closer to the fp32 dequantize-then-matmul reference, not further)."""
59
+ M, K = x.shape
60
+ N = q.shape[0]
61
+ if M > MAX_M or q.shape[1] != K or x.dtype not in (torch.bfloat16, torch.float16):
62
+ raise ValueError(f"int8_matmul: x {tuple(x.shape)} {x.dtype}, q {tuple(q.shape)}; needs M <= {MAX_M}, "
63
+ "matching K, bf16/fp16 x")
64
+ x = x if x.stride(1) == 1 else x.contiguous()
65
+ q = q if q.stride(1) == 1 else q.contiguous()
66
+ y = torch.empty((M, N), device=x.device, dtype=torch.float32)
67
+ block_m = max(16, triton.next_power_of_2(M))
68
+ grid = lambda meta: (triton.cdiv(N, meta["BLOCK_N"]),) # noqa: E731
69
+ _int8_matmul_kernel[grid](x, q, scale, y, M, N, K, x.stride(0), q.stride(0), y.stride(0), BLOCK_M=block_m)
70
+ return y
71
+
72
+
73
+ def best_configs() -> dict[str, str]:
74
+ """The autotuned config per (N, K, BLOCK_M) key so far, for the benchmark JSON."""
75
+ return {str(k): str(v) for k, v in _int8_matmul_kernel.cache.items()}
src/tinyllm/kernels/rmsnorm.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 4.10a (D-016): RMSNorm, and residual add + RMSNorm, each as one Triton kernel.
2
+
3
+ The PyTorch RMSNorm (tinyllm.model.RMSNorm, backend "torch") runs about seven small kernels per
4
+ call: cast to fp32, square, mean, add eps + rsqrt, multiply, cast back, multiply by the weight. At
5
+ decode a row is one token's 2,048 activations (4 KB in bf16), so each of those kernels is mostly
6
+ fixed cost. Here one program per row loads the row once and does it all in registers:
7
+
8
+ rmsnorm: y = (x * rsqrt(mean(x^2) + eps)).to(x.dtype) * w
9
+ add_rmsnorm: h = x + a (written out: the new residual stream); y = rmsnorm(h)
10
+
11
+ The roundings match the reference: h is rounded to its dtype before the statistic, the normalized
12
+ row is rounded to x's dtype before the weight multiply, and the product is rounded to the promoted
13
+ dtype of (x, w). Only the order of the fp32 sum differs, so results are allclose, not bitwise.
14
+
15
+ One compiled config per (row width, dtypes): no autotuning, so nothing has to run before CUDA-graph
16
+ capture beyond the first call (GraphDecoder warms up first).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import torch
22
+ import triton
23
+ import triton.language as tl
24
+ from torch import Tensor
25
+
26
+ MAX_D = 65536 # a whole row lives in one program's registers
27
+
28
+
29
+ @triton.jit
30
+ def _rmsnorm_kernel(x_ptr, a_ptr, h_ptr, w_ptr, y_ptr, D, eps, stride_x, stride_a, stride_h, stride_y,
31
+ ADD: tl.constexpr, BLOCK_D: tl.constexpr):
32
+ row = tl.program_id(0).to(tl.int64)
33
+ offs = tl.arange(0, BLOCK_D)
34
+ ok = offs < D
35
+ x = tl.load(x_ptr + row * stride_x + offs, mask=ok, other=0.0)
36
+ if ADD:
37
+ a = tl.load(a_ptr + row * stride_a + offs, mask=ok, other=0.0)
38
+ h = (x.to(tl.float32) + a.to(tl.float32)).to(h_ptr.dtype.element_ty) # x + a in the promoted dtype
39
+ tl.store(h_ptr + row * stride_h + offs, h, mask=ok)
40
+ x = h
41
+ xf = x.to(tl.float32)
42
+ rstd = 1.0 / tl.sqrt(tl.sum(xf * xf, axis=0) / D + eps)
43
+ n = (xf * rstd).to(x.dtype).to(tl.float32) # .to(x.dtype), as the reference
44
+ w = tl.load(w_ptr + offs, mask=ok, other=0.0).to(tl.float32)
45
+ tl.store(y_ptr + row * stride_y + offs, (n * w).to(y_ptr.dtype.element_ty), mask=ok)
46
+
47
+
48
+ def _rows(t: Tensor) -> Tensor:
49
+ t = t.reshape(-1, t.shape[-1])
50
+ return t if t.stride(-1) == 1 else t.contiguous()
51
+
52
+
53
+ def _launch(x: Tensor, a: Tensor | None, weight: Tensor, eps: float) -> tuple[Tensor, Tensor | None]:
54
+ D = x.shape[-1]
55
+ if not (x.is_cuda and weight.is_cuda) or weight.shape != (D,) or D > MAX_D:
56
+ raise ValueError(f"rmsnorm: x {tuple(x.shape)} on {x.device}, weight {tuple(weight.shape)}; "
57
+ f"needs CUDA tensors, weight of shape ({D},), width <= {MAX_D}")
58
+ x2 = _rows(x)
59
+ h = h2 = a2 = None
60
+ if a is not None:
61
+ if a.shape != x.shape:
62
+ raise ValueError(f"add_rmsnorm: x {tuple(x.shape)} and a {tuple(a.shape)} differ")
63
+ a2 = _rows(a)
64
+ h = torch.empty(x.shape, device=x.device, dtype=torch.result_type(x, a))
65
+ h2 = h.view(-1, D)
66
+ src = x2 if h2 is None else h2
67
+ y = torch.empty(x.shape, device=x.device, dtype=torch.result_type(src, weight))
68
+ y2 = y.view(-1, D)
69
+ rows = x2.shape[0]
70
+ if rows:
71
+ block = triton.next_power_of_2(D)
72
+ warps = 4 if block <= 2048 else 8 if block <= 8192 else 16
73
+ _rmsnorm_kernel[(rows,)](
74
+ x2, x2 if a2 is None else a2, x2 if h2 is None else h2, weight, y2, D, eps,
75
+ x2.stride(0), 0 if a2 is None else a2.stride(0), 0 if h2 is None else h2.stride(0), y2.stride(0),
76
+ ADD=a is not None, BLOCK_D=block, num_warps=warps)
77
+ return y, h
78
+
79
+
80
+ def rmsnorm(x: Tensor, weight: Tensor, eps: float) -> Tensor:
81
+ """x (..., D) CUDA -> y (..., D) in result_type(x, weight); same values as tinyllm.model.RMSNorm."""
82
+ return _launch(x, None, weight, eps)[0]
83
+
84
+
85
+ def add_rmsnorm(x: Tensor, a: Tensor, weight: Tensor, eps: float) -> tuple[Tensor, Tensor]:
86
+ """(y, h) with h = x + a and y = rmsnorm(h, weight, eps), from one pass over the row."""
87
+ y, h = _launch(x, a, weight, eps)
88
+ return y, h
src/tinyllm/kv_cache.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """KV caches for incremental decoding (docs/INFERENCE.md).
2
+
3
+ The model talks to a cache through three calls per forward pass:
4
+ rope = cache.begin_step(T, rope_module) # positions of the T new tokens; builds the mask
5
+ view = cache.layer(i) # per layer: view.update(k, v), view.attn_kwargs
6
+ cache.end_step(T) # the new tokens are now stored
7
+
8
+ - KVCache (4.3, 4.4 static): one shared position for every row. Optional left padding
9
+ (`pad_lens`) lets prompts of different lengths share a batch: pad keys are masked.
10
+ - SlotKVCache (4.4 continuous): S independent slots, each with its own length, so
11
+ requests can join and leave between decode steps.
12
+
13
+ Buffers are [B, n_kv_heads, max_len, head_dim] per layer. No reference cycles: the
14
+ caches own plain tensor lists and `layer(i)` returns a short-lived view. (A cycle once
15
+ kept every finished cache's GPU memory alive until Python's cycle collector ran.)
16
+
17
+ Left padding needs no per-row RoPE positions: RoPE makes q·k depend only on the
18
+ distance between two positions, and padding shifts every real token of a row by the
19
+ same amount. Pad *queries* may attend to themselves, so no row is fully masked (a fully
20
+ masked softmax row is NaN, and NaN would leak through 0 × NaN in later layers).
21
+ """
22
+
23
+ import torch
24
+ from torch import Tensor
25
+
26
+
27
+ def causal_kwargs(start: int, T: int, device) -> dict:
28
+ """SDPA masking for T new queries at positions start..start+T-1 over start+T keys, no padding.
29
+
30
+ - start 0 (prefill / training): standard causal.
31
+ - one new token: it may see every cached key, so no mask.
32
+ - several new tokens on a non-empty cache (chunked prefill, speculative verify):
33
+ SDPA's is_causal aligns to the top-left, which is wrong here, so pass an explicit
34
+ mask where query i sees keys 0..start+i.
35
+ """
36
+ if start == 0:
37
+ return {"is_causal": True}
38
+ if T == 1:
39
+ return {}
40
+ q_pos = torch.arange(start, start + T, device=device)[:, None]
41
+ k_pos = torch.arange(start + T, device=device)[None, :]
42
+ return {"attn_mask": k_pos <= q_pos}
43
+
44
+
45
+ def padded_causal_mask(start: int, T: int, first_valid: Tensor) -> Tensor:
46
+ """(B, 1, T, start+T) bool: causal, keys before first_valid[b] hidden, diagonal always kept."""
47
+ dev = first_valid.device
48
+ q_pos = torch.arange(start, start + T, device=dev)[:, None] # (T, 1)
49
+ k_pos = torch.arange(start + T, device=dev)[None, :] # (1, K)
50
+ causal = k_pos <= q_pos
51
+ valid = k_pos[None] >= first_valid[:, None, None] # (B, 1, K)
52
+ return (causal[None] & (valid | (k_pos == q_pos)[None]))[:, None] # (B, 1, T, K)
53
+
54
+
55
+ class KVCache:
56
+ """Shared-position cache. pad_lens (B,): left-pad count per row, or None for no padding."""
57
+
58
+ def __init__(self, n_layers: int, max_len: int, pad_lens: Tensor | None = None):
59
+ self.max_len = max_len
60
+ self.pos = 0
61
+ self.pad_lens = pad_lens
62
+ self.k: list[Tensor | None] = [None] * n_layers
63
+ self.v: list[Tensor | None] = [None] * n_layers
64
+ self.attn_kwargs: dict = {}
65
+
66
+ def begin_step(self, T: int, rope) -> tuple[Tensor, Tensor]:
67
+ cos, sin = rope(T, self.pos)
68
+ if self.pad_lens is None:
69
+ self.attn_kwargs = causal_kwargs(self.pos, T, cos.device)
70
+ else:
71
+ self.attn_kwargs = {"attn_mask": padded_causal_mask(self.pos, T, self.pad_lens)}
72
+ return cos, sin
73
+
74
+ def end_step(self, T: int) -> None:
75
+ self.pos += T
76
+
77
+ def layer(self, i: int) -> "LayerKV":
78
+ return LayerKV(self, i)
79
+
80
+ def nbytes(self) -> int:
81
+ return sum(t.numel() * t.element_size() for t in self.k + self.v if t is not None)
82
+
83
+
84
+ class LayerKV:
85
+ """One layer's view of a KVCache."""
86
+
87
+ def __init__(self, cache: KVCache, layer_idx: int):
88
+ self.cache = cache
89
+ self.i = layer_idx
90
+
91
+ @property
92
+ def start(self) -> int:
93
+ """Position of the first new token = number of tokens already cached."""
94
+ return self.cache.pos
95
+
96
+ @property
97
+ def attn_kwargs(self) -> dict:
98
+ return self.cache.attn_kwargs
99
+
100
+ def update(self, k: Tensor, v: Tensor) -> tuple[Tensor, Tensor]:
101
+ """Store k, v (B, H, T, D) at positions start..start+T-1; return all cached K, V so far."""
102
+ c, i = self.cache, self.i
103
+ B, H, T, D = k.shape
104
+ if c.k[i] is None:
105
+ shape = (B, H, c.max_len, D)
106
+ c.k[i] = torch.zeros(shape, dtype=k.dtype, device=k.device)
107
+ c.v[i] = torch.zeros(shape, dtype=v.dtype, device=v.device)
108
+ start, end = self.start, self.start + T
109
+ if end > c.max_len:
110
+ raise ValueError(f"KV cache full: position {end} > max_len {c.max_len}")
111
+ c.k[i][:, :, start:end] = k
112
+ c.v[i][:, :, start:end] = v
113
+ return c.k[i][:, :, :end], c.v[i][:, :, :end]
114
+
115
+
116
+ class SlotKVCache:
117
+ """S slots, each holding one sequence with its own length (continuous batching, 4.4).
118
+
119
+ Slot b holds keys at positions first[b] .. lens[b]-1 (positions before first[b] are
120
+ left padding from a batched prefill). A decode step feeds one token per slot, at
121
+ position lens[b]. Free slots still run (their output is ignored) so shapes stay fixed.
122
+
123
+ Every slot reserves max_len positions whatever its request needs: the waste that
124
+ paged KV (4.5) removes. `used_tokens()` vs `reserved_tokens()` measures it.
125
+ """
126
+
127
+ def __init__(self, n_layers: int, n_slots: int, n_kv_heads: int, head_dim: int,
128
+ max_len: int, dtype: torch.dtype, device):
129
+ shape = (n_slots, n_kv_heads, max_len, head_dim)
130
+ self.k = [torch.zeros(shape, dtype=dtype, device=device) for _ in range(n_layers)]
131
+ self.v = [torch.zeros(shape, dtype=dtype, device=device) for _ in range(n_layers)]
132
+ self.n_slots, self.max_len = n_slots, max_len
133
+ # CPU copies drive scheduling without GPU syncs; device copies feed RoPE and masks.
134
+ self.first = [0] * n_slots
135
+ self.lens = [0] * n_slots
136
+ self.active = [False] * n_slots
137
+ self._lens_dev = torch.zeros(n_slots, dtype=torch.long, device=device)
138
+ self._first_dev = torch.zeros(n_slots, dtype=torch.long, device=device)
139
+ self._active_dev = torch.zeros(n_slots, dtype=torch.long, device=device)
140
+ self._rows = torch.arange(n_slots, device=device)
141
+ self.attn_kwargs: dict = {}
142
+ self._end = 0
143
+ # 4.11b: a fixed attention window (a graph bucket) instead of "as far as the longest
144
+ # slot", so the step's shapes don't change between steps. None = eager default.
145
+ self.window: int | None = None
146
+
147
+ # -- scheduling --------------------------------------------------------
148
+ def free_slots(self) -> list[int]:
149
+ return [s for s in range(self.n_slots) if not self.active[s]]
150
+
151
+ def admit(self, slots: list[int], prefill: KVCache, pad_lens: list[int]) -> None:
152
+ """Copy a finished batched prefill (row j → slot slots[j]) into the slots."""
153
+ L = prefill.pos
154
+ if L > self.max_len:
155
+ raise ValueError(f"prompt length {L} > slot max_len {self.max_len}")
156
+ idx = torch.tensor(slots, device=self._lens_dev.device)
157
+ for i in range(len(self.k)):
158
+ self.k[i][idx, :, :L] = prefill.k[i][:, :, :L]
159
+ self.v[i][idx, :, :L] = prefill.v[i][:, :, :L]
160
+ for s, p in zip(slots, pad_lens):
161
+ self.first[s], self.lens[s], self.active[s] = p, L, True
162
+ self._sync()
163
+
164
+ def release(self, slot: int) -> None:
165
+ self.active[slot] = False
166
+ self.first[slot] = self.lens[slot] = 0
167
+ self._sync()
168
+
169
+ def _sync(self) -> None:
170
+ """Push the CPU bookkeeping to the device (only on admit / release, not every step)."""
171
+ self._lens_dev.copy_(torch.tensor(self.lens))
172
+ self._first_dev.copy_(torch.tensor(self.first))
173
+ self._active_dev.copy_(torch.tensor(self.active, dtype=torch.long))
174
+
175
+ def can_admit(self, lengths: list[int]) -> bool:
176
+ return True # a free slot is all a request needs
177
+
178
+ def reserve_step(self) -> list[int]:
179
+ return [] # never preempts: capacity is per slot
180
+
181
+ def used_tokens(self) -> int:
182
+ return sum(n - f for n, f, a in zip(self.lens, self.first, self.active) if a)
183
+
184
+ def allocated_tokens(self) -> int:
185
+ """KV positions claimed by running sequences (a whole slot each)."""
186
+ return sum(self.active) * self.max_len
187
+
188
+ def reserved_tokens(self) -> int:
189
+ return self.n_slots * self.max_len
190
+
191
+ def nbytes(self) -> int:
192
+ return sum(t.numel() * t.element_size() for t in self.k + self.v)
193
+
194
+ # -- model protocol (decode only: one token per slot) ------------------
195
+ def begin_step(self, T: int, rope) -> tuple[Tensor, Tensor]:
196
+ if T != 1:
197
+ raise ValueError("SlotKVCache decodes one token per slot; prefill goes through KVCache")
198
+ if max(self.lens) >= self.max_len:
199
+ raise ValueError(f"a slot is full (max_len {self.max_len})")
200
+ need = max(self.lens) + 1 # attend only as far as the longest slot
201
+ if self.window is not None and self.window < need:
202
+ raise ValueError(f"window {self.window} < {need} keys")
203
+ self._end = self.window or need
204
+ k_pos = torch.arange(self._end, device=self._lens_dev.device)
205
+ valid = (k_pos >= self._first_dev[:, None]) & (k_pos <= self._lens_dev[:, None])
206
+ self.attn_kwargs = {"attn_mask": valid[:, None, None, :]} # (S, 1, 1, end)
207
+ return rope.at(self._lens_dev[:, None])
208
+
209
+ def end_step(self, T: int) -> None:
210
+ self.advance_host()
211
+ self._lens_dev += self._active_dev # same update on the device, no copy
212
+
213
+ def advance_host(self) -> None:
214
+ """The host half of end_step. A CUDA-graph replay runs only the device half, so the
215
+ graph decoder calls this after each replay."""
216
+ for s in range(self.n_slots):
217
+ if self.active[s]:
218
+ self.lens[s] += 1
219
+
220
+ def layer(self, i: int) -> "SlotLayerKV":
221
+ return SlotLayerKV(self, i)
222
+
223
+
224
+ class SlotLayerKV:
225
+ def __init__(self, cache: SlotKVCache, layer_idx: int):
226
+ self.cache = cache
227
+ self.i = layer_idx
228
+
229
+ @property
230
+ def attn_kwargs(self) -> dict:
231
+ return self.cache.attn_kwargs
232
+
233
+ def update(self, k: Tensor, v: Tensor) -> tuple[Tensor, Tensor]:
234
+ """k, v (S, H, 1, D): write each slot's token at its own position lens[s]."""
235
+ c, i = self.cache, self.i
236
+ c.k[i][c._rows, :, c._lens_dev] = k[:, :, 0]
237
+ c.v[i][c._rows, :, c._lens_dev] = v[:, :, 0]
238
+ return c.k[i][:, :, :c._end], c.v[i][:, :, :c._end]
src/tinyllm/logger.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Experiment logging behind one small interface (D-013).
2
+
3
+ The training loop only calls log / log_sample / finish. Switching backend (W&B now,
4
+ TensorBoard as the fallback) means adding one class here. Only scalars and text
5
+ samples are logged, never checkpoints (W&B Free plan: 5 GB/month).
6
+
7
+ W&B login happens once, inside WSL: `uv run wandb login`. The key goes to ~/.netrc,
8
+ never into the repo.
9
+ """
10
+
11
+
12
+ class Logger:
13
+ """No-op logger (logger="none"): used by the tests and for quick local runs."""
14
+
15
+ run_id: str | None = None
16
+
17
+ def log(self, metrics: dict[str, float], step: int) -> None:
18
+ pass
19
+
20
+ def log_sample(self, text: str, step: int) -> None:
21
+ pass
22
+
23
+ def finish(self) -> None:
24
+ pass
25
+
26
+
27
+ class WandbLogger(Logger):
28
+ def __init__(self, project: str, name: str, config: dict, mode: str = "online",
29
+ run_id: str | None = None, dir: str | None = None):
30
+ import wandb # imported lazily: the "none" logger never needs it
31
+
32
+ self._wandb = wandb
33
+ # Passing the stored run id on resume continues the same W&B run (same charts).
34
+ self.run = wandb.init(project=project, name=name, config=config, mode=mode,
35
+ id=run_id, resume="allow" if run_id else None, dir=dir)
36
+ self.run_id = self.run.id
37
+ self._samples: list[list] = []
38
+
39
+ def log(self, metrics: dict[str, float], step: int) -> None:
40
+ self.run.log(metrics, step=step)
41
+
42
+ def log_sample(self, text: str, step: int) -> None:
43
+ # W&B tables are immutable, so re-log the growing table: one row per eval.
44
+ self._samples.append([step, text])
45
+ self.run.log({"samples": self._wandb.Table(columns=["step", "text"], data=self._samples)},
46
+ step=step)
47
+
48
+ def finish(self) -> None:
49
+ self.run.finish()
50
+
51
+
52
+ def make_logger(backend: str, **kwargs) -> Logger:
53
+ """kwargs are WandbLogger arguments; the no-op logger ignores them."""
54
+ if backend == "none":
55
+ return Logger()
56
+ if backend == "wandb":
57
+ return WandbLogger(**kwargs)
58
+ raise ValueError(f"unknown logger {backend!r}; use 'wandb' or 'none'")
src/tinyllm/logit_compare.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compare next-token logits between models (step 4.9c; reused by 4.8b).
2
+
3
+ Logits are collected the way inference produces them: a prefill over the first `n_prefill`
4
+ tokens, then one token per step through the KV cache (teacher-forced, so every model sees
5
+ the same sequence). Nothing here imports transformers: an HF model is passed in and only
6
+ called as `hf(input_ids, past_key_values=..., use_cache=True)` (D-015: test reference only).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import torch
12
+ import torch.nn.functional as F
13
+ from torch import Tensor
14
+
15
+ from tinyllm.kv_cache import KVCache
16
+
17
+
18
+ @torch.no_grad()
19
+ def ours_stepwise(model, ids: Tensor, n_prefill: int) -> Tensor:
20
+ """(B, T, V) fp32 CPU logits: prefill ids[:, :n_prefill], then one decode step per token."""
21
+ cache = KVCache(model.cfg.n_layers, model.cfg.max_seq_len)
22
+ autocast = dict(device_type=ids.device.type, dtype=torch.bfloat16, enabled=ids.device.type == "cuda")
23
+ out = []
24
+ with torch.autocast(**autocast): # same setting as generate()
25
+ out.append(model(ids[:, :n_prefill], cache=cache)[0].float().cpu())
26
+ for t in range(n_prefill, ids.shape[1]):
27
+ out.append(model(ids[:, t:t + 1], cache=cache)[0].float().cpu())
28
+ return torch.cat(out, dim=1)
29
+
30
+
31
+ @torch.no_grad()
32
+ def hf_stepwise(hf, ids: Tensor, n_prefill: int) -> Tensor:
33
+ """Same as ours_stepwise for an HF causal LM, using its own KV cache."""
34
+ o = hf(ids[:, :n_prefill], use_cache=True)
35
+ out, pkv = [o.logits.float().cpu()], o.past_key_values
36
+ for t in range(n_prefill, ids.shape[1]):
37
+ o = hf(ids[:, t:t + 1], past_key_values=pkv, use_cache=True)
38
+ out.append(o.logits.float().cpu())
39
+ pkv = o.past_key_values
40
+ return torch.cat(out, dim=1)
41
+
42
+
43
+ def logit_stats(ref: Tensor, x: Tensor) -> dict[str, float]:
44
+ """x against a reference, over all positions: abs error, KL(ref || x) in nats, top-1 agreement."""
45
+ ref, x = ref.float().flatten(0, -2), x.float().flatten(0, -2)
46
+ d = (x - ref).abs()
47
+ lp_ref, lp_x = F.log_softmax(ref, -1), F.log_softmax(x, -1)
48
+ kl = (lp_ref.exp() * (lp_ref - lp_x)).sum(-1)
49
+ return {
50
+ "max_abs": d.max().item(),
51
+ "mean_abs": d.mean().item(),
52
+ "kl_mean": kl.mean().item(),
53
+ "kl_max": kl.max().item(),
54
+ "top1": (ref.argmax(-1) == x.argmax(-1)).float().mean().item(),
55
+ "positions": ref.shape[0],
56
+ }
57
+
58
+
59
+ def first_divergence(a: list[int], b: list[int]) -> int | None:
60
+ """Index of the first differing token over the common length, or None if that prefix is equal."""
61
+ for i, (p, q) in enumerate(zip(a, b)):
62
+ if p != q:
63
+ return i
64
+ return None
src/tinyllm/logship.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ship the server's logs to a private Hugging Face dataset (6.5b step 4, docs/SERVING.md "Privacy").
2
+
3
+ The free Space wipes its disk on restart, so the request logs go to a private dataset:
4
+
5
+ - `ShipHandler` writes a filtered copy of the `tinyllm` log as JSON lines, one file per server start
6
+ per UTC hour: `<dir>/<YYYY-MM-DD>/<start>-<replica>-<HH>.jsonl`. Files are never shared between
7
+ restarts, so an upload only ever touches the files of this run (in practice the current hour's).
8
+ - Shipped: `request` and `slow request` lines, engine snapshots thinned to one per 30 s (the status
9
+ page keeps the 5 s ones in memory), the lifecycle lines (`model loaded`, `server ready`,
10
+ `shutting down: draining`, `stopped`) and errors without their traceback. Never debug lines.
11
+ No prompt or completion text and no raw IP reach the log at all (tests/test_logs.py checks it).
12
+ - `LogShipper` uploads the folder with `huggingface_hub.CommitScheduler` every 10 minutes from a
13
+ background thread, plus once more during the graceful drain. Upload failures are logged to stderr
14
+ and never touch serving. A hard kill loses at most the last 10 minutes.
15
+ - Retention: once a day (first at start) the uploader deletes dataset files older than 30 days, and
16
+ squashes the dataset history when its oldest commit is older than 30 days, so the deleted files
17
+ stop using storage.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import datetime as dt
23
+ import logging
24
+ import os
25
+ import re
26
+ import shutil
27
+ import threading
28
+ import time
29
+ from contextlib import nullcontext
30
+ from pathlib import Path
31
+ from typing import Callable
32
+
33
+ from tinyllm.obs import JsonFormatter
34
+
35
+ log = logging.getLogger("tinyllm.logship")
36
+
37
+ SHIPPED = frozenset({"model loaded", "server ready", "shutting down: draining", "stopped",
38
+ "request", "slow request"})
39
+ SNAPSHOT = "engine"
40
+ KEEP_DAYS = 30
41
+ UPLOAD_EVERY_MIN = 10.0
42
+ SNAPSHOT_EVERY_S = 30.0
43
+ PATH_IN_REPO = "logs"
44
+ _DAY = re.compile(r"\d{4}-\d{2}-\d{2}")
45
+
46
+
47
+ def start_id(t: float) -> str:
48
+ """The server start in a file name, UTC: 20261107T131500Z."""
49
+ return time.strftime("%Y%m%dT%H%M%SZ", time.gmtime(t))
50
+
51
+
52
+ def default_replica() -> str:
53
+ """The Space name on HF (`SPACE_ID` is owner/name), else "local"; safe for a file name."""
54
+ name = os.environ.get("SPACE_ID", "local").split("/")[-1]
55
+ return re.sub(r"[^A-Za-z0-9_.-]", "-", name) or "local"
56
+
57
+
58
+ def log_path(root: Path, start: str, replica: str, t: float) -> Path:
59
+ g = time.gmtime(t)
60
+ return root / time.strftime("%Y-%m-%d", g) / f"{start}-{replica}-{g.tm_hour:02d}.jsonl"
61
+
62
+
63
+ class ShipHandler(logging.Handler):
64
+ """Appends the shipped lines to the hourly file. `lock` guards each write against the uploader
65
+ reading the file at the same moment (CommitScheduler caps the upload at the size it saw)."""
66
+
67
+ def __init__(self, root: Path, start: str, replica: str, snapshot_every_s: float = SNAPSHOT_EVERY_S,
68
+ lock: Callable[[], object] | None = None):
69
+ super().__init__(level=logging.INFO)
70
+ self.root, self.start, self.replica = Path(root), start, replica
71
+ self.snapshot_every_s = snapshot_every_s
72
+ self._io_lock = lock or nullcontext
73
+ self._last_snapshot: dict[str, float] = {}
74
+ self.setFormatter(JsonFormatter(with_exc=False))
75
+
76
+ def keep(self, record: logging.LogRecord) -> bool:
77
+ msg = record.getMessage()
78
+ if msg == SNAPSHOT and record.levelno < logging.WARNING:
79
+ variant = str((getattr(record, "fields", None) or {}).get("variant"))
80
+ last = self._last_snapshot.get(variant)
81
+ if last is not None and record.created - last < 0.95 * self.snapshot_every_s:
82
+ return False
83
+ self._last_snapshot[variant] = record.created
84
+ return True
85
+ return msg in SHIPPED or record.levelno >= logging.ERROR
86
+
87
+ def emit(self, record: logging.LogRecord) -> None:
88
+ try:
89
+ if not self.keep(record):
90
+ return
91
+ line = self.format(record) + "\n"
92
+ path = log_path(self.root, self.start, self.replica, record.created)
93
+ with self._io_lock():
94
+ path.parent.mkdir(parents=True, exist_ok=True)
95
+ with path.open("a", encoding="utf-8") as f:
96
+ f.write(line)
97
+ except Exception:
98
+ self.handleError(record)
99
+
100
+
101
+ def _day(name: str) -> dt.date | None:
102
+ m = _DAY.fullmatch(name)
103
+ try:
104
+ return dt.date.fromisoformat(m.group()) if m else None
105
+ except ValueError:
106
+ return None
107
+
108
+
109
+ def apply_retention(api, repo_id: str, root: Path | None, now: float, keep_days: int = KEEP_DAYS) -> dict:
110
+ """Delete log files older than `keep_days` (by their UTC date folder) from the dataset, in one
111
+ commit, and from `root`; squash the dataset history once its oldest commit is older than
112
+ `keep_days`. `api` is an `HfApi` (a fake in the tests). Returns what was done."""
113
+ from huggingface_hub import CommitOperationDelete
114
+
115
+ cutoff = dt.datetime.fromtimestamp(now, dt.timezone.utc).date() - dt.timedelta(days=keep_days)
116
+ old = []
117
+ for f in api.list_repo_files(repo_id, repo_type="dataset"):
118
+ parts = f.split("/")
119
+ if len(parts) >= 3 and parts[0] == PATH_IN_REPO and (d := _day(parts[1])) is not None and d < cutoff:
120
+ old.append(f)
121
+ if old:
122
+ api.create_commit(repo_id, repo_type="dataset", operations=[CommitOperationDelete(f) for f in old],
123
+ commit_message=f"Delete logs older than {keep_days} days")
124
+ commits = api.list_repo_commits(repo_id, repo_type="dataset")
125
+ squash = len(commits) > 1 and commits[-1].created_at.timestamp() < now - keep_days * 86400
126
+ if squash:
127
+ api.super_squash_history(repo_id, repo_type="dataset",
128
+ commit_message=f"Squash history (logs kept {keep_days} days)")
129
+ local = 0
130
+ if root is not None and root.is_dir():
131
+ for d in root.iterdir():
132
+ if d.is_dir() and (day := _day(d.name)) is not None and day < cutoff:
133
+ shutil.rmtree(d, ignore_errors=True)
134
+ local += 1
135
+ return {"deleted": len(old), "squashed": squash, "local_dirs_deleted": local}
136
+
137
+
138
+ def _scheduler_class():
139
+ """CommitScheduler that runs the retention once a day before an upload. Uploads, deletes and the
140
+ squash all run on the scheduler's one worker thread, so they never overlap."""
141
+ from huggingface_hub import CommitScheduler
142
+
143
+ class RetentionScheduler(CommitScheduler):
144
+ def __init__(self, *a, keep_days: int = KEEP_DAYS, clock: Callable[[], float] = time.time, **kw):
145
+ self.keep_days, self.clock, self.last_retention = keep_days, clock, None
146
+ super().__init__(*a, **kw)
147
+
148
+ def push_to_hub(self):
149
+ now = self.clock()
150
+ if self.last_retention is None or now - self.last_retention >= 86400:
151
+ self.last_retention = now
152
+ try:
153
+ done = apply_retention(self.api, self.repo_id, self.folder_path, now, self.keep_days)
154
+ log.info("log retention", extra={"fields": {"dataset": self.repo_id, **done}})
155
+ except Exception as e:
156
+ log.warning("log retention failed", extra={"fields": {"error": f"{type(e).__name__}: {e}"}})
157
+ return super().push_to_hub()
158
+
159
+ return RetentionScheduler
160
+
161
+
162
+ class LogShipper:
163
+ """Local hourly JSONL under `log_dir`; with `dataset`, uploads it to that private HF dataset.
164
+ The token is the usual one: `HF_TOKEN` (a Space secret) or the local `hf auth login`."""
165
+
166
+ def __init__(self, log_dir: str | Path, dataset: str | None = None, replica: str | None = None,
167
+ every_min: float = UPLOAD_EVERY_MIN, keep_days: int = KEEP_DAYS,
168
+ snapshot_every_s: float = SNAPSHOT_EVERY_S, start: float | None = None,
169
+ scheduler_factory: Callable | None = None):
170
+ self.root = Path(log_dir)
171
+ self.root.mkdir(parents=True, exist_ok=True)
172
+ self.dataset, self.every_min, self.keep_days = dataset, every_min, keep_days
173
+ self.start = start_id(time.time() if start is None else start)
174
+ self.replica = replica or default_replica()
175
+ self.scheduler = None
176
+ self._factory = scheduler_factory
177
+ self._lock = threading.Lock()
178
+ self._thread: threading.Thread | None = None
179
+ self.handler = ShipHandler(self.root, self.start, self.replica, snapshot_every_s, self._io_lock)
180
+
181
+ def _io_lock(self):
182
+ s = self.scheduler
183
+ return s.lock if s is not None else self._lock
184
+
185
+ def attach(self, logger: logging.Logger | None = None) -> "LogShipper":
186
+ (logger or logging.getLogger("tinyllm")).addHandler(self.handler)
187
+ return self
188
+
189
+ def start_upload(self) -> None:
190
+ """Open the dataset in the background (the first upload and the retention run right away)."""
191
+ if self.dataset and self._thread is None:
192
+ self._thread = threading.Thread(target=self._connect, name="log-upload", daemon=True)
193
+ self._thread.start()
194
+
195
+ def _connect(self) -> None:
196
+ factory = self._factory or _scheduler_class()
197
+ try:
198
+ self.scheduler = factory(repo_id=self.dataset, repo_type="dataset", private=True,
199
+ folder_path=self.root, path_in_repo=PATH_IN_REPO,
200
+ every=self.every_min, keep_days=self.keep_days)
201
+ log.info("log upload on", extra={"fields": {"dataset": self.dataset, "every_min": self.every_min}})
202
+ except Exception as e: # no token, no access, no network: keep the logs local
203
+ log.warning("log upload off: logs stay local", extra={"fields": {
204
+ "dataset": self.dataset, "dir": str(self.root), "error": f"{type(e).__name__}: {e}"}})
205
+
206
+ def close(self, timeout_s: float = 30.0) -> None:
207
+ """Last upload (during the graceful drain), then stop the scheduler. Never raises."""
208
+ if self._thread is not None:
209
+ self._thread.join(timeout_s)
210
+ s = self.scheduler
211
+ if s is None:
212
+ return
213
+ try:
214
+ s.trigger().result(timeout=timeout_s)
215
+ log.info("logs uploaded", extra={"fields": {"dataset": self.dataset}})
216
+ except Exception as e:
217
+ log.warning("last log upload failed", extra={"fields": {"error": f"{type(e).__name__}: {e}"}})
218
+ finally:
219
+ s.stop()
220
+ logging.getLogger("tinyllm").removeHandler(self.handler)
src/tinyllm/model.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Llama-style decoder components, written from scratch (docs/ARCHITECTURE.md).
2
+
3
+ Parameter names follow HF Llama (weight, gate_proj, up_proj, down_proj) so the
4
+ Phase 4.8 export, and loading TinyLlama in Phase 4.9, need no renaming.
5
+ """
6
+
7
+ import math
8
+
9
+ import torch
10
+ import torch.nn.functional as F
11
+ from torch import Tensor, nn
12
+
13
+ from tinyllm.config import ModelConfig
14
+
15
+
16
+ class RMSNorm(nn.Module):
17
+ """y = x / sqrt(mean(x^2) + eps) * weight
18
+
19
+ Like LayerNorm, but with no mean subtraction and no bias: cheaper, and it
20
+ works just as well in LLMs. The statistic is computed in fp32 even under
21
+ bf16, because a sum of squares loses precision in bf16.
22
+
23
+ Backend "triton" (step 4.10a, the default since D-032) runs tinyllm.kernels.rmsnorm: one kernel
24
+ instead of about seven, and add_forward fuses the residual add before it. It is used only for
25
+ CUDA inputs when no gradient is needed (the kernel has no backward); otherwise, and on backend
26
+ "torch", this PyTorch code runs. So training and CPU inference are unchanged; GPU inference
27
+ differs from the PyTorch code only by bf16 rounding (docs/INFERENCE.md, 4.10a).
28
+ """
29
+
30
+ def __init__(self, dim: int, eps: float = 1e-5, backend: str | None = None):
31
+ super().__init__()
32
+ self.eps = eps
33
+ self.weight = nn.Parameter(torch.ones(dim))
34
+ self.backend = _check_norm_backend(DEFAULT_NORM_BACKEND if backend is None else backend)
35
+
36
+ def _kernel(self, x: Tensor):
37
+ if self.backend != "triton" or not x.is_cuda:
38
+ return None
39
+ if torch.is_grad_enabled() and (x.requires_grad or self.weight.requires_grad):
40
+ return None
41
+ return _norm_kernel()
42
+
43
+ def forward(self, x: Tensor) -> Tensor:
44
+ if (k := self._kernel(x)) is not None:
45
+ return k.rmsnorm(x, self.weight, self.eps)
46
+ xf = x.float()
47
+ xf = xf * torch.rsqrt(xf.pow(2).mean(-1, keepdim=True) + self.eps)
48
+ return xf.to(x.dtype) * self.weight
49
+
50
+ def add_forward(self, x: Tensor, a: Tensor) -> tuple[Tensor, Tensor]:
51
+ """(self(x + a), x + a): the residual add and the norm after it, fused on backend "triton"."""
52
+ if (k := self._kernel(x)) is not None and not (torch.is_grad_enabled() and a.requires_grad):
53
+ return k.add_rmsnorm(x, a, self.weight, self.eps)
54
+ h = x + a
55
+ return self(h), h
56
+
57
+ def extra_repr(self) -> str:
58
+ return f"{self.weight.shape[0]}, eps={self.eps}, backend={self.backend}"
59
+
60
+
61
+ NORM_BACKENDS = ("torch", "triton")
62
+ DEFAULT_NORM_BACKEND = "triton" # D-032: the fused kernel by default; it steps aside for autograd and CPU
63
+
64
+
65
+ def _check_norm_backend(backend: str) -> str:
66
+ if backend not in NORM_BACKENDS:
67
+ raise ValueError(f"unknown RMSNorm backend {backend!r}; one of {NORM_BACKENDS}")
68
+ return backend
69
+
70
+
71
+ _NORM_KERNEL: list = []
72
+
73
+
74
+ def _norm_kernel():
75
+ """tinyllm.kernels.rmsnorm, or None without Triton (imported once, on first CUDA use)."""
76
+ if not _NORM_KERNEL:
77
+ try:
78
+ from tinyllm.kernels import rmsnorm
79
+ except ImportError:
80
+ rmsnorm = None
81
+ _NORM_KERNEL.append(rmsnorm)
82
+ return _NORM_KERNEL[0]
83
+
84
+
85
+ def set_norm_backend(model: nn.Module, backend: str) -> int:
86
+ """Switch every RMSNorm in `model` to `backend` (step 4.10a). Returns how many."""
87
+ _check_norm_backend(backend)
88
+ norms = [m for m in model.modules() if isinstance(m, RMSNorm)]
89
+ for m in norms:
90
+ m.backend = backend
91
+ return len(norms)
92
+
93
+
94
+ def norm_backend(model: nn.Module | None = None) -> str:
95
+ """The RMSNorm code `model` runs at inference here: "triton" only if its norms ask for it, its
96
+ weights are on CUDA and Triton imports; else "torch" ("mixed" if its norms disagree). With no
97
+ model: what a freshly built model on CUDA would run. Recorded in benchmark provenance (D-032)."""
98
+ if model is None:
99
+ kinds = {DEFAULT_NORM_BACKEND}
100
+ on_cuda = torch.cuda.is_available()
101
+ else:
102
+ kinds = {m.backend for m in model.modules() if isinstance(m, RMSNorm)}
103
+ p = next(model.parameters(), None)
104
+ on_cuda = p is not None and p.is_cuda
105
+ if len(kinds) > 1:
106
+ return "mixed"
107
+ if kinds == {"triton"} and on_cuda and _norm_kernel() is not None:
108
+ return "triton"
109
+ return "torch"
110
+
111
+
112
+ class RotaryEmbedding(nn.Module):
113
+ """Precomputed RoPE cos/sin tables.
114
+
115
+ Each pair of channels (i, i + head_dim/2) in q and k is rotated by the angle
116
+ pos * theta^(-2i/head_dim). Then q·k depends only on the *relative* distance
117
+ between positions, and no position parameters are learned.
118
+ This is the "rotate_half" layout that HF Llama uses, so TinyLlama weights
119
+ work unchanged.
120
+ """
121
+
122
+ def __init__(self, head_dim: int, max_seq_len: int, theta: float = 10000.0):
123
+ super().__init__()
124
+ inv_freq = theta ** (-torch.arange(0, head_dim, 2, dtype=torch.float32) / head_dim) # (D/2,)
125
+ pos = torch.arange(max_seq_len, dtype=torch.float32)
126
+ angles = torch.outer(pos, inv_freq) # (T, D/2)
127
+ angles = torch.cat([angles, angles], dim=-1) # (T, D): same angle for i and i + D/2
128
+ # Not saved in checkpoints (persistent=False): they're derived from the config.
129
+ self.register_buffer("cos", angles.cos(), persistent=False)
130
+ self.register_buffer("sin", angles.sin(), persistent=False)
131
+
132
+ def forward(self, seq_len: int, offset: int = 0) -> tuple[Tensor, Tensor]:
133
+ """cos/sin for positions offset .. offset+seq_len-1. The offset is for KV-cached decode."""
134
+ end = offset + seq_len
135
+ if end > self.cos.shape[0]:
136
+ raise ValueError(f"position {end} exceeds max_seq_len {self.cos.shape[0]}")
137
+ return self.cos[offset:end], self.sin[offset:end]
138
+
139
+ def at(self, positions: Tensor) -> tuple[Tensor, Tensor]:
140
+ """cos/sin for per-row positions (B, T) → (B, 1, T, head_dim), broadcasting over heads.
141
+ Used when rows sit at different positions (continuous batching, 4.4).
142
+ No bounds check here (it would force a GPU sync every step): the caller checks."""
143
+ return self.cos[positions][:, None], self.sin[positions][:, None]
144
+
145
+
146
+ def rotate_half(x: Tensor) -> Tensor:
147
+ """[x1, x2] -> [-x2, x1], with x1, x2 the two halves of the last dim."""
148
+ x1, x2 = x.chunk(2, dim=-1)
149
+ return torch.cat([-x2, x1], dim=-1)
150
+
151
+
152
+ def apply_rope(x: Tensor, cos: Tensor, sin: Tensor) -> Tensor:
153
+ """x: (B, n_heads, T, head_dim); cos/sin: (T, head_dim)."""
154
+ return x * cos.to(x.dtype) + rotate_half(x) * sin.to(x.dtype)
155
+
156
+
157
+ class SwiGLU(nn.Module):
158
+ """down(silu(gate(x)) * up(x)): a gated MLP, used by Llama, Mistral, Qwen.
159
+
160
+ It has 3 matrices instead of 2, so ffn_hidden is ~(8/3)·d rather than 4·d
161
+ to keep the param count similar.
162
+ """
163
+
164
+ def __init__(self, cfg: ModelConfig):
165
+ super().__init__()
166
+ self.gate_proj = nn.Linear(cfg.d_model, cfg.ffn_hidden, bias=False)
167
+ self.up_proj = nn.Linear(cfg.d_model, cfg.ffn_hidden, bias=False)
168
+ self.down_proj = nn.Linear(cfg.ffn_hidden, cfg.d_model, bias=False)
169
+
170
+ def forward(self, x: Tensor) -> Tensor:
171
+ return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))
172
+
173
+
174
+ class Block(nn.Module):
175
+ """Pre-norm decoder layer: x + attn(norm(x)), then x + mlp(norm(x))."""
176
+
177
+ def __init__(self, cfg: ModelConfig, layer_idx: int):
178
+ super().__init__()
179
+ # Imported here, not at the top: the attention modules import apply_rope from this file.
180
+ from tinyllm.attention import build_attention
181
+
182
+ self.input_layernorm = RMSNorm(cfg.d_model, cfg.norm_eps)
183
+ self.self_attn = build_attention(cfg, layer_idx)
184
+ self.post_attention_layernorm = RMSNorm(cfg.d_model, cfg.norm_eps)
185
+ self.mlp = SwiGLU(cfg)
186
+
187
+ def forward(self, x: Tensor, rope: tuple[Tensor, Tensor], cache=None) -> Tensor:
188
+ a = self.self_attn(self.input_layernorm(x), rope, cache)
189
+ y, x = self.post_attention_layernorm.add_forward(x, a) # x + a, then its norm (one kernel, 4.10a)
190
+ return x + self.mlp(y)
191
+
192
+
193
+ # Projections that write into the residual stream. Their init is scaled down so the
194
+ # stream's variance doesn't grow with depth (GPT-2 / nanoGPT recipe).
195
+ RESIDUAL_PROJ = ("o_proj.weight", "down_proj.weight")
196
+
197
+
198
+ class Transformer(nn.Module):
199
+ """Token ids (B, T) -> logits (B, T, vocab), plus the next-token loss when targets are given."""
200
+
201
+ def __init__(self, cfg: ModelConfig):
202
+ super().__init__()
203
+ self.cfg = cfg
204
+ self.embed_tokens = nn.Embedding(cfg.vocab_size, cfg.d_model)
205
+ self.layers = nn.ModuleList(Block(cfg, i) for i in range(cfg.n_layers))
206
+ self.norm = RMSNorm(cfg.d_model, cfg.norm_eps)
207
+ self.lm_head = nn.Linear(cfg.d_model, cfg.vocab_size, bias=False)
208
+ if cfg.tie_embeddings:
209
+ # One matrix for both: saves V·d params (4.2M in main, 14% of the model).
210
+ self.lm_head.weight = self.embed_tokens.weight
211
+ self.rope = RotaryEmbedding(cfg.head_dim, cfg.max_seq_len, cfg.rope_theta)
212
+ self._init_weights()
213
+
214
+ def _init_weights(self) -> None:
215
+ std = self.cfg.init_std
216
+ for m in self.modules():
217
+ if isinstance(m, (nn.Linear, nn.Embedding)):
218
+ nn.init.normal_(m.weight, mean=0.0, std=std)
219
+ resid_std = std / math.sqrt(2 * self.cfg.n_layers) # 2 residual writes per layer
220
+ for name, p in self.named_parameters():
221
+ if name.endswith(RESIDUAL_PROJ):
222
+ nn.init.normal_(p, mean=0.0, std=resid_std)
223
+ # RMSNorm weights stay at 1.
224
+
225
+ def num_params(self) -> int:
226
+ return sum(p.numel() for p in self.parameters()) # a tied matrix is counted once
227
+
228
+ def forward(
229
+ self, idx: Tensor, targets: Tensor | None = None, cache=None,
230
+ ) -> tuple[Tensor, Tensor | None]:
231
+ """cache: a tinyllm.kv_cache cache for incremental decoding (None in training).
232
+ With a cache, idx holds only the new tokens; the cache supplies their positions."""
233
+ _, T = idx.shape
234
+ rope = self.rope(T) if cache is None else cache.begin_step(T, self.rope)
235
+ x = self.embed_tokens(idx)
236
+ for i, layer in enumerate(self.layers):
237
+ x = layer(x, rope, None if cache is None else cache.layer(i))
238
+ if cache is not None:
239
+ cache.end_step(T)
240
+ logits = self.lm_head(self.norm(x))
241
+ loss = None
242
+ if targets is not None:
243
+ # Loss in fp32: log-softmax over 8192 classes is imprecise in bf16.
244
+ loss = F.cross_entropy(logits.float().flatten(0, 1), targets.flatten())
245
+ return logits, loss
src/tinyllm/obs.py ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 4.13 step 2: observability for the server (D-028, docs/SERVING.md).
2
+
3
+ Standard library only, so the server and the Space need nothing extra.
4
+
5
+ - RequestRecord: one JSON log line per finished request (timings, token counts, status).
6
+ Never the prompt or completion text; the client is a salted hash (hash_client).
7
+ - EngineSnapshot: the engine's load, sampled every few seconds.
8
+ - Metrics: thread-safe aggregator. Rolling windows (5 min, 1 h) back /stats (JSON);
9
+ cumulative counters and histograms back /metrics (Prometheus text format, so a real
10
+ Prometheus could scrape it unchanged). history() backs /stats/history for the status page
11
+ (6.5b): the last hour per minute, engine snapshots, and the SLOs with their error budgets.
12
+ - JsonFormatter / setup_logging: every log line is one JSON object.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ import logging
20
+ import math
21
+ import sys
22
+ import threading
23
+ import time
24
+ from collections import deque
25
+ from dataclasses import asdict, dataclass, field
26
+
27
+ WINDOWS = {"5m": 300.0, "1h": 3600.0}
28
+ TTFT_BUCKETS = (0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0)
29
+ LATENCY_BUCKETS = (0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0, 120.0)
30
+
31
+ # SLOs (docs/SERVING.md, 4.13 step 3). Targets are shares of good requests; the TTFT threshold
32
+ # is per server (--slo-ttft-s: 1 s local GPU, 2 s on the Space).
33
+ SLO_TARGETS = {"ttft": 0.95, "availability": 0.99, "shed": 0.99}
34
+ SLO_WINDOW_S = 7 * 24 * 3600.0 # rolling 7 days, but only since the last restart (in memory)
35
+ FAST_BURN = 14.4 # Google SRE fast-burn threshold, judged on the last hour
36
+ BURN_MIN_REQUESTS = 10 # fewer counted requests in the hour: no flag (too few to judge)
37
+ HISTORY_MINUTES = 60
38
+
39
+
40
+ def slo_counted(status: int) -> bool:
41
+ """Requests the SLOs judge: served (200), shed (429) and server failures (>= 500, incl. 504).
42
+ Client errors (400) and client disconnects (499) count for none of them."""
43
+ return status == 200 or status == 429 or status >= 500
44
+
45
+
46
+ class _SloBucket:
47
+ """Counters for one hour of counted requests."""
48
+ __slots__ = ("counted", "ok", "slow", "failed", "shed")
49
+
50
+ def __init__(self):
51
+ self.counted = self.ok = self.slow = self.failed = self.shed = 0
52
+
53
+ def add(self, rec: "RequestRecord", ttft_s: float) -> None:
54
+ self.counted += 1
55
+ if rec.status == 200:
56
+ self.ok += 1
57
+ self.slow += rec.ttft_s is None or rec.ttft_s > ttft_s
58
+ elif rec.status == 429:
59
+ self.shed += 1
60
+ else:
61
+ self.failed += 1
62
+
63
+ def bad_and_total(self, slo: str) -> tuple[int, int]:
64
+ if slo == "ttft":
65
+ return self.slow, self.ok
66
+ return (self.failed, self.counted) if slo == "availability" else (self.shed, self.counted)
67
+
68
+
69
+ def hash_client(ip: str, salt: str) -> str:
70
+ """Stable, salted id for a client. The raw IP is never stored."""
71
+ return hashlib.sha256(f"{salt}:{ip}".encode()).hexdigest()[:16]
72
+
73
+
74
+ def percentile(xs, q: float) -> float | None:
75
+ """Linear-interpolated percentile (numpy's default); None for no data. q in [0, 100]."""
76
+ xs = sorted(xs)
77
+ if not xs:
78
+ return None
79
+ k = (len(xs) - 1) * q / 100
80
+ lo, hi = math.floor(k), math.ceil(k)
81
+ return xs[lo] + (xs[hi] - xs[lo]) * (k - lo)
82
+
83
+
84
+ def itl_stats(token_times: list[float]) -> tuple[float | None, float | None]:
85
+ """Inter-token latency p50 / p95 from the times each token was produced."""
86
+ gaps = [b - a for a, b in zip(token_times, token_times[1:])]
87
+ return percentile(gaps, 50), percentile(gaps, 95)
88
+
89
+
90
+ @dataclass
91
+ class RequestRecord:
92
+ """One finished request (any outcome). Times in seconds; None when not reached."""
93
+ request_id: str
94
+ status: int # HTTP status: 200, 400, 429, 500, 504, 499 (client gone)
95
+ variant: str = "main"
96
+ model_version: str = ""
97
+ client: str = "" # hash_client(), never the IP
98
+ arrival_unix: float = 0.0
99
+ queue_wait_s: float | None = None # arrival → admitted into a slot (first token)
100
+ ttft_s: float | None = None
101
+ itl_p50_s: float | None = None
102
+ itl_p95_s: float | None = None
103
+ latency_s: float | None = None
104
+ prompt_tokens: int = 0
105
+ completion_tokens: int = 0
106
+ finish_reason: str | None = None # "stop" (EOS), "length", "abort", "timeout", "error"
107
+ stream: bool = False
108
+ error: str | None = None
109
+ queue_depth_at_arrival: int = 0
110
+ busy_slots_at_arrival: int = 0
111
+
112
+ def to_dict(self) -> dict:
113
+ return asdict(self)
114
+
115
+
116
+ @dataclass
117
+ class EngineSnapshot:
118
+ t_unix: float
119
+ queue_len: int
120
+ busy_slots: int
121
+ n_slots: int
122
+ tokens_per_s: float # generated tokens / s since the previous snapshot
123
+ kv_used_frac: float
124
+ ram_mb: float | None = None
125
+ vram_mb: float | None = None
126
+
127
+
128
+ class _Histogram:
129
+ def __init__(self, buckets):
130
+ self.buckets = tuple(buckets)
131
+ self.counts = [0] * len(self.buckets) # per bucket, not cumulative
132
+ self.sum = 0.0
133
+ self.count = 0
134
+
135
+ def observe(self, x: float) -> None:
136
+ for i, b in enumerate(self.buckets):
137
+ if x <= b:
138
+ self.counts[i] += 1
139
+ break
140
+ self.sum += x
141
+ self.count += 1
142
+
143
+ def lines(self, name: str, labels: str) -> list[str]:
144
+ sep = "," if labels else ""
145
+ out, cum = [], 0
146
+ for b, c in zip(self.buckets, self.counts):
147
+ cum += c
148
+ out.append(f'{name}_bucket{{{labels}{sep}le="{b:g}"}} {cum}')
149
+ out.append(f'{name}_bucket{{{labels}{sep}le="+Inf"}} {self.count}')
150
+ lab = f"{{{labels}}}" if labels else ""
151
+ out.append(f"{name}_sum{lab} {self.sum:.6f}")
152
+ out.append(f"{name}_count{lab} {self.count}")
153
+ return out
154
+
155
+
156
+ @dataclass
157
+ class _Window:
158
+ span_s: float
159
+ items: deque = field(default_factory=deque) # (t_monotonic, RequestRecord)
160
+
161
+ def prune(self, now: float) -> None:
162
+ while self.items and self.items[0][0] < now - self.span_s:
163
+ self.items.popleft()
164
+
165
+
166
+ class Metrics:
167
+ """Thread-safe: handlers call observe(), the engine thread calls snapshot()."""
168
+
169
+ def __init__(self, windows: dict[str, float] = WINDOWS, clock=time.monotonic, slo_ttft_s: float = 1.0,
170
+ wall=time.time):
171
+ self._lock = threading.Lock()
172
+ self._clock, self._wall = clock, wall
173
+ self.slo_ttft_s = slo_ttft_s
174
+ self._windows = {k: _Window(v) for k, v in windows.items()}
175
+ self._hour = _Window(HISTORY_MINUTES * 60.0) # per-minute history and the 1 h burn rate
176
+ self._slo: deque = deque() # (hour index since start, _SloBucket)
177
+ self._snaps: deque = deque() # (t_monotonic, EngineSnapshot), last hour
178
+ self._requests: dict[tuple[str, int], int] = {} # (variant, status) → count
179
+ self._tokens: dict[str, int] = {} # variant → completion tokens
180
+ self._ttft: dict[str, _Histogram] = {}
181
+ self._latency: dict[str, _Histogram] = {}
182
+ self.last_snapshot: EngineSnapshot | None = None
183
+ self.started = clock()
184
+
185
+ def observe(self, rec: RequestRecord, now: float | None = None) -> None:
186
+ now = self._clock() if now is None else now
187
+ with self._lock:
188
+ for w in (*self._windows.values(), self._hour):
189
+ w.items.append((now, rec))
190
+ w.prune(now)
191
+ if slo_counted(rec.status):
192
+ hour = int((now - self.started) // 3600)
193
+ if not self._slo or self._slo[-1][0] != hour:
194
+ self._slo.append((hour, _SloBucket()))
195
+ while self._slo and self._slo[0][0] <= hour - SLO_WINDOW_S / 3600:
196
+ self._slo.popleft()
197
+ self._slo[-1][1].add(rec, self.slo_ttft_s)
198
+ v = rec.variant
199
+ self._requests[(v, rec.status)] = self._requests.get((v, rec.status), 0) + 1
200
+ self._tokens[v] = self._tokens.get(v, 0) + rec.completion_tokens
201
+ if rec.ttft_s is not None:
202
+ self._ttft.setdefault(v, _Histogram(TTFT_BUCKETS)).observe(rec.ttft_s)
203
+ if rec.status == 200 and rec.latency_s is not None:
204
+ self._latency.setdefault(v, _Histogram(LATENCY_BUCKETS)).observe(rec.latency_s)
205
+
206
+ def snapshot(self, snap: EngineSnapshot, now: float | None = None) -> None:
207
+ now = self._clock() if now is None else now
208
+ with self._lock:
209
+ self.last_snapshot = snap
210
+ self._snaps.append((now, snap))
211
+ while self._snaps and self._snaps[0][0] < now - HISTORY_MINUTES * 60:
212
+ self._snaps.popleft()
213
+
214
+ def history(self, now: float | None = None) -> dict:
215
+ """/stats/history, for the status page: the last hour per minute, the engine snapshots of the
216
+ last hour, and each SLO over the rolling 7-day window (since the last restart)."""
217
+ now = self._clock() if now is None else now
218
+ wall = self._wall()
219
+ with self._lock:
220
+ self._hour.prune(now)
221
+ recs = list(self._hour.items)
222
+ snaps = [(t, s) for t, s in self._snaps if t >= now - HISTORY_MINUTES * 60]
223
+ hour_now = int((now - self.started) // 3600)
224
+ buckets = [b for h, b in self._slo if h > hour_now - SLO_WINDOW_S / 3600]
225
+ uptime = now - self.started
226
+
227
+ by_min: list[list[RequestRecord]] = [[] for _ in range(HISTORY_MINUTES)]
228
+ for t, r in recs:
229
+ ago = int((now - t) // 60)
230
+ if 0 <= ago < HISTORY_MINUTES:
231
+ by_min[ago].append(r)
232
+ minutes = []
233
+ for ago in range(HISTORY_MINUTES - 1, -1, -1): # oldest first
234
+ rs = by_min[ago]
235
+ ok = [r for r in rs if r.status == 200]
236
+ ttft = [r.ttft_s for r in ok if r.ttft_s is not None]
237
+ lat = [r.latency_s for r in ok if r.latency_s is not None]
238
+ minutes.append({
239
+ "minutes_ago": ago, "covered": uptime >= ago * 60, # the server was up for this minute
240
+ "requests": len(rs), "ok": len(ok),
241
+ "failed": sum(r.status >= 500 for r in rs), "shed": sum(r.status == 429 for r in rs),
242
+ "completion_tokens": sum(r.completion_tokens for r in rs),
243
+ "ttft_p50_s": percentile(ttft, 50), "ttft_p95_s": percentile(ttft, 95),
244
+ "latency_p95_s": percentile(lat, 95)})
245
+
246
+ hour = _SloBucket()
247
+ for _, r in recs:
248
+ if slo_counted(r.status):
249
+ hour.add(r, self.slo_ttft_s)
250
+ slos = {}
251
+ for name, target in SLO_TARGETS.items():
252
+ bad, total = map(sum, zip(*(b.bad_and_total(name) for b in buckets))) if buckets else (0, 0)
253
+ hbad, htotal = hour.bad_and_total(name)
254
+ allowed = 1 - target
255
+ burn = (hbad / htotal) / allowed if htotal else None
256
+ slos[name] = {
257
+ "target": target, "good": total - bad, "total": total,
258
+ "compliance": round((total - bad) / total, 6) if total else None,
259
+ "met": (total - bad) / total >= target if total else None,
260
+ "budget_left": round(1 - bad / (allowed * total), 6) if total else None,
261
+ "burn_rate_1h": round(burn, 3) if burn is not None else None,
262
+ "requests_1h": htotal,
263
+ "fast_burn": burn is not None and htotal >= BURN_MIN_REQUESTS and burn > FAST_BURN}
264
+ slos["ttft"]["threshold_s"] = self.slo_ttft_s
265
+ slos["shed"]["judged"] = False # only at or below the rated load, unknown for the Space yet
266
+
267
+ return {"now_unix": round(wall, 3), "uptime_s": round(uptime, 1),
268
+ "slo_window_s": round(min(SLO_WINDOW_S, uptime), 1), "minutes": minutes,
269
+ "snapshots": [{"seconds_ago": round(now - t, 1), "queue_len": s.queue_len,
270
+ "busy_slots": s.busy_slots, "n_slots": s.n_slots,
271
+ "tokens_per_s": round(s.tokens_per_s, 1)} for t, s in snaps],
272
+ "slo": slos, "fast_burn_threshold": FAST_BURN, "burn_min_requests": BURN_MIN_REQUESTS}
273
+
274
+ def stats(self, now: float | None = None) -> dict:
275
+ """/stats: per rolling window, request counts, rates and latency percentiles."""
276
+ now = self._clock() if now is None else now
277
+ out: dict = {"uptime_s": round(now - self.started, 1), "windows": {}}
278
+ with self._lock:
279
+ for name, w in self._windows.items():
280
+ w.prune(now)
281
+ recs = [r for _, r in w.items]
282
+ span = min(w.span_s, max(now - self.started, 1e-9))
283
+ ok = [r for r in recs if r.status == 200]
284
+ ttft = [r.ttft_s for r in ok if r.ttft_s is not None]
285
+ lat = [r.latency_s for r in ok if r.latency_s is not None]
286
+ qw = [r.queue_wait_s for r in ok if r.queue_wait_s is not None]
287
+ n = len(recs)
288
+ out["windows"][name] = {
289
+ "requests": n,
290
+ "requests_per_min": round(60 * n / span, 3),
291
+ "ok": len(ok),
292
+ "error_rate": round(sum(r.status >= 500 for r in recs) / n, 4) if n else 0.0,
293
+ "reject_rate": round(sum(r.status == 429 for r in recs) / n, 4) if n else 0.0,
294
+ "clients": len({r.client for r in recs}),
295
+ "completion_tokens": sum(r.completion_tokens for r in recs),
296
+ "ttft_s": {f"p{q}": percentile(ttft, q) for q in (50, 95, 99)},
297
+ "latency_s": {f"p{q}": percentile(lat, q) for q in (50, 95, 99)},
298
+ "queue_wait_s": {f"p{q}": percentile(qw, q) for q in (50, 95)},
299
+ }
300
+ out["engine"] = asdict(self.last_snapshot) if self.last_snapshot else None
301
+ return out
302
+
303
+ def prometheus(self) -> str:
304
+ """/metrics in the Prometheus text exposition format (version 0.0.4)."""
305
+ L: list[str] = []
306
+ with self._lock:
307
+ L += ["# HELP tinyllm_requests_total Finished requests by variant and HTTP status.",
308
+ "# TYPE tinyllm_requests_total counter"]
309
+ for (v, s), c in sorted(self._requests.items()):
310
+ L.append(f'tinyllm_requests_total{{variant="{v}",status="{s}"}} {c}')
311
+ L += ["# HELP tinyllm_completion_tokens_total Generated tokens by variant.",
312
+ "# TYPE tinyllm_completion_tokens_total counter"]
313
+ for v, c in sorted(self._tokens.items()):
314
+ L.append(f'tinyllm_completion_tokens_total{{variant="{v}"}} {c}')
315
+ for name, hists, help_ in (
316
+ ("tinyllm_ttft_seconds", self._ttft, "Arrival to first token."),
317
+ ("tinyllm_request_latency_seconds", self._latency, "Arrival to last token, successful requests.")):
318
+ L += [f"# HELP {name} {help_}", f"# TYPE {name} histogram"]
319
+ for v, h in sorted(hists.items()):
320
+ L += h.lines(name, f'variant="{v}"')
321
+ s = self.last_snapshot
322
+ for name, help_, val in (
323
+ ("tinyllm_queue_length", "Requests waiting for a slot.", s and s.queue_len),
324
+ ("tinyllm_busy_slots", "Slots decoding a request.", s and s.busy_slots),
325
+ ("tinyllm_slots", "Total slots.", s and s.n_slots),
326
+ ("tinyllm_tokens_per_second", "Generated tokens per second (last snapshot).", s and s.tokens_per_s),
327
+ ("tinyllm_kv_used_ratio", "Share of the KV cache holding tokens.", s and s.kv_used_frac)):
328
+ if s is not None:
329
+ L += [f"# HELP {name} {help_}", f"# TYPE {name} gauge", f"{name} {val:g}"]
330
+ return "\n".join(L) + "\n"
331
+
332
+
333
+ class JsonFormatter(logging.Formatter):
334
+ """One JSON object per line. Structured fields go in `extra={"fields": {...}}`.
335
+ `with_exc=False` leaves tracebacks out (the shipped logs, logship.py)."""
336
+
337
+ def __init__(self, with_exc: bool = True):
338
+ super().__init__()
339
+ self.with_exc = with_exc
340
+
341
+ def format(self, record: logging.LogRecord) -> str:
342
+ d = {"ts": round(record.created, 3), "level": record.levelname.lower(),
343
+ "logger": record.name, "msg": record.getMessage()}
344
+ d.update(getattr(record, "fields", None) or {})
345
+ if record.exc_info and self.with_exc:
346
+ d["exc"] = self.formatException(record.exc_info)
347
+ return json.dumps(d, default=str)
348
+
349
+
350
+ def setup_logging(level: str = "info", path: str | None = None) -> logging.Logger:
351
+ """The `tinyllm` logger: JSON lines to stderr, and to `path` (JSONL) if given."""
352
+ log = logging.getLogger("tinyllm")
353
+ log.setLevel(level.upper())
354
+ log.handlers.clear()
355
+ log.propagate = False
356
+ handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
357
+ if path:
358
+ handlers.append(logging.FileHandler(path, encoding="utf-8"))
359
+ for h in handlers:
360
+ h.setFormatter(JsonFormatter())
361
+ log.addHandler(h)
362
+ return log
src/tinyllm/paged_kv.py ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 4.5: paged KV cache (vLLM-style blocks), a drop-in for SlotKVCache in run_continuous.
2
+
3
+ The analogy is virtual memory:
4
+ block table = page table (one per sequence: logical block j -> physical block)
5
+ block = page (block_size tokens of K and V, every layer)
6
+ token t = (table[t // block_size], t % block_size)
7
+
8
+ All layers share one block table: block b holds the same token positions in every layer's
9
+ pool. Blocks are allocated as a sequence grows, not reserved up front, and every block has
10
+ the same size, so a freed block fits any sequence: no external fragmentation, and at most
11
+ block_size - 1 wasted slots per sequence (its last, partly filled block).
12
+
13
+ Left padding from the batched prefill is not stored: only real tokens go into blocks.
14
+ The pad count becomes a RoPE offset (`first`), so a token at storage index j keeps its
15
+ rotation position first + j and the next query lands at first + len, as in SlotKVCache.
16
+
17
+ Attention reads the blocks by gathering each sequence's blocks into a contiguous
18
+ (S, H, n_blocks * block_size, D) view and calling SDPA as before. vLLM's PagedAttention
19
+ kernel reads the blocks in place instead; the gather is this toy's extra copy (measured
20
+ in docs/INFERENCE.md).
21
+
22
+ When decode needs more blocks than are free, the newest sequence is preempted: its blocks
23
+ are freed and the engine requeues it to be recomputed (prompt + tokens so far), which
24
+ reproduces the same greedy tokens. This is vLLM's default recompute policy.
25
+ """
26
+
27
+ import math
28
+
29
+ import torch
30
+ from torch import Tensor
31
+
32
+ from tinyllm.kv_cache import KVCache
33
+
34
+
35
+ class OutOfBlocks(RuntimeError):
36
+ pass
37
+
38
+
39
+ class BlockAllocator:
40
+ """Fixed-size blocks with a free list. Lowest ids are handed out first (reused LIFO)."""
41
+
42
+ def __init__(self, n_blocks: int, block_size: int):
43
+ self.n_blocks, self.block_size = n_blocks, block_size
44
+ self._free = list(range(n_blocks - 1, -1, -1)) # stack: pop() gives block 0 first
45
+ self._in_use = [False] * n_blocks
46
+ self.peak_used = 0
47
+
48
+ @property
49
+ def num_free(self) -> int:
50
+ return len(self._free)
51
+
52
+ @property
53
+ def num_used(self) -> int:
54
+ return self.n_blocks - len(self._free)
55
+
56
+ def alloc(self) -> int:
57
+ if not self._free:
58
+ raise OutOfBlocks(f"all {self.n_blocks} KV blocks in use")
59
+ b = self._free.pop()
60
+ self._in_use[b] = True
61
+ self.peak_used = max(self.peak_used, self.num_used)
62
+ return b
63
+
64
+ def free(self, blocks: list[int]) -> None:
65
+ for b in blocks:
66
+ if not self._in_use[b]:
67
+ raise ValueError(f"block {b} freed twice (or never allocated)")
68
+ self._in_use[b] = False
69
+ self._free.append(b)
70
+
71
+
72
+ class PagedKVCache:
73
+ """S sequence slots over a shared pool of n_blocks KV blocks.
74
+
75
+ Same model protocol as SlotKVCache (begin_step / layer / end_step, one token per slot)
76
+ and the same scheduling calls (free_slots / admit / release), plus can_admit() and
77
+ reserve_step() so the engine can respect the block budget.
78
+ """
79
+
80
+ def __init__(self, n_layers: int, n_slots: int, n_kv_heads: int, head_dim: int,
81
+ n_blocks: int, block_size: int, max_len: int, dtype: torch.dtype, device):
82
+ self.alloc = BlockAllocator(n_blocks, block_size)
83
+ self.bs, self.n_slots, self.max_len = block_size, n_slots, max_len
84
+ self.max_blocks = math.ceil(max_len / block_size)
85
+ self.scratch = n_blocks # extra block, never allocated: free slots write here
86
+ shape = (n_blocks + 1, n_kv_heads, block_size, head_dim)
87
+ self.k = [torch.zeros(shape, dtype=dtype, device=device) for _ in range(n_layers)]
88
+ self.v = [torch.zeros(shape, dtype=dtype, device=device) for _ in range(n_layers)]
89
+ # CPU bookkeeping (scheduling, no GPU syncs) and its device mirror (indexing, masks).
90
+ self.tables: list[list[int]] = [[] for _ in range(n_slots)]
91
+ self.lens = [0] * n_slots # tokens stored (no padding)
92
+ self.first = [0] * n_slots # RoPE offset = left-pad count at prefill
93
+ self.active = [False] * n_slots
94
+ self._admit_no = [0] * n_slots # admission order, for picking preemption victims
95
+ self._admits = 0
96
+ self._table_dev = torch.full((n_slots, self.max_blocks), self.scratch,
97
+ dtype=torch.long, device=device)
98
+ self._lens_dev = torch.zeros(n_slots, dtype=torch.long, device=device)
99
+ self._first_dev = torch.zeros(n_slots, dtype=torch.long, device=device)
100
+ self._active_dev = torch.zeros(n_slots, dtype=torch.long, device=device)
101
+ self._rows = torch.arange(n_slots, device=device)
102
+ self.attn_kwargs: dict = {}
103
+ self._n_read_blocks = 0
104
+ self._blk = self._off = None
105
+
106
+ # -- scheduling --------------------------------------------------------
107
+ def free_slots(self) -> list[int]:
108
+ return [s for s in range(self.n_slots) if not self.active[s]]
109
+
110
+ def can_admit(self, lengths: list[int]) -> bool:
111
+ """Enough free blocks for these prompts, plus room for each one's first decode token."""
112
+ return sum(math.ceil((n + 1) / self.bs) for n in lengths) <= self.alloc.num_free
113
+
114
+ def admit(self, slots: list[int], prefill: KVCache, pad_lens: list[int]) -> None:
115
+ """Copy row j of a padded prefill (real tokens only) into fresh blocks for slots[j]."""
116
+ L = prefill.pos
117
+ dev = self._lens_dev.device
118
+ rows, src, blk, off = [], [], [], []
119
+ for j, (s, p) in enumerate(zip(slots, pad_lens)):
120
+ n = L - p
121
+ if n >= self.max_len:
122
+ raise ValueError(f"prompt length {n} >= max_len {self.max_len}")
123
+ blocks = [self.alloc.alloc() for _ in range(math.ceil(n / self.bs))]
124
+ self.tables[s] = blocks
125
+ self.lens[s], self.first[s], self.active[s] = n, p, True
126
+ self._admits += 1
127
+ self._admit_no[s] = self._admits
128
+ self._table_dev[s].fill_(self.scratch)
129
+ self._table_dev[s, :len(blocks)] = torch.tensor(blocks, device=dev)
130
+ t = torch.arange(n)
131
+ rows.append(torch.full((n,), j))
132
+ src.append(t + p)
133
+ blk.append(torch.tensor(blocks, dtype=torch.long)[t // self.bs])
134
+ off.append(t % self.bs)
135
+ rows, src, blk, off = (torch.cat(x).to(dev) for x in (rows, src, blk, off))
136
+ for i in range(len(self.k)):
137
+ self.k[i][blk, :, off] = prefill.k[i][rows, :, src] # (N, H, D) both sides
138
+ self.v[i][blk, :, off] = prefill.v[i][rows, :, src]
139
+ self._sync()
140
+
141
+ def release(self, slot: int) -> None:
142
+ self.alloc.free(self.tables[slot])
143
+ self.tables[slot] = []
144
+ self.active[slot] = False
145
+ self.lens[slot] = self.first[slot] = 0
146
+ self._table_dev[slot].fill_(self.scratch)
147
+ self._sync()
148
+
149
+ def reserve_step(self) -> list[int]:
150
+ """Make sure the next decode step's new blocks can be allocated. Preempts the newest
151
+ sequences (frees their blocks) until they can; returns the preempted slots, newest first."""
152
+ victims = []
153
+ while True:
154
+ need = sum(1 for s in range(self.n_slots)
155
+ if self.active[s] and len(self.tables[s]) * self.bs <= self.lens[s])
156
+ if need <= self.alloc.num_free or not any(self.active):
157
+ return victims
158
+ v = max((s for s in range(self.n_slots) if self.active[s]), key=self._admit_no.__getitem__)
159
+ self.release(v)
160
+ victims.append(v)
161
+
162
+ def _sync(self) -> None:
163
+ self._lens_dev.copy_(torch.tensor(self.lens))
164
+ self._first_dev.copy_(torch.tensor(self.first))
165
+ self._active_dev.copy_(torch.tensor(self.active, dtype=torch.long))
166
+
167
+ def used_tokens(self) -> int:
168
+ return sum(n for n, a in zip(self.lens, self.active) if a)
169
+
170
+ def allocated_tokens(self) -> int:
171
+ return self.alloc.num_used * self.bs
172
+
173
+ def reserved_tokens(self) -> int:
174
+ """Pool capacity: the KV memory this cache holds."""
175
+ return self.alloc.n_blocks * self.bs
176
+
177
+ def nbytes(self) -> int:
178
+ return sum(t.numel() * t.element_size() for t in self.k + self.v)
179
+
180
+ # -- model protocol (decode only: one token per slot) ------------------
181
+ def begin_step(self, T: int, rope) -> tuple[Tensor, Tensor]:
182
+ if T != 1:
183
+ raise ValueError("PagedKVCache decodes one token per slot; prefill goes through KVCache")
184
+ if max(self.lens) >= self.max_len:
185
+ raise ValueError(f"a sequence is full (max_len {self.max_len})")
186
+ new_rows, new_cols, new_blks = [], [], []
187
+ for s in range(self.n_slots): # grow sequences that filled a block
188
+ if self.active[s] and len(self.tables[s]) * self.bs <= self.lens[s]:
189
+ b = self.alloc.alloc() # reserve_step made room
190
+ new_rows.append(s)
191
+ new_cols.append(len(self.tables[s]))
192
+ new_blks.append(b)
193
+ self.tables[s].append(b)
194
+ if new_rows:
195
+ dev = self._table_dev.device
196
+ self._table_dev[torch.tensor(new_rows, device=dev),
197
+ torch.tensor(new_cols, device=dev)] = torch.tensor(new_blks, device=dev)
198
+ self._n_read_blocks = math.ceil((max(self.lens) + 1) / self.bs)
199
+ self._blk = self._table_dev[self._rows, self._lens_dev // self.bs] # where to write
200
+ self._off = self._lens_dev % self.bs
201
+ k_pos = torch.arange(self._n_read_blocks * self.bs, device=self._lens_dev.device)
202
+ self.attn_kwargs = {"attn_mask": (k_pos <= self._lens_dev[:, None])[:, None, None, :]}
203
+ return rope.at((self._first_dev + self._lens_dev)[:, None])
204
+
205
+ def end_step(self, T: int) -> None:
206
+ for s in range(self.n_slots):
207
+ if self.active[s]:
208
+ self.lens[s] += 1
209
+ self._lens_dev += self._active_dev
210
+
211
+ def layer(self, i: int) -> "PagedLayerKV":
212
+ return PagedLayerKV(self, i)
213
+
214
+
215
+ class PagedLayerKV:
216
+ def __init__(self, cache: PagedKVCache, layer_idx: int):
217
+ self.cache = cache
218
+ self.i = layer_idx
219
+
220
+ @property
221
+ def attn_kwargs(self) -> dict:
222
+ return self.cache.attn_kwargs
223
+
224
+ def update(self, k: Tensor, v: Tensor) -> tuple[Tensor, Tensor]:
225
+ """k, v (S, H, 1, D): write each slot's token into its block, then gather its blocks."""
226
+ c, i = self.cache, self.i
227
+ c.k[i][c._blk, :, c._off] = k[:, :, 0]
228
+ c.v[i][c._blk, :, c._off] = v[:, :, 0]
229
+ table = c._table_dev[:, :c._n_read_blocks] # (S, nb)
230
+ S, nb = table.shape
231
+ _, H, bs, D = c.k[i].shape
232
+
233
+ def gather(pool: Tensor) -> Tensor: # (S, nb, H, bs, D) -> (S, H, nb*bs, D)
234
+ return pool[table].transpose(1, 2).reshape(S, H, nb * bs, D)
235
+
236
+ return gather(c.k[i]), gather(c.v[i])
src/tinyllm/plots.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 6.2 (D-030): the README and docs/RESULTS.md plots, drawn from the same run as the tables.
2
+
3
+ render_plots(data, curve) returns {file name: SVG text}; scripts/bench_all.py writes them to docs/img/.
4
+ `data` is results.load_run(); `curve` is docs/data/train_curve.json (scripts/extract_train_curve.py).
5
+
6
+ - One transparent SVG per plot, readable on GitHub's light and dark themes: mid-gray text, and a
7
+ blue / orange pair checked for colour-vision deficiency on both backgrounds. Blue = ours, orange =
8
+ HF Transformers, gray = context. Every bar and line end carries its value, so color is never the
9
+ only key.
10
+ - Byte-identical between renders (fixed SVG hash salt, no date, text drawn as paths with the font
11
+ matplotlib ships), so re-rendering leaves git clean. matplotlib's version is pinned by uv.lock.
12
+ - Pyplot is not used: no global figure state, no GUI backend.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import io
18
+
19
+ import matplotlib
20
+ from matplotlib.figure import Figure
21
+ from matplotlib.ticker import FixedLocator, FuncFormatter, NullLocator
22
+
23
+ from tinyllm.results import PAGED_ROW, n, need
24
+
25
+ OURS, HF, CONTEXT = "#3987e5", "#e0602d", "#8b949e"
26
+ INK = "#6e7681" # ≥ 4:1 on both white and GitHub's dark #0d1117
27
+ RC = {
28
+ "font.family": "DejaVu Sans", "font.size": 10, "svg.fonttype": "path", "svg.hashsalt": "tinyllm",
29
+ "text.color": INK, "axes.labelcolor": INK, "xtick.color": INK, "ytick.color": INK,
30
+ "axes.edgecolor": CONTEXT, "axes.linewidth": 0.8, "axes.spines.top": False, "axes.spines.right": False,
31
+ "axes.facecolor": "none", "figure.facecolor": "none", "savefig.transparent": True,
32
+ "xtick.major.size": 3, "ytick.major.size": 0, "legend.frameon": False,
33
+ }
34
+ GRID = {"color": CONTEXT, "alpha": 0.3, "linewidth": 0.6}
35
+
36
+
37
+ def _svg(fig: Figure) -> str:
38
+ buf = io.StringIO()
39
+ fig.savefig(buf, format="svg", metadata={"Date": None}, bbox_inches="tight", pad_inches=0.12)
40
+ return buf.getvalue()
41
+
42
+
43
+ def _head(ax, title: str, subtitle: str, lift: float = 0) -> None:
44
+ """Left-aligned title and subtitle above the plot area, placed in points so they never overlap it."""
45
+ ax.annotate(title, (0, 1), xycoords="axes fraction", xytext=(0, 30 + lift), textcoords="offset points",
46
+ fontsize=12.5, fontweight="bold", va="bottom")
47
+ ax.annotate(subtitle, (0, 1), xycoords="axes fraction", xytext=(0, 12 + lift), textcoords="offset points",
48
+ fontsize=9.5, va="bottom")
49
+
50
+
51
+ def _foot(ax, text: str, below: float = 34) -> None:
52
+ ax.annotate(text, (0, 0), xycoords="axes fraction", xytext=(0, -below), textcoords="offset points",
53
+ fontsize=8, va="top")
54
+
55
+
56
+ def _source(data: dict, name: str) -> str:
57
+ commit = (((data[name].get("provenance") or {}).get("git") or {}).get("commit") or "?")[:7]
58
+ return f"tiny-llm · {data.get('_run_dir', 'runs/bench/v1b')} · commit {commit} · RTX 3070 Ti Laptop GPU"
59
+
60
+
61
+ def decode_b1(data: dict) -> Figure:
62
+ hf = data["tl-hf"]
63
+ ceiling = hf["bandwidth_bytes_s"] / hf["weight_bytes"]
64
+ engines = [("tl-kv-graph", "Ours: KV cache + CUDA graphs", OURS), ("tl-kv", "Ours: KV cache", CONTEXT),
65
+ ("tl-hf", "HF Transformers generate", HF), ("tl-naive", "Ours: naive, no cache", CONTEXT)]
66
+ rows = [(label, need(data[nm]["rows"], nm, batch=1, prompt_len=128), c) for nm, label, c in engines]
67
+ rows.sort(key=lambda r: -r[1]["decode_tok_s"])
68
+ fig = Figure(figsize=(7.6, 2.5))
69
+ ax = fig.add_subplot()
70
+ y = range(len(rows))
71
+ ax.barh(y, [r["decode_tok_s"] for _, r, _ in rows], height=0.62, color=[c for *_, c in rows])
72
+ ax.set_yticks(list(y), [label for label, *_ in rows])
73
+ ax.invert_yaxis()
74
+ for i, (_, r, _) in enumerate(rows):
75
+ ax.text(r["decode_tok_s"] + ceiling * 0.015, i, f"{n(r['decode_tok_s'])} tok/s · {r['pct_of_ceiling']:.0f}%",
76
+ va="center", fontsize=9.5)
77
+ ax.axvline(ceiling, color=INK, linewidth=1.2, linestyle=(0, (4, 3)))
78
+ ax.text(ceiling * 1.015, len(rows) - 1, f"bandwidth\nceiling\n{n(ceiling)} tok/s", va="center", fontsize=9)
79
+ ax.set_xlim(0, ceiling * 1.28)
80
+ ax.xaxis.set_major_locator(FixedLocator([0, 50, 100, 150, 200]))
81
+ ax.grid(axis="x", **GRID)
82
+ ax.set_axisbelow(True)
83
+ ax.spines["left"].set_visible(False)
84
+ ax.set_xlabel("decode tokens / s at batch 1")
85
+ _head(ax, "TinyLlama 1.1B decode speed, batch 1",
86
+ f"128-token prompt, 256 new tokens. % = share of the {n(ceiling)} tok/s ceiling "
87
+ "(bandwidth ÷ weight bytes).")
88
+ _foot(ax, _source(data, "tl-kv-graph"), below=40)
89
+ return fig
90
+
91
+
92
+ def batch_scaling(data: dict) -> Figure:
93
+ hf = data["tl-hf"]
94
+ ceiling = hf["bandwidth_bytes_s"] / hf["weight_bytes"]
95
+ batches = sorted({r["batch"] for r in data["tl-kv-graph"]["rows"]} & {r["batch"] for r in hf["rows"]})
96
+ tok = {nm: [need(data[nm]["rows"], nm, batch=b, prompt_len=128)["decode_tok_s"] for b in batches]
97
+ for nm in ("tl-kv-graph", "tl-hf")}
98
+ fig = Figure(figsize=(7.6, 3.6))
99
+ ax = fig.add_subplot()
100
+ ax.plot(batches, [ceiling * b for b in batches], color=INK, linewidth=1.2, linestyle=(0, (4, 3)),
101
+ label=f"bandwidth ceiling ({n(ceiling)} tok/s × batch)")
102
+ ax.plot(batches, tok["tl-kv-graph"], color=OURS, linewidth=2, marker="o", markersize=7,
103
+ label="Ours: KV cache + CUDA graphs")
104
+ ax.plot(batches, tok["tl-hf"], color=HF, linewidth=2, marker="s", markersize=6.5,
105
+ label="HF Transformers generate")
106
+ # Every line rises to the right: just left of our markers (incoming line below, ceiling well above) and
107
+ # below-right of HF's are clear.
108
+ for nm, off, ha, va in (("tl-kv-graph", (-8, 2), "right", "bottom"), ("tl-hf", (6, -7), "left", "top")):
109
+ for b, v in zip(batches, tok[nm]):
110
+ ax.annotate(n(v), (b, v), xytext=off, textcoords="offset points", fontsize=9, ha=ha, va=va)
111
+ ax.set_xscale("log", base=2)
112
+ ax.set_yscale("log")
113
+ ax.xaxis.set_major_locator(FixedLocator(batches))
114
+ ax.xaxis.set_major_formatter(FuncFormatter(lambda v, _: f"{v:.0f}"))
115
+ ax.xaxis.set_minor_locator(NullLocator())
116
+ ticks = [t for t in (25, 50, 100, 200, 400, 800, 1600, 3200, 6400)
117
+ if min(tok["tl-hf"][0], tok["tl-kv-graph"][0]) / 2 <= t <= ceiling * batches[-1] * 1.5]
118
+ ax.yaxis.set_major_locator(FixedLocator(ticks))
119
+ ax.yaxis.set_major_formatter(FuncFormatter(lambda v, _: n(v)))
120
+ ax.yaxis.set_minor_locator(NullLocator())
121
+ ax.set_xlim(batches[0] / 1.7, batches[-1] * 1.9)
122
+ ax.set_ylim(min(tok["tl-hf"][0], tok["tl-kv-graph"][0]) / 1.8, ceiling * batches[-1] * 1.4)
123
+ ax.grid(axis="y", **GRID)
124
+ ax.set_xlabel("batch size (sequences decoded together)")
125
+ ax.set_ylabel("decode tokens / s (log scale)")
126
+ ax.legend(loc="upper left", fontsize=9)
127
+ wmib = hf["weight_bytes"] / 2**20
128
+ _head(ax, "Decode throughput vs batch size, TinyLlama 1.1B",
129
+ f"Each decode step reads all {n(wmib)} MiB of weights once; a batch shares that read. 128-token prompt.")
130
+ _foot(ax, _source(data, "tl-kv-graph"), below=40)
131
+ return fig
132
+
133
+
134
+ def train_loss(curve: dict) -> Figure:
135
+ tps = curve["tokens_per_step"]
136
+ tr, va = curve["train"], curve["val"]
137
+ top = 3.2
138
+ fig = Figure(figsize=(7.6, 3.2))
139
+ ax = fig.add_subplot()
140
+ ax.plot([s * tps / 1e6 for s, _ in tr], [v for _, v in tr], color=CONTEXT, linewidth=0.9, label="train loss")
141
+ ax.plot([s * tps / 1e6 for s, _ in va], [v for _, v in va], color=OURS, linewidth=2, marker="o",
142
+ markersize=3.5, label="validation loss")
143
+ last_s, last_v = va[-1]
144
+ ax.annotate(f"val {last_v:.4f}", (last_s * tps / 1e6, last_v), xytext=(-4, 14), textcoords="offset points",
145
+ ha="right", fontsize=9.5)
146
+ ax.annotate(f"← loss starts at {va[0][1]:.1f}, above the chart", (0.06, 0.97), xycoords="axes fraction",
147
+ fontsize=8.5, va="top")
148
+ ax.set_ylim(1.0, top)
149
+ ax.set_xlim(0, tr[-1][0] * tps / 1e6 * 1.01)
150
+ ax.grid(axis="y", **GRID)
151
+ ax.set_xlabel("training tokens (millions)")
152
+ ax.set_ylabel("cross-entropy loss")
153
+ ax.legend(loc="upper right", fontsize=9)
154
+ every = va[1][0] - va[0][0] if len(va) > 1 else 0
155
+ _head(ax, "Training our 30M-parameter model from scratch on TinyStories",
156
+ f"{curve['params'] / 1e6:.1f}M parameters, {tr[-1][0] * tps / 1e6:,.0f}M tokens"
157
+ + (f"; validation every {every} steps ({n(every * tps / 1e6)}M tokens)" if every else "") + ".")
158
+ _foot(ax, f"tiny-llm · docs/data/train_curve.json (from {curve.get('source', 'the training log')})", below=40)
159
+ return fig
160
+
161
+
162
+ def decode_profile(data: dict) -> Figure:
163
+ d = data["tl-profile"]
164
+ modes = [("eager", "Eager: one launch per kernel"), ("graph", "CUDA graph: one launch per step")]
165
+ fig = Figure(figsize=(7.6, 2.1))
166
+ ax = fig.add_subplot()
167
+ for i, (mode, _) in enumerate(modes):
168
+ m = d["modes"][mode]
169
+ busy = 1 - m["gpu_idle_frac"]
170
+ ax.barh(i, busy, height=0.58, color=OURS, label="GPU busy" if i == 0 else None)
171
+ ax.barh(i, m["gpu_idle_frac"], left=busy, height=0.58, color=CONTEXT, alpha=0.55,
172
+ label="GPU idle, waiting for launches" if i == 0 else None)
173
+ ops = m.get("gpu_ops_per_step")
174
+ ax.text(1.02, i, f"{m['ms_per_step']:.1f} ms / step, idle {m['gpu_idle_frac']:.0%}"
175
+ + (f"\n{n(ops)} kernels" if ops else ""), va="center", fontsize=9)
176
+ ax.set_yticks(range(len(modes)), [label for _, label in modes])
177
+ ax.invert_yaxis()
178
+ ax.set_xlim(0, 1)
179
+ ax.xaxis.set_major_formatter(FuncFormatter(lambda v, _: f"{v:.0%}"))
180
+ ax.spines["left"].set_visible(False)
181
+ ax.set_xlabel("share of one decode step")
182
+ ax.legend(loc="lower left", bbox_to_anchor=(0, 1.0), ncols=2, fontsize=9, borderaxespad=0.2)
183
+ speed = d["modes"]["eager"]["ms_per_step"] / d["modes"]["graph"]["ms_per_step"]
184
+ _head(ax, "Why CUDA graphs help: TinyLlama 1.1B decode step, batch 1",
185
+ f"In eager mode Python launches every kernel and the GPU waits; a graph replays the whole step "
186
+ f"at once ({speed:.2f}× faster).", lift=16) # above the legend
187
+ _foot(ax, _source(data, "tl-profile"), below=40)
188
+ return fig
189
+
190
+
191
+ def serving(data: dict) -> Figure:
192
+ tls, ms = data["tl-serving"]["rows"], data["30m-serving"]["rows"]
193
+ st = need(tls, "tl-serving", engine="static", size=8)
194
+ left = [("Static batching", st), ("Continuous batching", need(tls, "tl-serving", engine="continuous", size=8)),
195
+ ("Continuous + CUDA graphs", need(tls, "tl-serving", engine="cont+graph", size=8))]
196
+ c8 = need(ms, "30m-serving", engine="continuous", size=8)
197
+ pg = need(ms, "30m-serving", engine=PAGED_ROW[0], size=PAGED_ROW[1])
198
+ right = [(f"Contiguous KV: {c8['peak_active']} sequences", c8), (f"Paged KV: {pg['peak_active']} sequences", pg)]
199
+ fig = Figure(figsize=(7.6, 2.4))
200
+ axes = fig.subplots(1, 2, gridspec_kw={"wspace": 1.05, "width_ratios": [1, 1]})
201
+ for ax, rows, title in ((axes[0], left, "TinyLlama 1.1B, 8 slots"),
202
+ (axes[1], right, f"30M model, same {c8['kv_reserved_mib']:.0f} MiB of KV")):
203
+ base = rows[0][1]["tok_s"]
204
+ vals = [r["tok_s"] for _, r in rows]
205
+ ax.barh(range(len(rows)), vals, height=0.6, color=OURS)
206
+ ax.set_yticks(range(len(rows)), [label for label, _ in rows])
207
+ ax.invert_yaxis()
208
+ for i, v in enumerate(vals):
209
+ ax.text(v + max(vals) * 0.03, i, n(v) + (f" ({v / base:.2f}×)" if i else ""), va="center", fontsize=9)
210
+ ax.set_xlim(0, max(vals) * 1.45)
211
+ ax.xaxis.set_major_locator(FixedLocator([]))
212
+ ax.spines["left"].set_visible(False)
213
+ ax.spines["bottom"].set_visible(False)
214
+ ax.set_title(title, loc="left", fontsize=10, color=INK)
215
+ _head(axes[0], f"Serving throughput, {data['tl-serving']['workload']['requests']} mixed-length requests",
216
+ "Tokens / s; × = vs the first row. Continuous batching refills a slot as soon as its request ends; "
217
+ "the paged cache fits more sequences in the same memory.", lift=18) # above the panel titles
218
+ _foot(axes[0], _source(data, "tl-serving"), below=12)
219
+ return fig
220
+
221
+
222
+ AQUA = "#1aa775" # third engine; blue / orange / aqua checked for colour-vision deficiency on both themes
223
+ LOAD_STYLE = {"Ours": (OURS, "o", 7), "vLLM": (HF, "s", 6.5), "llama.cpp": (AQUA, "D", 6)}
224
+
225
+
226
+ def load_sweep(load: dict) -> Figure:
227
+ """docs/data/load_sweep.json (scripts/extract_load_sweep.py): the 4.8a equal-seat sweep, one row per rate."""
228
+ col = {c: i for i, c in enumerate(load["columns"])}
229
+ eng = {name: [[p[col[c]] for p in e["points"]] for c in ("rate_rps", "achieved_rps", "ttft_p95_s", "shed_rate")]
230
+ for name, e in load["engines"].items()}
231
+ top = max(r[-1] for r, *_ in eng.values())
232
+ fig = Figure(figsize=(7.6, 3.3))
233
+ left, right = fig.subplots(1, 2, gridspec_kw={"wspace": 0.7})
234
+ peak = max(max(a) for _, a, *_ in eng.values())
235
+ left.plot([0, peak * 1.12], [0, peak * 1.12], color=INK, linewidth=1.2, linestyle=(0, (4, 3)))
236
+ left.annotate("every request\nserved", (peak, peak), xytext=(-8, 0), textcoords="offset points", ha="right",
237
+ va="center", fontsize=8.5)
238
+ slo = load["slo_ttft_p95_s"]
239
+ right.axhline(slo, color=INK, linewidth=1.2, linestyle=(0, (4, 3)))
240
+ right.annotate(f"SLO {n(slo)} s", (top, slo), xytext=(0, 3), textcoords="offset points", ha="right", va="bottom",
241
+ fontsize=8.5)
242
+ for name, (rate, done, ttft, shed) in eng.items():
243
+ c, m, ms = LOAD_STYLE[name]
244
+ for ax, y in ((left, done), (right, ttft)):
245
+ ax.plot(rate, y, color=c, linewidth=2, marker=m, markersize=ms)
246
+ left.annotate(f"{name} {done[-1]:.1f}", (rate[-1], done[-1]), xytext=(7, 0), textcoords="offset points",
247
+ va="center", fontsize=9, annotation_clip=False)
248
+ if shed[-1] > 0: # the gap between our flat line and the diagonal is rejected, not queued
249
+ i = len(rate) - 2
250
+ left.annotate(f"{shed[i] * 100:.0f}–{shed[-1]:.0%} rejected\nwith a 429", (rate[-1], done[-1]), xytext=(0, 9),
251
+ textcoords="offset points", ha="right", va="bottom", fontsize=8.5)
252
+ end = f"{name} {ttft[-1]:.0f} s" if ttft[-1] >= 10 else f"{name} {ttft[-1]:.1f} s"
253
+ if shed[-1] > 0: # flat because the bounded queue rejects, not because the engine is faster
254
+ right.annotate(end + ": capped;\na full queue answers\nnew requests with a 429", (rate[-1], ttft[-1]),
255
+ xytext=(7, 6), textcoords="offset points", va="top", fontsize=9, annotation_clip=False)
256
+ else:
257
+ right.annotate(end, (rate[-1], ttft[-1]), xytext=(7, 0), textcoords="offset points", va="center",
258
+ fontsize=9, annotation_clip=False)
259
+ for ax in (left, right):
260
+ ax.set_xlim(0, top * 1.04)
261
+ ax.xaxis.set_major_locator(FixedLocator([0, 4, 8, 12, 16]))
262
+ ax.grid(axis="y", **GRID)
263
+ ax.set_xlabel("offered requests / s")
264
+ left.set_ylim(0, peak * 1.12)
265
+ left.set_title("Completed requests / s", loc="left", fontsize=10, color=INK)
266
+ right.set_yscale("log")
267
+ right.yaxis.set_major_locator(FixedLocator([0.1, 1, 10, 100]))
268
+ right.yaxis.set_major_formatter(FuncFormatter(lambda v, _: f"{v:g} s"))
269
+ right.yaxis.set_minor_locator(NullLocator())
270
+ right.set_ylim(0.02, max(max(t) for _, _, t, _ in eng.values()) * 2)
271
+ right.set_title("Time to first token p95, served requests", loc="left", fontsize=10, color=INK)
272
+ w = load["workload"]
273
+ _head(left, "TinyLlama 1.1B under load: 8 sequences per engine",
274
+ f"Poisson arrivals, {w['max_tokens']} new tokens per request, {w['duration']:.0f} s per rate. Past capacity "
275
+ "we reject the excess; vLLM and llama.cpp queue it.", lift=18) # above the panel titles
276
+ commit = next(iter(load["engines"].values()))["commit"]
277
+ _foot(left, f"tiny-llm · docs/data/load_sweep.json ({load['source']}) · commit {commit} · "
278
+ "RTX 3070 Ti Laptop GPU, thermally throttled", below=40)
279
+ return fig
280
+
281
+
282
+ DRAW = {"decode_b1.svg": decode_b1, "batch_scaling.svg": batch_scaling,
283
+ "decode_profile.svg": decode_profile, "serving.svg": serving}
284
+
285
+
286
+ def render_plots(data: dict, curve: dict | None, load: dict | None = None) -> dict[str, str]:
287
+ """{file name: SVG text}; train_loss.svg and load_sweep.svg only when their docs/data files exist."""
288
+ with matplotlib.rc_context(RC):
289
+ out = {name: _svg(draw(data)) for name, draw in DRAW.items()}
290
+ if curve is not None:
291
+ out["train_loss.svg"] = _svg(train_loss(curve))
292
+ if load is not None:
293
+ out["load_sweep.svg"] = _svg(load_sweep(load))
294
+ return out
src/tinyllm/provenance.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 4.12 (D-025): record where every benchmark number came from.
2
+
3
+ prov = Provenance(weights="fp32 + bf16 autocast").start() # after model load, before timing
4
+ ... timed runs ...
5
+ json_out["provenance"] = prov.finish()
6
+
7
+ Recorded:
8
+ static UTC time, git commit and whether tracked files are modified, python / torch / CUDA /
9
+ cuDNN versions, GPU name and driver, plus any fields passed in (e.g. the weight dtype)
10
+ before GPU sample, WSL load average, Windows CPU load %
11
+ during GPU samples from one `nvidia-smi -lms` process running next to the benchmark:
12
+ min / median / max of SM clock, memory clock, temperature, power, utilisation; every
13
+ throttle reason seen; and the WSL load average
14
+ after same as before
15
+
16
+ Notes:
17
+ - WSL's /proc/loadavg only sees the Linux VM. Load from Windows programs (the usual cause of
18
+ the 4.5 outliers) shows up only in the Windows CPU %, read via powershell.exe.
19
+ - That read costs ~2 s of CPU, so it runs only before and after, never during the timed region.
20
+ - The GPU sampler is a separate process, so it costs the benchmark's Python process nothing.
21
+ Every probe is optional: missing tools give None, never an error.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import platform
28
+ import shutil
29
+ import statistics
30
+ import subprocess
31
+ import threading
32
+ import time
33
+ from datetime import datetime, timezone
34
+ from pathlib import Path
35
+
36
+ import torch
37
+
38
+ GPU_FIELDS = ["pstate", "clocks.sm", "clocks.mem", "temperature.gpu", "power.draw",
39
+ "utilization.gpu", "memory.used", "clocks_throttle_reasons.active"]
40
+ NUMERIC = ["clocks.sm", "clocks.mem", "temperature.gpu", "power.draw", "utilization.gpu", "memory.used"]
41
+ BUSY_UTIL = 10 # % GPU utilisation at or above which a sample counts as "busy"
42
+
43
+ # NVML clocksThrottleReasons bits (nvml.h)
44
+ THROTTLE_BITS = {
45
+ 0x1: "gpu_idle", 0x2: "applications_clocks_setting", 0x4: "sw_power_cap", 0x8: "hw_slowdown",
46
+ 0x10: "sync_boost", 0x20: "sw_thermal_slowdown", 0x40: "hw_thermal_slowdown",
47
+ 0x80: "hw_power_brake_slowdown", 0x100: "display_clock_setting",
48
+ }
49
+ REPO = Path(__file__).resolve().parents[2]
50
+
51
+
52
+ def _run(cmd: list[str], timeout: float = 10.0) -> str | None:
53
+ try:
54
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, cwd=REPO)
55
+ except (OSError, subprocess.SubprocessError):
56
+ return None
57
+ return r.stdout.strip() if r.returncode == 0 else None
58
+
59
+
60
+ def git_info() -> dict:
61
+ commit = _run(["git", "rev-parse", "HEAD"])
62
+ status = _run(["git", "status", "--porcelain", "--untracked-files=no"])
63
+ return {"commit": commit, "dirty": None if status is None else bool(status)}
64
+
65
+
66
+ def claim_output(path: str | Path, overwrite: bool = False) -> Path:
67
+ """Call before a benchmark starts: refuse an existing result file unless overwrite (6.8).
68
+
69
+ The 11-04 TinyLlama serving JSON was lost because a later run with other flags wrote the same
70
+ default name. Checking at startup costs nothing; finding out after an hour's run costs the hour.
71
+ """
72
+ path = Path(path)
73
+ if path.exists() and not overwrite:
74
+ raise SystemExit(f"{path} already exists: pass a new --out, or --overwrite to replace it")
75
+ path.parent.mkdir(parents=True, exist_ok=True)
76
+ return path
77
+
78
+
79
+ def decode_throttle(mask: int | None) -> list[str]:
80
+ if mask is None:
81
+ return []
82
+ names = [name for bit, name in THROTTLE_BITS.items() if mask & bit]
83
+ rest = mask & ~sum(THROTTLE_BITS)
84
+ return names + ([f"unknown_0x{rest:x}"] if rest else [])
85
+
86
+
87
+ def parse_gpu_line(line: str) -> dict | None:
88
+ """One `nvidia-smi --query-gpu=<GPU_FIELDS> --format=csv,noheader,nounits` line -> dict."""
89
+ parts = [p.strip() for p in line.strip().split(",")]
90
+ if len(parts) != len(GPU_FIELDS):
91
+ return None
92
+ out: dict = {}
93
+ for k, v in zip(GPU_FIELDS, parts):
94
+ if v in ("[N/A]", "N/A", "[Not Supported]", ""):
95
+ out[k] = None
96
+ elif k == "pstate":
97
+ out[k] = v
98
+ elif k == "clocks_throttle_reasons.active":
99
+ out[k] = int(v, 16)
100
+ else:
101
+ try:
102
+ out[k] = float(v)
103
+ except ValueError:
104
+ out[k] = None
105
+ return out
106
+
107
+
108
+ def _smi() -> str | None:
109
+ return shutil.which("nvidia-smi")
110
+
111
+
112
+ def gpu_sample() -> dict | None:
113
+ smi = _smi()
114
+ if smi is None:
115
+ return None
116
+ out = _run([smi, f"--query-gpu={','.join(GPU_FIELDS)}", "--format=csv,noheader,nounits"])
117
+ return parse_gpu_line(out.splitlines()[0]) if out else None
118
+
119
+
120
+ def wsl_loadavg() -> float | None:
121
+ try:
122
+ return float(Path("/proc/loadavg").read_text().split()[0])
123
+ except (OSError, ValueError, IndexError):
124
+ return None
125
+
126
+
127
+ def windows_cpu_percent() -> float | None:
128
+ ps = shutil.which("powershell.exe")
129
+ if ps is None:
130
+ return None
131
+ out = _run([ps, "-NoProfile", "-Command",
132
+ "(Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average"],
133
+ timeout=20)
134
+ try:
135
+ return float(out) if out else None
136
+ except ValueError:
137
+ return None
138
+
139
+
140
+ def host_sample() -> dict:
141
+ return {"gpu": gpu_sample(), "wsl_loadavg_1m": wsl_loadavg(), "windows_cpu_percent": windows_cpu_percent()}
142
+
143
+
144
+ def summarize(samples: list[dict], loads: list[float]) -> dict:
145
+ """min / median / max per numeric GPU field, the throttle reasons seen, and the WSL load."""
146
+ out: dict = {"n_samples": len(samples)}
147
+ for k in NUMERIC:
148
+ vals = [s[k] for s in samples if s.get(k) is not None]
149
+ out[k] = ({"min": min(vals), "median": statistics.median(vals), "max": max(vals)} if vals else None)
150
+ # Idle gaps (startup, CPU-only work between runs) drag the minimum down; this is the clock while working.
151
+ busy = [s["clocks.sm"] for s in samples
152
+ if s.get("clocks.sm") is not None and (s.get("utilization.gpu") or 0) >= BUSY_UTIL]
153
+ out["clocks.sm_busy"] = ({"min": min(busy), "median": statistics.median(busy), "max": max(busy),
154
+ "n": len(busy)} if busy else None)
155
+ mask = 0
156
+ for s in samples:
157
+ mask |= s.get("clocks_throttle_reasons.active") or 0
158
+ out["throttle_reasons_seen"] = decode_throttle(mask)
159
+ out["pstates_seen"] = sorted({s["pstate"] for s in samples if s.get("pstate")})
160
+ out["wsl_loadavg_1m"] = ({"min": min(loads), "max": max(loads)} if loads else None)
161
+ return out
162
+
163
+
164
+ class GpuMonitor:
165
+ """Streams `nvidia-smi -lms <interval>` in a background thread (one process for the whole run)."""
166
+
167
+ def __init__(self, interval_ms: int = 500):
168
+ self.interval_ms = interval_ms
169
+ self.samples: list[dict] = []
170
+ self.loads: list[float] = []
171
+ self._proc: subprocess.Popen | None = None
172
+ self._thread: threading.Thread | None = None
173
+
174
+ def start(self) -> GpuMonitor:
175
+ smi = _smi()
176
+ if smi is None:
177
+ return self
178
+ try:
179
+ self._proc = subprocess.Popen(
180
+ [smi, f"--query-gpu={','.join(GPU_FIELDS)}", "--format=csv,noheader,nounits",
181
+ f"-lms={self.interval_ms}"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
182
+ except OSError:
183
+ return self
184
+ self._thread = threading.Thread(target=self._read, daemon=True)
185
+ self._thread.start()
186
+ return self
187
+
188
+ def _read(self) -> None:
189
+ assert self._proc is not None and self._proc.stdout is not None
190
+ for line in self._proc.stdout:
191
+ s = parse_gpu_line(line)
192
+ if s is not None:
193
+ self.samples.append(s)
194
+ load = wsl_loadavg()
195
+ if load is not None:
196
+ self.loads.append(load)
197
+
198
+ def stop(self) -> dict:
199
+ if self._proc is not None:
200
+ self._proc.terminate()
201
+ try:
202
+ self._proc.wait(timeout=5)
203
+ except subprocess.TimeoutExpired:
204
+ self._proc.kill()
205
+ if self._thread is not None:
206
+ self._thread.join(timeout=5)
207
+ return summarize(self.samples, self.loads)
208
+
209
+
210
+ class Provenance:
211
+ def __init__(self, interval_ms: int = 500, **extra):
212
+ self.record: dict = {
213
+ "time_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
214
+ "git": git_info(),
215
+ "python": platform.python_version(),
216
+ "torch": torch.__version__,
217
+ "cuda": torch.version.cuda,
218
+ "cudnn": torch.backends.cudnn.version() if torch.backends.cudnn.is_available() else None,
219
+ "gpu_name": torch.cuda.get_device_name() if torch.cuda.is_available() else None,
220
+ "driver": _run([_smi(), "--query-gpu=driver_version", "--format=csv,noheader"]) if _smi() else None,
221
+ "cpu_count": os.cpu_count(),
222
+ **extra,
223
+ }
224
+ self._monitor = GpuMonitor(interval_ms)
225
+ self._t0 = 0.0
226
+
227
+ def start(self) -> Provenance:
228
+ self.record["before"] = host_sample()
229
+ self._monitor.start()
230
+ self._t0 = time.perf_counter()
231
+ return self
232
+
233
+ def finish(self) -> dict:
234
+ self.record["monitored_s"] = round(time.perf_counter() - self._t0, 2)
235
+ self.record["during"] = self._monitor.stop()
236
+ self.record["after"] = host_sample()
237
+ return self.record
src/tinyllm/quant.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 4.6: INT8 weight-only quantization (per-output-channel, symmetric absmax).
2
+
3
+ Each Linear weight row j gets one scale s_j = max|W_j| / 127 and is stored as
4
+ q_j = round(W_j / s_j), clipped to [-127, 127] (int8, 1 byte instead of 2 for bf16).
5
+ Activations stay in floating point ("weight-only", W8A16).
6
+
7
+ Because the scale is per output channel, it factors out of the matmul exactly:
8
+ y_j = sum_i x_i * (q_ji * s_j) = s_j * sum_i x_i * q_ji
9
+ so the forward is F.linear(x, q.to(dtype)) * s : cast int8 -> bf16 (exact: |q| <= 127 < 256),
10
+ matmul, scale the output. Values are the same as dequantizing W first. In PyTorch the int8 ->
11
+ bf16 cast is its own kernel (backend "torch"). Backend "triton" (4.10b) runs one kernel that reads
12
+ the int8 weights directly (tinyllm.kernels.int8_gemv) for decode-sized inputs on CUDA, and falls
13
+ back to the torch path otherwise (CPU, fp32, prefill-sized inputs, no Triton).
14
+
15
+ Embeddings stay in floating point: a lookup reads only the B rows it needs, not the table.
16
+ With tied embeddings, lm_head gets its own int8 copy and the embedding keeps the original.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import torch
22
+ import torch.nn.functional as F
23
+ from torch import Tensor, nn
24
+
25
+ QMAX = 127 # symmetric: -128 is unused so that +/- ranges match
26
+ BACKENDS = ("torch", "triton")
27
+
28
+
29
+ def quantize_per_channel(w: Tensor) -> tuple[Tensor, Tensor]:
30
+ """(out, in) float weight -> (int8 q (out, in), fp32 scale (out,)). An all-zero row gets scale 0."""
31
+ w = w.detach().float()
32
+ scale = w.abs().amax(dim=1) / QMAX
33
+ q = torch.round(w / scale.clamp_min(1e-12)[:, None]).clamp_(-QMAX, QMAX).to(torch.int8)
34
+ return q, scale
35
+
36
+
37
+ def dequantize(q: Tensor, scale: Tensor) -> Tensor:
38
+ return q.float() * scale[:, None]
39
+
40
+
41
+ class Int8Linear(nn.Module):
42
+ """Drop-in for a bias-free nn.Linear, with int8 weights and one fp32 scale per output channel."""
43
+
44
+ def __init__(self, q: Tensor, scale: Tensor, backend: str = "torch"):
45
+ super().__init__()
46
+ self.in_features, self.out_features = q.shape[1], q.shape[0]
47
+ self.register_buffer("qweight", q)
48
+ self.register_buffer("scale", scale)
49
+ self.backend = _check_backend(backend)
50
+
51
+ @classmethod
52
+ def from_linear(cls, m: nn.Linear, backend: str = "torch") -> Int8Linear:
53
+ if m.bias is not None:
54
+ raise ValueError("Int8Linear supports bias-free Linear only (the model has no biases)")
55
+ return cls(*quantize_per_channel(m.weight), backend=backend)
56
+
57
+ def forward(self, x: Tensor) -> Tensor:
58
+ dev = x.device.type
59
+ dt = torch.get_autocast_dtype(dev) if torch.is_autocast_enabled(dev) else x.dtype
60
+ if self.backend == "triton" and x.is_cuda and dt in (torch.bfloat16, torch.float16):
61
+ kernel = _triton_kernel()
62
+ rows = x.numel() // self.in_features
63
+ if kernel is not None and rows <= kernel.MAX_M:
64
+ y = kernel.int8_matmul(x.reshape(rows, self.in_features).to(dt), self.qweight, self.scale)
65
+ return y.view(*x.shape[:-1], self.out_features)
66
+ # Output keeps fp32 under autocast (bf16 matmul result * fp32 scale), so the scale isn't
67
+ # rounded to bf16. Downstream ops cast as they would after an nn.Linear.
68
+ return F.linear(x.to(dt), self.qweight.to(dt)) * self.scale
69
+
70
+ def extra_repr(self) -> str:
71
+ return (f"in_features={self.in_features}, out_features={self.out_features}, int8 per-channel, "
72
+ f"backend={self.backend}")
73
+
74
+
75
+ def _check_backend(backend: str) -> str:
76
+ if backend not in BACKENDS:
77
+ raise ValueError(f"unknown Int8Linear backend {backend!r}; one of {BACKENDS}")
78
+ return backend
79
+
80
+
81
+ _KERNEL: list = []
82
+
83
+
84
+ def _triton_kernel():
85
+ """tinyllm.kernels.int8_gemv, or None without Triton (imported once, on first CUDA use)."""
86
+ if not _KERNEL:
87
+ try:
88
+ from tinyllm.kernels import int8_gemv
89
+ except ImportError:
90
+ int8_gemv = None
91
+ _KERNEL.append(int8_gemv)
92
+ return _KERNEL[0]
93
+
94
+
95
+ def quantize_model(model: nn.Module, backend: str = "torch") -> int:
96
+ """Replace every nn.Linear in `model` (in place) with an Int8Linear. Returns how many."""
97
+ targets = [(parent, name, child) for parent in model.modules()
98
+ for name, child in parent.named_children() if isinstance(child, nn.Linear)]
99
+ for parent, name, child in targets:
100
+ q = Int8Linear.from_linear(child, backend).to(child.weight.device)
101
+ setattr(parent, name, q)
102
+ return len(targets)
103
+
104
+
105
+ def set_backend(model: nn.Module, backend: str) -> int:
106
+ """Switch every Int8Linear in `model` to `backend` (same weights, no copy). Returns how many."""
107
+ _check_backend(backend)
108
+ mods = [m for m in model.modules() if isinstance(m, Int8Linear)]
109
+ for m in mods:
110
+ m.backend = backend
111
+ return len(mods)
112
+
113
+
114
+ def weight_bytes(model: nn.Module, float_bytes: int | None = None) -> dict[str, int]:
115
+ """Bytes of persistent weights (params + saved buffers; RoPE tables excluded), split into the
116
+ matmul weights read every decode step and the embedding table (only B rows read per step).
117
+
118
+ float_bytes: count float tensors at this width (2 = as if stored in bf16); None = as stored.
119
+ Int8Linear tensors are always counted as stored (int8 weights, fp32 scales).
120
+ A tied matrix is counted once, under matmul: lm_head reads all of it every step."""
121
+ def nbytes(t: Tensor, as_stored: bool) -> int:
122
+ if t.is_floating_point() and float_bytes is not None and not as_stored:
123
+ return t.numel() * float_bytes
124
+ return t.numel() * t.element_size()
125
+
126
+ is_mm = lambda m: isinstance(m, (nn.Linear, Int8Linear)) # noqa: E731
127
+ mods = sorted(model.modules(), key=lambda m: not is_mm(m)) # matmul modules claim shared tensors first
128
+ seen, out = set(), {"matmul": 0, "embedding": 0, "other": 0}
129
+ for mod in mods:
130
+ tensors = list(mod.named_parameters(recurse=False)) + [
131
+ (n, b) for n, b in mod.named_buffers(recurse=False)
132
+ if n not in mod._non_persistent_buffers_set]
133
+ for _, t in tensors:
134
+ if id(t) in seen:
135
+ continue
136
+ seen.add(id(t))
137
+ kind = "matmul" if is_mm(mod) else "embedding" if isinstance(mod, nn.Embedding) else "other"
138
+ out[kind] += nbytes(t, as_stored=isinstance(mod, Int8Linear)) # its scales stay fp32
139
+ return out
src/tinyllm/results.py ADDED
@@ -0,0 +1,654 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 6.8 (D-029): the results tables, rendered from one benchmark run's JSON files.
2
+
3
+ scripts/bench_all.py runs every benchmark in BENCHES into one directory (runs/bench/v1b/), then calls
4
+ render_results() for docs/RESULTS.md and render_readme() for the README's <!-- bench:NAME --> blocks.
5
+ Everything here is pure (JSON in, markdown out), so the CPU tests cover it without a GPU.
6
+
7
+ Rules this module enforces:
8
+ - every table is built from one run directory: nothing is mixed in from other sessions
9
+ - a missing file is an error naming the `--only` command that makes it, never an empty table
10
+ - each table carries a heat line from its files' provenance, since absolute tok/s on this laptop
11
+ moves 10-20% between sessions with temperature
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ # A sustained clock below this, or a hardware slowdown, means the numbers are not this GPU's normal
22
+ # (11-04 runs: busy median ~1,600 MHz at 85 °C, with sw_power_cap / sw_thermal_slowdown only).
23
+ MIN_CLOCK_MHZ = 1400
24
+ HARD_THROTTLE = {"hw_slowdown", "hw_thermal_slowdown", "hw_power_brake_slowdown"}
25
+ PAGED_ROW = ("paged/16", 32) # the README's paged row: 16-token blocks, 32 slots, the 8-slot budget
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Bench:
30
+ name: str
31
+ script: str
32
+ args: tuple[str, ...]
33
+ out: str # path inside the run directory
34
+ minutes: int # rough, for the run plan printout
35
+ env: tuple[tuple[str, str], ...] = () # extra environment for this benchmark, recorded in the manifest
36
+
37
+ @property
38
+ def out_dir_flag(self) -> bool: # profile_decode takes a directory, the others a file
39
+ return self.script == "profile_decode.py"
40
+
41
+
42
+ TL = ("--model", "tinyllama")
43
+ # The naive engine runs a full forward per token, so its activations and logits grow by one position every
44
+ # step. No cached block fits the next request, the caching allocator keeps reserving new ones until the GPU
45
+ # is full (7,991 MiB in use for a 475 MiB peak at 30M B=32 P=128), and a cuBLAS call then fails
46
+ # (6.8, 2026-11-06). Expandable segments let a block grow in place: 695 MiB, same speed. Only the naive
47
+ # benchmarks need it; the other engines use fixed-size caches.
48
+ GROWING = (("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True"),)
49
+ BENCHES = [
50
+ Bench("30m-naive", "bench_generate.py", ("--engine", "naive", "--dtype", "bf16"), "30m/naive.json", 4, GROWING),
51
+ Bench("30m-kv", "bench_generate.py", ("--engine", "kv", "--dtype", "bf16"), "30m/kv.json", 2),
52
+ Bench("30m-kv-graph", "bench_generate.py", ("--engine", "kv-graph", "--dtype", "bf16"), "30m/kv-graph.json", 2),
53
+ Bench("30m-serving", "bench_serving.py", ("--cuda-graph", "--paged"), "30m/serving.json", 12),
54
+ Bench("30m-int8", "bench_quant.py", (), "30m/int8.json", 4),
55
+ Bench("30m-profile", "profile_decode.py", ("--batch", "1"), "30m/profile/decode_main_B1.json", 1),
56
+ Bench("tl-logits", "tinyllama_vs_hf.py", (), "tinyllama/logits_vs_hf.json", 4),
57
+ Bench("tl-hf", "bench_generate.py", (*TL, "--engine", "hf"), "tinyllama/hf.json", 12),
58
+ Bench("tl-naive", "bench_generate.py", (*TL, "--engine", "naive"), "tinyllama/naive.json", 6, GROWING),
59
+ Bench("tl-kv", "bench_generate.py", (*TL, "--engine", "kv"), "tinyllama/kv.json", 10),
60
+ Bench("tl-kv-graph", "bench_generate.py", (*TL, "--engine", "kv-graph"), "tinyllama/kv-graph.json", 6),
61
+ Bench("tl-serving", "bench_serving.py", (*TL, "--cuda-graph"), "tinyllama/serving.json", 15),
62
+ Bench("tl-int8", "bench_quant.py", TL, "tinyllama/int8.json", 10),
63
+ # 4.10b: the Triton INT8 kernel, end to end with CUDA graphs, and its matmuls alone
64
+ Bench("tl-int8-graph", "bench_quant.py", (*TL, "--engine", "kv-graph", "--triton"),
65
+ "tinyllama/int8_graph_triton.json", 12),
66
+ Bench("tl-int8-gemv", "bench_int8_gemv.py", (), "tinyllama/int8_gemv.json", 3),
67
+ # 4.10a: the Triton RMSNorm + residual add, alone and end to end with CUDA graphs
68
+ Bench("tl-rmsnorm", "bench_rmsnorm.py", (), "tinyllama/rmsnorm_graph.json", 10),
69
+ Bench("tl-profile", "profile_decode.py", (*TL, "--batch", "1"), "tinyllama/profile/decode_tinyllama_B1.json", 2),
70
+ ]
71
+ BY_NAME = {b.name: b for b in BENCHES}
72
+ MANIFEST = "manifest.json"
73
+ # Optional results run by hand, read when present (None otherwise). 4.10c: tl-logits with our RMSNorm forced
74
+ # to the PyTorch code (tinyllama_vs_hf.py --norm-backend torch), which shows where D-032's logit gap comes from.
75
+ EXTRAS = {"tl-logits-torchnorm": "tinyllama/logits_vs_hf_torchnorm.json"}
76
+
77
+
78
+ class MissingResult(Exception):
79
+ pass
80
+
81
+
82
+ def missing(run_dir: str | Path) -> list[Bench]:
83
+ return [b for b in BENCHES if not (Path(run_dir) / b.out).exists()]
84
+
85
+
86
+ def load_run(run_dir: str | Path) -> dict:
87
+ """{bench name: its JSON, "manifest": manifest or {}}; raises MissingResult listing every gap."""
88
+ run_dir = Path(run_dir)
89
+ gaps = missing(run_dir)
90
+ if gaps:
91
+ lines = "\n".join(f" {run_dir / b.out} -> scripts/bench_all.py --only {b.name}" for b in gaps)
92
+ raise MissingResult(f"{len(gaps)} result file(s) missing from {run_dir}:\n{lines}")
93
+ data = {b.name: json.loads((run_dir / b.out).read_text()) for b in BENCHES}
94
+ for name, out in EXTRAS.items():
95
+ data[name] = json.loads((run_dir / out).read_text()) if (run_dir / out).exists() else None
96
+ m = run_dir / MANIFEST
97
+ data["manifest"] = json.loads(m.read_text()) if m.exists() else {}
98
+ return data
99
+
100
+
101
+ # ---------------------------------------------------------------- formatting
102
+
103
+
104
+ def n(x: float, d: int = 0) -> str:
105
+ return f"{x:,.{d}f}"
106
+
107
+
108
+ def table(header: list[str], rows: list[list[str]]) -> str:
109
+ out = ["| " + " | ".join(header) + " |", "|" + "---|" * len(header)]
110
+ out += ["| " + " | ".join(r) + " |" for r in rows]
111
+ return "\n".join(out)
112
+
113
+
114
+ def row(rows: list[dict], **kv) -> dict | None:
115
+ return next((r for r in rows if all(r.get(k) == v for k, v in kv.items())), None)
116
+
117
+
118
+ def need(rows: list[dict], what: str, **kv) -> dict:
119
+ r = row(rows, **kv)
120
+ if r is None or "skipped" in r:
121
+ raise MissingResult(f"{what}: no row with {kv}")
122
+ return r
123
+
124
+
125
+ # ---------------------------------------------------------------- heat
126
+
127
+
128
+ def heat(prov: dict | None) -> dict | None:
129
+ """Busy SM clock median / min, peak temperature, throttle reasons (gpu_idle dropped), warnings."""
130
+ if not prov or not prov.get("during"):
131
+ return None
132
+ d = prov["during"]
133
+ clock = d.get("clocks.sm_busy") or d.get("clocks.sm") or {}
134
+ reasons = [r for r in d.get("throttle_reasons_seen") or [] if r != "gpu_idle"]
135
+ warn = sorted(HARD_THROTTLE.intersection(reasons))
136
+ # A low clock alone is not throttling: small or host-bound steps leave the GPU idle and it clocks down by
137
+ # itself (30M KV decode: 1,095 MHz at 65 °C). Flag it only when the driver also reports a thermal slowdown.
138
+ if (clock.get("median") is not None and clock["median"] < MIN_CLOCK_MHZ
139
+ and "sw_thermal_slowdown" in reasons):
140
+ warn.append(f"busy clock median {clock['median']:.0f} MHz < {MIN_CLOCK_MHZ}")
141
+ return {"clock_median": clock.get("median"), "clock_min": clock.get("min"),
142
+ "temp_max": (d.get("temperature.gpu") or {}).get("max"), "reasons": reasons, "warn": warn}
143
+
144
+
145
+ def heat_text(h: dict | None) -> str:
146
+ if h is None:
147
+ return "not recorded"
148
+ parts = []
149
+ if h["clock_median"] is not None:
150
+ parts.append(f"{n(h['clock_median'])} MHz (min {n(h['clock_min'])})")
151
+ if h["temp_max"] is not None:
152
+ parts.append(f"{h['temp_max']:.0f} °C")
153
+ parts.append(" + ".join(h["reasons"]) or "no throttling")
154
+ return ("⚠ " if h["warn"] else "") + ", ".join(parts)
155
+
156
+
157
+ def heat_line(data: dict, names: list[str], labels: list[str] | None = None) -> str:
158
+ labels = labels or names
159
+ body = "; ".join(f"{lab}: {heat_text(heat(data[nm].get('provenance')))}" for nm, lab in zip(names, labels))
160
+ return f"_Heat (busy SM clock median, peak GPU temperature, throttle reasons): {body}._"
161
+
162
+
163
+ def warnings(data: dict) -> list[str]:
164
+ out = []
165
+ for b in BENCHES:
166
+ h = heat(data[b.name].get("provenance"))
167
+ if h and h["warn"]:
168
+ out.append(f"{b.name}: {', '.join(h['warn'])} -> consider scripts/bench_all.py --only {b.name}")
169
+ return out
170
+
171
+
172
+ # ---------------------------------------------------------------- tables
173
+
174
+
175
+ def ceiling_note(d: dict) -> str:
176
+ bw, wb = d["bandwidth_bytes_s"], d["weight_bytes"]
177
+ return (f"{bw / 1e9:.0f} GB/s ÷ {n(wb / 2**20)} MiB = **{n(bw / wb)} tok/s per sequence**")
178
+
179
+
180
+ def repeats_note(d: dict) -> str:
181
+ r = (d.get("provenance") or {}).get("repeats")
182
+ return f"median of {r} runs" if r else "median of repeated runs"
183
+
184
+
185
+ TL_ENGINES = [("tl-hf", "HF Transformers `generate` (bf16, SDPA)"),
186
+ ("tl-naive", "Ours, naive (full forward per token)"),
187
+ ("tl-kv", "Ours, KV cache"),
188
+ ("tl-kv-graph", "Ours, KV cache + CUDA graphs")]
189
+
190
+
191
+ def tl_b1_table(data: dict, prompt_len: int = 128) -> str:
192
+ rows = []
193
+ for name, label in TL_ENGINES:
194
+ r = need(data[name]["rows"], name, batch=1, prompt_len=prompt_len)
195
+ cells = [label, n(r["decode_tok_s"]), f"{r['pct_of_ceiling']:.0f}%", f"{r['ms_per_step']:.1f}"]
196
+ rows.append([f"**{c}**" for c in cells] if name == "tl-kv-graph" else cells)
197
+ return table(["Engine", "tok/s", "% of ceiling", "ms / step"], rows)
198
+
199
+
200
+ def decode_grid(data: dict, engines: list[tuple[str, str]]) -> str:
201
+ plens = sorted({r["prompt_len"] for nm, _ in engines for r in data[nm]["rows"]})
202
+ out, slides = [], False
203
+ for name, label in engines:
204
+ rows = data[name]["rows"]
205
+ for b in sorted({r["batch"] for r in rows}):
206
+ cells = [label, str(b)]
207
+ for p in plens:
208
+ r = row(rows, batch=b, prompt_len=p)
209
+ if r is None or "skipped" in r:
210
+ cells.append("—")
211
+ continue
212
+ mark = " ‡" if r.get("window_slides") else ""
213
+ slides |= bool(mark)
214
+ cells.append(f"{n(r['decode_tok_s'])} ({r['pct_of_ceiling']:.0f}%){mark}")
215
+ out.append(cells)
216
+ text = table(["Engine", "Batch", *[f"P={p}" for p in plens]], out)
217
+ text += "\n\nDecode tok/s, with the % of the bandwidth ceiling at that batch size. — = skipped: " \
218
+ "prompt + new tokens exceed the context, which a KV cache can't slide."
219
+ if slides:
220
+ text += " ‡ = prompt + new tokens exceed the context, so naive slides its window."
221
+ return text
222
+
223
+
224
+ def int8_section(d: dict) -> str:
225
+ base = d["baseline"]
226
+ variants = list(dict.fromkeys(r["variant"] for r in d["rows"]))
227
+ others = [v for v in variants if v != base]
228
+ rows = []
229
+ for b in sorted({r["batch"] for r in d["rows"]}):
230
+ tok = {v: need(d["rows"], "int8", variant=v, batch=b)["decode_tok_s"] for v in variants}
231
+ rows.append([str(b), *[n(tok[v]) for v in variants], *[f"{tok[v] / tok[base]:.2f}×" for v in others]])
232
+ text = table(["Batch", *[f"{v} tok/s" for v in variants], *[f"{v} / {base}" for v in others]], rows)
233
+ mib = {v: sum(d["weights_mib"][v].values()) for v in variants}
234
+ loss, tf, same = d["val_loss"], d["teacher_forced"], d["greedy_rows_identical"]
235
+ n_seq = same[base]
236
+ text += "\n\n" + "\n".join([
237
+ "- Weights: " + ", ".join(f"{v} {n(mib[v])} MiB" for v in variants) + ".",
238
+ "- Val loss: " + ", ".join(f"{v} {loss[v]:.4f}" for v in variants)
239
+ + " (" + ", ".join(f"{v} {loss[v] - loss[base]:+.4f}" for v in others) + f" vs {base}).",
240
+ f"- Teacher-forced on {base}'s greedy tokens: argmax agreement "
241
+ + ", ".join(f"{v} {tf[v]['argmax_agree']:.2%}" for v in variants)
242
+ + f" (median top-1 margin {tf['_median_gap']:.2f}).",
243
+ f"- Greedy rows identical to {base}: " + ", ".join(f"{v} {same[v]}/{n_seq}" for v in variants) + ".",
244
+ ])
245
+ return text
246
+
247
+
248
+ GEMV_VARIANTS = [("bf16", "bf16"), ("torch", "PyTorch int8"), ("triton", "Triton int8")]
249
+
250
+
251
+ def gemv_bandwidth(d: dict, m: int, v: str) -> tuple[float, float]:
252
+ """(bytes one decode step's matmuls must read, % of the measured bandwidth) for variant v at m rows."""
253
+ rows = [r for r in d["rows"] if r["m"] == m]
254
+ nbytes = sum(r[f"{v}_gb_s"] * r[f"{v}_us"] * 1e3 * r["per_step"] for r in rows) # GB/s × µs = kB
255
+ return nbytes, 100 * nbytes / (d["step_us"][str(m)][v] * 1e-6) / d["bandwidth"]
256
+
257
+
258
+ def gemv_table(d: dict) -> str:
259
+ ms = sorted(map(int, d["step_us"]))
260
+ rows = []
261
+ for m in ms:
262
+ s = d["step_us"][str(m)]
263
+ rows.append([str(m), *[f"{s[v] / 1e3:.2f}" for v, _ in GEMV_VARIANTS],
264
+ _ratio(s["bf16"] / s["triton"]), _ratio(s["torch"] / s["triton"], 1)])
265
+ text = table(["Rows (batch)", *[f"{lab} ms" for _, lab in GEMV_VARIANTS], "Triton vs bf16",
266
+ "Triton vs PyTorch int8"], rows)
267
+ (b_bf, p_bf), (b_tr, p_tr) = gemv_bandwidth(d, ms[0], "bf16"), gemv_bandwidth(d, ms[0], "triton")
268
+ calls = sum(r["per_step"] for r in d["rows"] if r["m"] == ms[0])
269
+ return text + (
270
+ f"\n\nTime for every matmul of one decode step (the {calls} Linears), replayed in CUDA graphs over weight "
271
+ f"copies larger than the L2 cache, so each call reads its weight from memory. At {ms[0]} row, a step must "
272
+ f"read {n(b_bf / 2**20)} MiB of bf16 weights or {n(b_tr / 2**20)} MiB of int8 weights and scales: bf16 "
273
+ f"runs at {p_bf:.0f}% of the measured {d['bandwidth'] / 1e9:.0f} GB/s, the Triton kernel at {p_tr:.0f}%. "
274
+ "PyTorch int8 casts each weight back to bf16 first, so it moves more bytes than bf16, not fewer.")
275
+
276
+
277
+ def rmsnorm_section(d: dict) -> str:
278
+ batches = sorted({r["batch"] for r in d["rows"]})
279
+ rows = []
280
+ for b in batches:
281
+ t, k = need(d["rows"], "rmsnorm", variant="torch", batch=b), need(d["rows"], "rmsnorm", variant="triton", batch=b)
282
+ rows.append([str(b), n(t["decode_tok_s"]), n(k["decode_tok_s"]), _ratio(k["decode_tok_s"] / t["decode_tok_s"]),
283
+ f"{t['ms_per_step']:.2f} → {k['ms_per_step']:.2f}"])
284
+ text = table(["Batch", "PyTorch norm tok/s", "Triton norm tok/s", "Triton vs PyTorch", "ms per step"], rows)
285
+ p, tf, alone = d["profile_b1"], d["teacher_forced"], d["kernels_alone"]
286
+ fused = sorted((r for r in alone if r["op"] == "add+norm"), key=lambda r: r["rows"])
287
+ if not fused or fused[0]["rows"] != 1:
288
+ raise MissingResult("rmsnorm alone: no add+norm row with 1 row")
289
+ one, big = fused[0], fused[-1]
290
+ return text + (
291
+ f"\n\nKV cache + CUDA graphs, the two norms alternating at each batch size. At batch 1 a decode step runs "
292
+ f"{n(p['torch']['gpu_ops_per_step'])} GPU ops with the PyTorch norm and {n(p['triton']['gpu_ops_per_step'])} "
293
+ f"with the Triton kernels (GPU busy {p['torch']['gpu_busy_ms_per_step']:.2f} → "
294
+ f"{p['triton']['gpu_busy_ms_per_step']:.2f} ms per step). Residual add + norm alone: one row takes "
295
+ f"{one['torch_us']:.1f} µs in PyTorch and {one['triton_us']:.1f} µs in Triton; {n(big['rows'])} rows run at "
296
+ f"{big['triton_pct_bw']:.0f}% of the measured {d['bandwidth'] / 1e9:.0f} GB/s. Greedy rows identical to the "
297
+ f"PyTorch norm: {d['greedy_rows_identical']}/{d['n_greedy_rows']}; teacher-forced argmax agreement "
298
+ f"{tf['argmax_agree']:.2%}.")
299
+
300
+
301
+ def serving_baseline(rows: list[dict], r: dict) -> tuple[dict, str] | None:
302
+ """Static has none; continuous vs static at the same size; graphs vs continuous; paged vs 8 contiguous slots."""
303
+ e, s = r["engine"], r["size"]
304
+ if e == "continuous":
305
+ return row(rows, engine="static", size=s), "static"
306
+ if e == "cont+graph":
307
+ return row(rows, engine="continuous", size=s), "continuous"
308
+ if e.startswith("paged/"):
309
+ return row(rows, engine="continuous", size=8), "continuous 8 (same KV memory)"
310
+ return None
311
+
312
+
313
+ def serving_table(d: dict) -> str:
314
+ rows = []
315
+ for r in d["rows"]:
316
+ base = serving_baseline(d["rows"], r)
317
+ vs = f"{r['tok_s'] / base[0]['tok_s']:.2f}× {base[1]}" if base and base[0] else "—"
318
+ alloc = r.get("kv_alloc_used_mean")
319
+ c = r.get("correctness") or {}
320
+ rows.append([r["engine"], str(r["size"]), n(r["tok_s"]), vs,
321
+ f"{r['latency_mean_s']:.2f} / {r['latency_p95_s']:.2f}",
322
+ f"{r['ttft_mean_s']:.2f} / {r['ttft_p95_s']:.2f}",
323
+ f"{r['kv_reserved_mib']:.0f}", "—" if alloc is None else f"{alloc:.0%}",
324
+ str(r["peak_active"] or "—"), str(r["preemptions"]),
325
+ f"{c['identical']}/{c['of']}" if c else "—"])
326
+ w = d["workload"]
327
+ text = table(["Engine", "Slots", "tok/s", "vs", "Latency mean / p95 (s)", "TTFT mean / p95 (s)",
328
+ "KV MiB", "KV claimed in use", "Peak active", "Preempt", "Same as solo"], rows)
329
+ reps = (d.get("provenance") or {}).get("repeats")
330
+ text += (f"\n\n{w['requests']} requests, all queued at t=0 (seed {w['seed']}); "
331
+ f"{'median-wall run of ' + str(reps) if reps else 'median run'}. \"Same as solo\": requests whose "
332
+ "tokens equal solo `generate()`; bf16 near-ties flip some, so it is reported, not asserted.")
333
+ return text
334
+
335
+
336
+ def logits_section(d: dict, torchnorm: dict | None = None) -> str:
337
+ """`torchnorm`: the same run with our RMSNorm forced to PyTorch (EXTRAS); adds its rows, labelled by norm."""
338
+ rows = []
339
+ runs = [(d, "")] if torchnorm is None else [
340
+ (d, f", {(d.get('provenance') or {}).get('norm_backend', '?')} norm"), (torchnorm, ", torch norm")]
341
+ for (r, label), phase in [(x, p) for x in runs for p in ("prefill", "decode")]:
342
+ s = r["stats"][phase]
343
+ flips = s["flips"]["ours_bf16"]
344
+ rows.append([phase + label, str(s["ours_vs_hf_bf16"]["positions"]),
345
+ "yes" if s["bit_identical_to_hf_bf16"] else "no",
346
+ f"{s['ours_vs_hf_bf16']['max_abs']:.3g}",
347
+ f"{s['ours_bf16']['top1']:.2%}", f"{s['hf_bf16']['top1']:.2%}",
348
+ ", ".join(f"{f:.3f}" for f in flips) or "none"])
349
+ text = table(["Phase", "Positions", "Ours == HF bf16 (`torch.equal`)", "Max abs Δ, ours vs HF",
350
+ "Top-1 vs fp32: ours", "Top-1 vs fp32: HF", "Our margin at each fp32 flip"], rows)
351
+ div = d["greedy_divergence"]
352
+ same = sum(g["ours_vs_hf_bf16"] is None for g in div)
353
+ at = lambda i: "never" if i is None else f"token {i}" # noqa: E731
354
+ text += (f"\n\nGreedy, {len(div)} prompts × {d['new_tokens']} tokens: ours equals HF bf16 on {same}/{len(div)}. "
355
+ "First divergence from the fp32 greedy text, ours / HF: "
356
+ + "; ".join(f"{at(g['ours_vs_fp32'])} / {at(g['hf_bf16_vs_fp32'])}" for g in div) + ".")
357
+ return text
358
+
359
+
360
+ def profile_table(profiles: list[tuple[str, dict]]) -> str:
361
+ rows = []
362
+ for label, d in profiles:
363
+ for mode in ("eager", "graph"):
364
+ m = d["modes"][mode]
365
+ rows.append([label, mode, f"{m['ms_per_step']:.2f}", f"{m['host_issue_ms_per_step']:.2f}",
366
+ f"{m['gpu_busy_ms_per_step']:.2f}", f"{m['gpu_idle_frac']:.0%}",
367
+ n(m["decode_tok_s"]), f"{m['pct_of_ceiling']:.0f}%"])
368
+ return table(["Model (B=1)", "Decode step", "ms / step", "Host issue ms", "GPU busy ms", "GPU idle",
369
+ "tok/s", "% of ceiling"], rows)
370
+
371
+
372
+ # ---------------------------------------------------------------- pages
373
+
374
+
375
+ def run_header(data: dict) -> str:
376
+ m = data["manifest"]
377
+ rec = m.get("benches") or {}
378
+ # the Run line describes the main run only; --only reruns get their own line, grouped by session
379
+ provs = [data[b.name].get("provenance") or {} for b in BENCHES if not (rec.get(b.name) or {}).get("rerun")]
380
+ commits = sorted({(p.get("git") or {}).get("commit") or "?" for p in provs})
381
+ dirty = any((p.get("git") or {}).get("dirty") for p in provs)
382
+ first = next((p for p in provs if p.get("gpu_name")), {})
383
+ times = sorted(p["time_utc"] for p in provs if p.get("time_utc"))
384
+ commit = (f"commit `{commits[0][:7]}`" if len(commits) == 1 else
385
+ f"⚠ {len(commits)} commits ({', '.join(c[:7] for c in commits)})")
386
+ lines = [
387
+ f"- **Run:** {times[0] if times else '?'} → {m.get('finished_utc', times[-1] if times else '?')} UTC, "
388
+ f"{commit}{' with uncommitted changes' if dirty else ''}, NitroSense **{m.get('power_mode', 'not recorded')}**.",
389
+ f"- **Machine:** {first.get('gpu_name', '?')}, driver {first.get('driver', '?')}, "
390
+ f"torch {first.get('torch', '?')}, CUDA {first.get('cuda', '?')}.",
391
+ ]
392
+ sessions: dict[tuple, list[str]] = {}
393
+ for n, r in sorted(rec.items(), key=lambda kv: str(kv[1].get("finished_utc", ""))):
394
+ if r.get("rerun"):
395
+ fin = str(r.get("finished_utc", "?"))
396
+ key = (fin[:10], ((r.get("git") or {}).get("commit") or "?")[:7], r.get("power_mode") or "not recorded")
397
+ sessions.setdefault(key, []).append(
398
+ f"{n} (finished {fin[11:16]} UTC, started at {r.get('start_temp_c', '?')} °C)")
399
+ if sessions:
400
+ lines.append("- **Rerun later with `--only`:** " + "; ".join(
401
+ f"{day}, commit `{c}`, NitroSense **{mode}**: {', '.join(names)}"
402
+ for (day, c, mode), names in sessions.items()) + ". Every other benchmark is from the run above.")
403
+ return "\n".join(lines)
404
+
405
+
406
+ def render_results(data: dict, run_dir: str = "runs/bench/v1b") -> str:
407
+ tl_gen = [(nm, lab) for nm, lab in TL_ENGINES]
408
+ w = warnings(data)
409
+ parts = [
410
+ "# Results",
411
+ "",
412
+ f"<!-- Generated by scripts/bench_all.py from {run_dir}/. Do not edit by hand: change the script "
413
+ "(src/tinyllm/results.py) and re-render. -->",
414
+ "",
415
+ "Every number on this page comes from **one** run of `scripts/bench_all.py --run`: one commit, "
416
+ "one session, one GPU job at a time, with a cool-down before each benchmark"
417
+ + (" (a benchmark rerun later with `--only` is listed below)." if any(
418
+ r.get("rerun") for r in (data["manifest"].get("benches") or {}).values()) else "."),
419
+ "",
420
+ run_header(data),
421
+ "- **How to read it:** ratios between rows of one table are the reliable result; they share a "
422
+ "session and its heat. Absolute tok/s on this laptop moves 10–20% between sessions with "
423
+ "temperature, so each table ends with its heat line. ⚠ marks a hardware slowdown, or a thermal "
424
+ f"slowdown with the busy clock median below {n(MIN_CLOCK_MHZ)} MHz.",
425
+ "- **Elsewhere:** dated results and the reasoning behind each step are in "
426
+ "[INFERENCE.md](INFERENCE.md); ours vs vLLM vs llama.cpp under load (4.8a, rerun in 4.10c) is in "
427
+ "[SERVING.md](SERVING.md).",
428
+ "- **Reproduce:** `uv run python scripts/bench_all.py` re-renders this page and the README "
429
+ "tables from the saved JSON in seconds, with no GPU. `--run --power-mode <mode>` reruns "
430
+ "everything (~1.5–2 h); `--only <bench>` reruns one benchmark.",
431
+ ]
432
+ if w:
433
+ parts += ["", "**⚠ Throttling warnings:**", *[f"- {x}" for x in w]]
434
+ hf = data["tl-hf"]
435
+ parts += [
436
+ "", "## TinyLlama 1.1B (bf16)", "",
437
+ "### Decode at batch 1", "",
438
+ f"128-token prompt, 256 new tokens, {repeats_note(hf)}. Ceiling: {ceiling_note(hf)}.", "",
439
+ tl_b1_table(data), "", heat_line(data, [n_ for n_, _ in tl_gen], ["hf", "naive", "kv", "kv-graph"]),
440
+ "", img("img/decode_b1.svg", b1_alt(data)),
441
+ "", "### Decode by batch size and prompt length", "",
442
+ decode_grid(data, tl_gen), "", img("img/batch_scaling.svg", scaling_alt(data)), "",
443
+ "", "### INT8 weights (per-row scales)", "",
444
+ "Two ways to run an int8 matmul: `int8` casts the weight back to bf16 on every call (plain PyTorch); "
445
+ "`int8-triton` is our Triton kernel, which reads the int8 weight and dequantizes in registers (4.10b).",
446
+ "", "#### Eager decode (KV cache, no graphs)", "",
447
+ int8_section(data["tl-int8"]), "", heat_line(data, ["tl-int8"]),
448
+ "", "#### With CUDA graphs: PyTorch path vs Triton kernel", "",
449
+ "Each variant is timed with only its own weights on the GPU.", "",
450
+ int8_section(data["tl-int8-graph"]), "", heat_line(data, ["tl-int8-graph"]),
451
+ "", "#### The matmuls alone", "",
452
+ gemv_table(data["tl-int8-gemv"]), "", heat_line(data, ["tl-int8-gemv"]),
453
+ "", "### Fused RMSNorm + residual add (Triton, 4.10a)", "",
454
+ rmsnorm_section(data["tl-rmsnorm"]), "", heat_line(data, ["tl-rmsnorm"]),
455
+ "", "### Serving: static vs continuous batching, with and without CUDA graphs", "",
456
+ serving_table(data["tl-serving"]), "", heat_line(data, ["tl-serving"]),
457
+ "", "### Logits vs HF Transformers", "",
458
+ "Ours (bf16) and HF Transformers (bf16, eager) against an fp32 HF reference, on the same prompts.",
459
+ "", logits_section(data["tl-logits"], data.get("tl-logits-torchnorm")), "", heat_line(data, ["tl-logits"]),
460
+ "", "## Our 30M model (bf16 weights)", "",
461
+ "### Decode by batch size and prompt length", "",
462
+ f"256 new tokens, {repeats_note(data['30m-kv'])}. Ceiling: {ceiling_note(data['30m-kv'])}.", "",
463
+ decode_grid(data, [("30m-naive", "Naive"), ("30m-kv", "KV cache"), ("30m-kv-graph", "KV + CUDA graphs")]),
464
+ "", heat_line(data, ["30m-naive", "30m-kv", "30m-kv-graph"], ["naive", "kv", "kv-graph"]),
465
+ "", "### Serving: static, continuous, CUDA graphs, paged KV", "",
466
+ "Paged rows: `paged/<block size>`, all within the 8-slot contiguous cache's KV memory.", "",
467
+ serving_table(data["30m-serving"]), "", heat_line(data, ["30m-serving"]),
468
+ "", img("img/serving.svg", serving_alt(data)),
469
+ "", "### INT8 weights", "",
470
+ int8_section(data["30m-int8"]), "", heat_line(data, ["30m-int8"]),
471
+ "", "## Decode-step profile (batch 1, 128-token context)", "",
472
+ profile_table([("TinyLlama 1.1B", data["tl-profile"]), ("30M", data["30m-profile"])]), "",
473
+ heat_line(data, ["tl-profile", "30m-profile"], ["TinyLlama", "30M"]),
474
+ "", img("img/decode_profile.svg", profile_alt(data)),
475
+ ]
476
+ return re.sub(r"\n{3,}", "\n\n", "\n".join(parts)) + "\n"
477
+
478
+
479
+ # ---------------------------------------------------------------- README blocks
480
+
481
+
482
+ def _ratio(x: float, d: int = 2) -> str:
483
+ return f"{x:.{d}f}×"
484
+
485
+
486
+ # ---------------------------------------------------------------- plot embeds (6.2, D-030)
487
+ # The SVGs come from tinyllm.plots via bench_all.py; the alt text carries the plot's numbers.
488
+
489
+
490
+ def img(path: str, alt: str) -> str:
491
+ return f"![{alt}]({path})"
492
+
493
+
494
+ def _tl(data: dict, nm: str, b: int) -> dict:
495
+ return need(data[nm]["rows"], nm, batch=b, prompt_len=128)
496
+
497
+
498
+ def b1_alt(data: dict) -> str:
499
+ hf = data["tl-hf"]
500
+ return ("Bar chart, TinyLlama 1.1B decode at batch 1: " + "; ".join(
501
+ f"{lab.split(' (')[0].replace('`', '')} {n(_tl(data, nm, 1)['decode_tok_s'])} tok/s" for nm, lab in TL_ENGINES)
502
+ + f"; bandwidth ceiling {n(hf['bandwidth_bytes_s'] / hf['weight_bytes'])} tok/s")
503
+
504
+
505
+ def scaling_alt(data: dict) -> str:
506
+ bs = sorted({r["batch"] for r in data["tl-kv-graph"]["rows"]} & {r["batch"] for r in data["tl-hf"]["rows"]})
507
+ per = lambda nm: ", ".join(n(_tl(data, nm, b)["decode_tok_s"]) for b in bs) # noqa: E731
508
+ return (f"Line chart, decode tok/s at batch {', '.join(map(str, bs))}: ours with CUDA graphs {per('tl-kv-graph')}; "
509
+ f"HF Transformers {per('tl-hf')}; log scales, with the bandwidth ceiling times batch size")
510
+
511
+
512
+ def serving_alt(data: dict) -> str:
513
+ tls, ms = data["tl-serving"]["rows"], data["30m-serving"]["rows"]
514
+ tok = lambda rows, nm, **kv: n(need(rows, nm, **kv)["tok_s"]) # noqa: E731
515
+ pg = need(ms, "30m-serving", engine=PAGED_ROW[0], size=PAGED_ROW[1])
516
+ return ("Bar chart, serving tok/s. TinyLlama, 8 slots: static " + tok(tls, "tl-serving", engine="static", size=8)
517
+ + ", continuous " + tok(tls, "tl-serving", engine="continuous", size=8)
518
+ + ", continuous with CUDA graphs " + tok(tls, "tl-serving", engine="cont+graph", size=8)
519
+ + ". 30M model, same KV memory: contiguous 8 sequences " + tok(ms, "30m-serving", engine="continuous", size=8)
520
+ + f", paged {pg['peak_active']} sequences {n(pg['tok_s'])}")
521
+
522
+
523
+ def profile_alt(data: dict) -> str:
524
+ m = data["tl-profile"]["modes"]
525
+ return ("Bar chart, share of a TinyLlama decode step the GPU is idle: "
526
+ f"eager {m['eager']['gpu_idle_frac']:.0%} ({m['eager']['ms_per_step']:.1f} ms per step), "
527
+ f"CUDA graph {m['graph']['gpu_idle_frac']:.0%} ({m['graph']['ms_per_step']:.1f} ms per step)")
528
+
529
+
530
+ def readme_blocks(data: dict) -> dict[str, str]:
531
+ g = {nm: data[nm]["rows"] for nm, _ in TL_ENGINES}
532
+ at = lambda nm, b: need(g[nm], nm, batch=b, prompt_len=128) # noqa: E731
533
+ hf1, kv1, kvg1 = at("tl-hf", 1), at("tl-kv", 1), at("tl-kv-graph", 1)
534
+ tls, ms = data["tl-serving"]["rows"], data["30m-serving"]["rows"]
535
+ st8 = need(tls, "tl-serving", engine="static", size=8)
536
+ co8 = need(tls, "tl-serving", engine="continuous", size=8)
537
+ gr8 = need(tls, "tl-serving", engine="cont+graph", size=8)
538
+ c8 = need(ms, "30m-serving", engine="continuous", size=8)
539
+ pg = need(ms, "30m-serving", engine=PAGED_ROW[0], size=PAGED_ROW[1])
540
+ lg = data["tl-logits"]["stats"]
541
+ identical = lg["prefill"]["bit_identical_to_hf_bf16"] and lg["decode"]["bit_identical_to_hf_bf16"]
542
+ max_d = max(lg[p]["ours_vs_hf_bf16"]["max_abs"] for p in ("prefill", "decode"))
543
+ positions = sum(lg[p]["ours_vs_hf_bf16"]["positions"] for p in ("prefill", "decode"))
544
+ flips = [f for p in ("prefill", "decode") for f in lg[p]["flips"]["ours_bf16"]]
545
+ top1 = {p: lg[p]["ours_vs_hf_bf16"]["top1"] for p in ("prefill", "decode")}
546
+ tn = (data.get("tl-logits-torchnorm") or {}).get("stats")
547
+ torch_identical = bool(tn) and all(tn[p]["bit_identical_to_hf_bf16"] for p in ("prefill", "decode"))
548
+ gap_cause = (" The difference is our Triton RMSNorm (D-032): with the PyTorch norm the logits are "
549
+ "bit-identical to HF's." if torch_identical else "")
550
+
551
+ logits_hl = ("**Our engine's logits are bit-identical to HF Transformers** in bf16 on TinyLlama, "
552
+ "prefill and cached decode." if identical else
553
+ f"**Our engine's bf16 logits match HF Transformers to within {max_d:.2g}** on TinyLlama "
554
+ f"(same top token at {top1['prefill']:.1%} of prefill and {top1['decode']:.1%} of cached-decode "
555
+ "positions)." + gap_cause)
556
+ highlights = "\n".join([
557
+ img("docs/img/decode_b1.svg", b1_alt(data)),
558
+ "",
559
+ f"- **TinyLlama 1.1B decodes at {n(kvg1['decode_tok_s'])} tok/s at batch 1: "
560
+ f"{kvg1['pct_of_ceiling']:.0f}% of the bandwidth ceiling, "
561
+ f"{_ratio(kvg1['decode_tok_s'] / hf1['decode_tok_s'], 1)} HF `generate`** (KV cache + CUDA graphs).",
562
+ f"- {logits_hl}",
563
+ f"- **Serving:** continuous batching gives {_ratio(co8['tok_s'] / st8['tok_s'])} static batching. "
564
+ f"A paged KV cache fits {pg['peak_active']} sequences where contiguous slots fit 8, for "
565
+ f"{_ratio(pg['tok_s'] / c8['tok_s'])} the throughput in the same memory.",
566
+ ])
567
+
568
+ hf = data["tl-hf"]
569
+ prof = data["tl-profile"]["modes"]
570
+ kv32 = at("tl-kv", 32)
571
+ host_bound = prof["eager"]["gpu_idle_frac"] >= 0.5
572
+ i8 = data["tl-int8-graph"]
573
+ vs_bf16 = lambda v, b: (need(i8["rows"], "tl-int8-graph", variant=v, batch=b)["decode_tok_s"] # noqa: E731
574
+ / need(i8["rows"], "tl-int8-graph", variant="bf16", batch=b)["decode_tok_s"])
575
+ tri, cast = {b: vs_bf16("int8-triton", b) for b in (1, 32)}, {b: vs_bf16("int8", b) for b in (1, 32)}
576
+ int8_text = (f"- **INT8 weights:** val loss {i8['val_loss']['int8-triton'] - i8['val_loss']['bf16']:+.4f}, "
577
+ f"{i8['teacher_forced']['int8-triton']['argmax_agree']:.1%} argmax agreement with bf16. Our "
578
+ "Triton kernel reads the int8 weight and dequantizes inside the matmul; with CUDA graphs, decode "
579
+ f"is {_ratio(tri[1])} bf16 at batch 1 and {_ratio(tri[32])} at batch 32. Casting the weight back "
580
+ f"to bf16 on every call (plain PyTorch) gives {_ratio(cast[1])} and {_ratio(cast[32])}")
581
+ int8_text += (": it moves more bytes than bf16, not fewer." if cast[1] < 1 and cast[32] < 1 else ".")
582
+ eager_text = (f"- **Eager decode is {'host-bound' if host_bound else 'slow'}.** At batch 1, HF and our eager "
583
+ f"engine take {hf1['ms_per_step']:.1f} and {kv1['ms_per_step']:.1f} ms per step "
584
+ f"(ours {kv32['ms_per_step']:.1f} ms at batch 32). The profiler shows the GPU idle "
585
+ f"{prof['eager']['gpu_idle_frac']:.0%} of an eager step"
586
+ + (", waiting for kernel launches" if host_bound else "")
587
+ + f". Capturing the decode step as a CUDA graph removes that overhead; the GPU is then idle "
588
+ f"{prof['graph']['gpu_idle_frac']:.0%} of the step.")
589
+ corr = (f"- **Correctness:** bf16 logits equal HF's bf16 logits exactly (`torch.equal`) at all {positions} "
590
+ "test positions." if identical else
591
+ f"- **Correctness:** bf16 logits differ from HF's bf16 logits by at most {max_d:.2g} over "
592
+ f"{positions} test positions." + gap_cause)
593
+ corr += (f" Every top-1 disagreement with fp32 is a near-tie (margin ≤ {max(flips):.3f} logits)." if flips
594
+ else " No top-1 disagreement with fp32.")
595
+ tinyllama = "\n".join([
596
+ f"Decode speed at batch 1 with a 128-token prompt, 256 new tokens, {repeats_note(hf)}. The ceiling is "
597
+ f"the measured bandwidth divided by the weight size: {ceiling_note(hf)}, because each decoded token "
598
+ "has to read every weight once.",
599
+ "",
600
+ tl_b1_table(data),
601
+ "",
602
+ eager_text,
603
+ f"- **Batching:** with graphs, {n(at('tl-kv-graph', 8)['decode_tok_s'])} tok/s at batch 8 and "
604
+ f"{n(at('tl-kv-graph', 32)['decode_tok_s'])} tok/s at batch 32 (HF: {n(at('tl-hf', 8)['decode_tok_s'])} "
605
+ f"and {n(at('tl-hf', 32)['decode_tok_s'])}).",
606
+ int8_text,
607
+ corr,
608
+ "",
609
+ img("docs/img/batch_scaling.svg", scaling_alt(data)),
610
+ ])
611
+
612
+ w = data["tl-serving"]["workload"]
613
+ reps = (data["tl-serving"].get("provenance") or {}).get("repeats")
614
+ serving = "\n".join([
615
+ f"{w['requests']} requests of mixed lengths ({n(st8['useful_tokens'])} tokens to generate), "
616
+ f"{'median of ' + str(reps) + ' runs' if reps else 'median run'}.",
617
+ "",
618
+ table(["Scheduler / cache", "Setting", "tok/s", "vs baseline"], [
619
+ ["Static batching (TinyLlama)", "8 slots", n(st8["tok_s"]), "—"],
620
+ ["Continuous batching (TinyLlama)", "8 slots", n(co8["tok_s"]), _ratio(co8["tok_s"] / st8["tok_s"])],
621
+ ["Continuous batching + CUDA graphs (TinyLlama)", "8 slots", n(gr8["tok_s"]),
622
+ f"{_ratio(gr8['tok_s'] / co8['tok_s'])} vs eager continuous"],
623
+ ["Contiguous KV slots (30M model)", f"{c8['kv_reserved_mib']:.0f} MiB of KV, 8 sequences",
624
+ n(c8["tok_s"]), "—"],
625
+ ["Paged KV cache (30M model)", f"same {pg['kv_reserved_mib']:.0f} MiB, {pg['peak_active']} sequences",
626
+ n(pg["tok_s"]), _ratio(pg["tok_s"] / c8["tok_s"])],
627
+ ]),
628
+ "",
629
+ "The paged cache stores KV in fixed-size blocks through a per-sequence block table, as in vLLM, so a "
630
+ "sequence holds only the blocks it has filled. The share of reserved KV holding real tokens goes from "
631
+ f"{c8['kv_alloc_used_mean']:.0%} to {pg['kv_alloc_used_mean']:.0%}.",
632
+ ])
633
+ return {"highlights": highlights, "tinyllama": tinyllama, "serving": serving}
634
+
635
+
636
+ def replace_block(text: str, name: str, body: str) -> str:
637
+ """Replace what sits between <!-- bench:NAME --> and <!-- /bench:NAME -->; the rest stays byte-identical."""
638
+ start, end = f"<!-- bench:{name} -->", f"<!-- /bench:{name} -->"
639
+ if text.count(start) != 1 or text.count(end) != 1:
640
+ raise ValueError(f"expected exactly one {start} and one {end}")
641
+ i, j = text.index(start) + len(start), text.index(end)
642
+ if j < i:
643
+ raise ValueError(f"{end} comes before {start}")
644
+ return text[:i] + "\n" + body.strip("\n") + "\n" + text[j:]
645
+
646
+
647
+ def render_readme(text: str, data: dict) -> str:
648
+ for name, body in readme_blocks(data).items():
649
+ text = replace_block(text, name, body)
650
+ return text
651
+
652
+
653
+ def block_names(text: str) -> list[str]:
654
+ return re.findall(r"<!-- bench:([\w-]+) -->", text)
src/tinyllm/safetensors_io.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal .safetensors reader and writer (step 4.9b, D-026). No dependency beyond torch.
2
+
3
+ File layout (https://github.com/huggingface/safetensors#format):
4
+ 8 bytes little-endian u64 N = header size
5
+ N bytes UTF-8 JSON: {name: {"dtype": "BF16", "shape": [...], "data_offsets": [begin, end]}, ...}
6
+ plus an optional "__metadata__": {str: str}. Padded with spaces to a multiple of 8.
7
+ rest raw little-endian tensor bytes; offsets are relative to the end of the header
8
+
9
+ Reading memory-maps the file, so a tensor is copied once, straight to its target device.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import mmap
16
+ import struct
17
+ from pathlib import Path
18
+
19
+ import torch
20
+
21
+ DTYPES = {
22
+ "BF16": torch.bfloat16, "F16": torch.float16, "F32": torch.float32, "F64": torch.float64,
23
+ "I64": torch.int64, "I32": torch.int32, "I16": torch.int16, "I8": torch.int8,
24
+ "U8": torch.uint8, "BOOL": torch.bool,
25
+ }
26
+ NAMES = {v: k for k, v in DTYPES.items()}
27
+
28
+
29
+ def read_header(path: str | Path) -> tuple[dict, int]:
30
+ """(header without __metadata__, byte offset where the data starts)."""
31
+ with open(path, "rb") as f:
32
+ (n,) = struct.unpack("<Q", f.read(8))
33
+ header = json.loads(f.read(n))
34
+ header.pop("__metadata__", None)
35
+ return header, 8 + n
36
+
37
+
38
+ def load_file(path: str | Path, device: str | torch.device = "cpu", dtype: torch.dtype | None = None,
39
+ keys: set[str] | None = None) -> dict[str, torch.Tensor]:
40
+ """name -> tensor on `device`. dtype: cast floating tensors to it (integers are kept)."""
41
+ header, start = read_header(path)
42
+ out = {}
43
+ with open(path, "rb") as f:
44
+ # ACCESS_COPY: a writable (copy-on-write) view, so torch.frombuffer doesn't warn.
45
+ # Nothing is written back and pages are only copied if touched for writing.
46
+ mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_COPY)
47
+ try:
48
+ for name, info in header.items():
49
+ if keys is not None and name not in keys:
50
+ continue
51
+ dt = DTYPES[info["dtype"]]
52
+ begin, end = info["data_offsets"]
53
+ shape = info["shape"]
54
+ numel = 1
55
+ for s in shape:
56
+ numel *= s
57
+ if (end - begin) != numel * torch.empty((), dtype=dt).element_size():
58
+ raise ValueError(f"{name}: {end - begin} bytes for shape {shape} {info['dtype']}")
59
+ if numel == 0:
60
+ t = torch.empty(shape, dtype=dt)
61
+ else:
62
+ t = torch.frombuffer(mm, dtype=dt, count=numel, offset=start + begin).view(shape)
63
+ target = dtype if (dtype is not None and dt.is_floating_point) else dt
64
+ out[name] = t.to(device=device, dtype=target, copy=True) # copy: detach from the mmap
65
+ del t # release the view, or mm.close() raises BufferError
66
+ finally:
67
+ mm.close()
68
+ return out
69
+
70
+
71
+ def save_file(tensors: dict[str, torch.Tensor], path: str | Path, metadata: dict[str, str] | None = None) -> None:
72
+ """Write tensors (any device) in safetensors layout. Shared tensors are written once per name."""
73
+ header: dict = {}
74
+ blobs = []
75
+ offset = 0
76
+ for name in sorted(tensors):
77
+ t = tensors[name].detach().contiguous().cpu()
78
+ raw = t.reshape(-1).view(torch.uint8).numpy().tobytes() if t.numel() else b""
79
+ header[name] = {"dtype": NAMES[t.dtype], "shape": list(t.shape), "data_offsets": [offset, offset + len(raw)]}
80
+ blobs.append(raw)
81
+ offset += len(raw)
82
+ if metadata:
83
+ header["__metadata__"] = metadata
84
+ h = json.dumps(header, separators=(",", ":")).encode()
85
+ h += b" " * (-len(h) % 8)
86
+ with open(path, "wb") as f:
87
+ f.write(struct.pack("<Q", len(h)))
88
+ f.write(h)
89
+ for raw in blobs:
90
+ f.write(raw)
src/tinyllm/server.py ADDED
@@ -0,0 +1,1031 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 4.13 step 2: OpenAI-compatible HTTP server over ContinuousEngine (D-025, D-028).
2
+
3
+ Design: docs/SERVING.md.
4
+
5
+ client ──HTTP──▶ FastAPI handler ──▶ Router ──▶ EngineWorker thread: ContinuousEngine.step()
6
+ ◀── JSON / SSE ◀── per-request asyncio.Queue ◀── tokens (call_soon_threadsafe)
7
+
8
+ - One thread owns the engine (it is not thread-safe). Handlers talk to it through an inbox.
9
+ - POST /v1/completions: prompt as text or token ids, max_tokens, temperature, top_p, top_k,
10
+ ignore_eos, stream (SSE ending with `data: [DONE]`), finish_reason, usage.
11
+ - Hardening:
12
+ - a bounded queue → 429 + Retry-After
13
+ - a per-client token-bucket rate limit → 429
14
+ - a per-request timeout → 504, slot freed
15
+ - client disconnect → abort, slot freed
16
+ - graceful shutdown
17
+ - an engine error → 500 for in-flight requests and not ready
18
+ - out of memory → 503 for in-flight requests, then recover
19
+ - Observability (obs.py): a JSON log line per request, X-Request-ID, engine snapshots,
20
+ /stats, /metrics (Prometheus text format), --log-level debug with per-step timings,
21
+ slow-request timelines, and /debug/state (localhost only).
22
+ - Pages (6.5b): GET / is the demo (story continuation, streamed), GET /status the status page
23
+ (charts and SLOs from GET /stats/history: the last hour per minute, the 7-day error budget).
24
+ - Router with one variant now; X-Model-Version on every response. Release strategies (6.9)
25
+ add routing rules.
26
+
27
+ Run the local GPU server (TinyLlama) or our 30M model:
28
+
29
+ uv run python -m tinyllm.server --model tinyllama --slots 8 --cuda-graph
30
+ uv run python -m tinyllm.server --ckpt checkpoints/main/best.pt --slots 8
31
+
32
+ or our 30M model in HF format (6.5b), from a folder or from the Hub (the Docker image does this):
33
+
34
+ uv run python -m tinyllm.server --hf-model runs/export/tinystories-30m
35
+ uv run python -m tinyllm.server --hf-model CoolDip00/tiny-llm-tinystories-30m --device cpu
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import argparse
41
+ import asyncio
42
+ import concurrent.futures
43
+ import hashlib
44
+ import json
45
+ import logging
46
+ import math
47
+ import os
48
+ import queue
49
+ import re
50
+ import secrets
51
+ import subprocess
52
+ import threading
53
+ import time
54
+ import uuid
55
+ from contextlib import asynccontextmanager
56
+ from dataclasses import asdict, dataclass
57
+ from pathlib import Path
58
+ from typing import Protocol
59
+
60
+ import torch
61
+ from fastapi import FastAPI
62
+ from fastapi import Request as HTTPRequest
63
+ from fastapi.exceptions import RequestValidationError
64
+ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, StreamingResponse
65
+ from pydantic import BaseModel, Field
66
+
67
+ from tinyllm.engine import ContinuousEngine, Request, Sampling
68
+ from tinyllm.model import norm_backend
69
+ from tinyllm.obs import EngineSnapshot, Metrics, RequestRecord, hash_client, itl_stats, setup_logging
70
+
71
+ log = logging.getLogger("tinyllm.server")
72
+
73
+
74
+ class Tokenizer(Protocol):
75
+ eos_id: int | None
76
+
77
+ def encode(self, text: str) -> list[int]: ...
78
+ def decode(self, ids: list[int]) -> str: ...
79
+
80
+
81
+ # -- engine thread ----------------------------------------------------------------------
82
+
83
+ @dataclass
84
+ class Faults:
85
+ """Test-only fault injection (D-028 amendment): forces the timeout / 500 / OOM paths."""
86
+ step_delay_s: float = 0.0 # sleep before every step (slow engine)
87
+ fail_at_step: int | None = None # RuntimeError at step N (1-based): fatal, not ready
88
+ oom_at_step: int | None = None # torch.OutOfMemoryError at step N: recoverable
89
+
90
+
91
+ @dataclass
92
+ class Item:
93
+ """What the engine thread sends a request's handler."""
94
+ kind: str # "token" or "error"
95
+ token: int = 0
96
+ t: float = 0.0 # time.monotonic() when the token was produced
97
+ finish: str | None = None # "stop" / "length" on the last token
98
+ queue_wait: float | None = None # on the first token: arrival → start of its step
99
+ status: int = 200 # errors: 400, 500, 503, 504
100
+ message: str = ""
101
+
102
+
103
+ @dataclass
104
+ class Job:
105
+ request_id: str
106
+ request: Request
107
+ loop: asyncio.AbstractEventLoop
108
+ out: asyncio.Queue
109
+ arrival: float # time.monotonic()
110
+ deadline: float
111
+ rid: int | None = None # engine id, set by the engine thread
112
+ n_tokens: int = 0
113
+
114
+ def send(self, item: Item) -> None:
115
+ self.loop.call_soon_threadsafe(self.out.put_nowait, item)
116
+
117
+
118
+ class EngineWorker:
119
+ """Owns one ContinuousEngine on its own thread. submit() / abort() / debug_state() are
120
+ safe to call from the event loop; everything else runs on the engine thread."""
121
+
122
+ def __init__(self, engine: ContinuousEngine, metrics: Metrics, max_queue: int,
123
+ snapshot_s: float = 5.0, faults: Faults | None = None, name: str = "main"):
124
+ self.engine = engine
125
+ self.metrics = metrics
126
+ self.max_outstanding = engine.n_slots + max_queue
127
+ self.snapshot_s = snapshot_s
128
+ self.faults = faults or Faults()
129
+ self.name = name
130
+ self.accepting = True
131
+ self.failed: str | None = None
132
+ self.steps = 0
133
+ self.busy = 0
134
+ self._outstanding = 0
135
+ self._lock = threading.Lock()
136
+ self._inbox: queue.SimpleQueue = queue.SimpleQueue()
137
+ self._jobs: dict[int, Job] = {} # engine rid → job (engine thread only)
138
+ self._stop = threading.Event()
139
+ self._thread = threading.Thread(target=self._run, name=f"engine-{name}", daemon=True)
140
+ self._tokens_since_snap = 0
141
+ self._last_snap = time.monotonic()
142
+
143
+ # -- event-loop side ------------------------------------------------------------
144
+ def start(self) -> None:
145
+ self._thread.start()
146
+
147
+ @property
148
+ def alive(self) -> bool:
149
+ return self._thread.is_alive()
150
+
151
+ def load(self) -> tuple[int, int]:
152
+ """(queued, busy) as last seen; a gauge, good enough for logs and admission."""
153
+ with self._lock:
154
+ return max(0, self._outstanding - self.busy), self.busy
155
+
156
+ def submit(self, job: Job) -> str | None:
157
+ """Queue a job. Returns None, or why it was refused: "failed", "draining", "full"."""
158
+ with self._lock:
159
+ if self.failed:
160
+ return "failed"
161
+ if not self.accepting:
162
+ return "draining"
163
+ if self._outstanding >= self.max_outstanding:
164
+ return "full"
165
+ self._outstanding += 1
166
+ self._inbox.put(("add", job))
167
+ return None
168
+
169
+ def abort(self, job: Job) -> None:
170
+ self._inbox.put(("abort", job))
171
+
172
+ async def debug_state(self) -> dict:
173
+ fut: concurrent.futures.Future = concurrent.futures.Future()
174
+ self._inbox.put(("debug", fut))
175
+ return await asyncio.wait_for(asyncio.wrap_future(fut), 5.0)
176
+
177
+ def stop(self, drain_s: float) -> None:
178
+ """Stop accepting, let in-flight requests finish (up to drain_s), then stop."""
179
+ with self._lock:
180
+ self.accepting = False
181
+ end = time.monotonic() + drain_s
182
+ while time.monotonic() < end and self.outstanding() and self.alive:
183
+ time.sleep(0.02)
184
+ self._stop.set()
185
+ self._thread.join(timeout=10)
186
+
187
+ def outstanding(self) -> int:
188
+ with self._lock:
189
+ return self._outstanding
190
+
191
+ # -- engine thread --------------------------------------------------------------
192
+ def _finish(self, job: Job) -> None:
193
+ if job.rid is not None:
194
+ self._jobs.pop(job.rid, None)
195
+ self.engine.results.pop(job.rid, None)
196
+ with self._lock:
197
+ self._outstanding -= 1
198
+
199
+ def _fail_all(self, status: int, message: str) -> None:
200
+ for job in list(self._jobs.values()):
201
+ if self.failed is None:
202
+ self.engine.abort(job.rid)
203
+ job.send(Item("error", status=status, message=message))
204
+ self._finish(job)
205
+
206
+ def _handle(self, cmd: str, arg) -> None:
207
+ if cmd == "add":
208
+ job: Job = arg
209
+ if self.failed or self._stop.is_set():
210
+ job.send(Item("error", status=503 if not self.failed else 500,
211
+ message="engine failed" if self.failed else "shutting down"))
212
+ self._finish(job)
213
+ return
214
+ try:
215
+ job.rid = self.engine.add_request(job.request)
216
+ except ValueError as e:
217
+ job.send(Item("error", status=400, message=str(e)))
218
+ self._finish(job)
219
+ return
220
+ self._jobs[job.rid] = job
221
+ elif cmd == "abort":
222
+ job = arg
223
+ if job.rid is not None and self._jobs.get(job.rid) is job:
224
+ if self.failed is None:
225
+ self.engine.abort(job.rid)
226
+ self._finish(job)
227
+ elif cmd == "debug":
228
+ arg.set_result(self._debug_state())
229
+
230
+ def _drain_inbox(self, block: bool) -> None:
231
+ try:
232
+ cmd, arg = self._inbox.get(timeout=0.05) if block else self._inbox.get_nowait()
233
+ except queue.Empty:
234
+ return
235
+ self._handle(cmd, arg)
236
+ while True:
237
+ try:
238
+ cmd, arg = self._inbox.get_nowait()
239
+ except queue.Empty:
240
+ return
241
+ self._handle(cmd, arg)
242
+
243
+ def _expire(self, now: float) -> None:
244
+ for job in [j for j in self._jobs.values() if now > j.deadline]:
245
+ self.engine.abort(job.rid)
246
+ job.send(Item("error", status=504, message="request timed out"))
247
+ self._finish(job)
248
+
249
+ def _step(self) -> None:
250
+ self.steps += 1
251
+ f = self.faults
252
+ if f.step_delay_s:
253
+ time.sleep(f.step_delay_s)
254
+ if f.fail_at_step == self.steps:
255
+ raise RuntimeError(f"injected fault at step {self.steps}")
256
+ if f.oom_at_step == self.steps:
257
+ raise torch.OutOfMemoryError(f"injected OOM at step {self.steps}")
258
+ t0 = time.monotonic()
259
+ events = self.engine.step()
260
+ t1 = time.monotonic()
261
+ for e in events:
262
+ job = self._jobs.get(e.rid)
263
+ if job is None:
264
+ continue
265
+ job.n_tokens += 1
266
+ finish = None
267
+ if e.done:
268
+ eos = job.request.eos_id
269
+ finish = "stop" if eos is not None and e.token == eos else "length"
270
+ job.send(Item("token", e.token, t1, finish,
271
+ queue_wait=t0 - job.arrival if job.n_tokens == 1 else None))
272
+ if e.done:
273
+ self._finish(job)
274
+ self._tokens_since_snap += len(events)
275
+ queued, busy = self.engine.load()
276
+ with self._lock:
277
+ self.busy = busy
278
+ if log.isEnabledFor(logging.DEBUG):
279
+ log.debug("step", extra={"fields": {"step": self.steps, "step_ms": round(1e3 * (t1 - t0), 3),
280
+ "tokens": len(events), "queued": queued, "busy": busy}})
281
+
282
+ def _snapshot(self, now: float) -> None:
283
+ dt = max(now - self._last_snap, 1e-9)
284
+ queued, busy = self.engine.load()
285
+ snap = EngineSnapshot(time.time(), queued, busy, self.engine.n_slots,
286
+ round(self._tokens_since_snap / dt, 2), round(self.engine.kv_used_frac(), 4),
287
+ ram_mb=_rss_mb(), vram_mb=_vram_mb(self.engine.device))
288
+ self.metrics.snapshot(snap)
289
+ if busy or queued or self._tokens_since_snap:
290
+ log.info("engine", extra={"fields": {"variant": self.name, **asdict(snap)}})
291
+ self._tokens_since_snap, self._last_snap = 0, now
292
+
293
+ def _run(self) -> None:
294
+ self._snapshot(time.monotonic())
295
+ while not self._stop.is_set():
296
+ idle = self.failed is not None or not self.engine.has_work()
297
+ self._drain_inbox(block=idle)
298
+ now = time.monotonic()
299
+ if now - self._last_snap >= self.snapshot_s and self.failed is None:
300
+ self._snapshot(now)
301
+ if self.failed is not None:
302
+ continue
303
+ self._expire(now)
304
+ if not self.engine.has_work():
305
+ continue
306
+ try:
307
+ self._step()
308
+ except torch.OutOfMemoryError as e:
309
+ log.error("out of memory: aborting in-flight requests", exc_info=True,
310
+ extra={"fields": {"variant": self.name, "in_flight": len(self._jobs)}})
311
+ self._fail_all(503, f"out of memory, retry later ({e})")
312
+ if self.engine.device.type == "cuda":
313
+ torch.cuda.empty_cache()
314
+ if self.engine.has_work(): # could not get back to a clean state
315
+ self.failed = "out of memory, engine not clean"
316
+ except Exception as e: # engine state unknown: stop serving
317
+ self.failed = f"{type(e).__name__}: {e}"
318
+ log.error("engine step failed: not ready", exc_info=True,
319
+ extra={"fields": {"variant": self.name, "in_flight": len(self._jobs)}})
320
+ self._fail_all(500, "engine error")
321
+ with self._lock:
322
+ self.busy = 0
323
+ # stopped: answer whatever is left
324
+ self._fail_all(503, "server shutting down")
325
+ while True:
326
+ try:
327
+ cmd, arg = self._inbox.get_nowait()
328
+ except queue.Empty:
329
+ break
330
+ self._handle(cmd, arg)
331
+
332
+ def _debug_state(self) -> dict:
333
+ now = time.monotonic()
334
+ queued, busy = self.engine.load()
335
+ return {"variant": self.name, "steps": self.steps, "failed": self.failed, "accepting": self.accepting,
336
+ "queued": queued, "busy": busy, "n_slots": self.engine.n_slots,
337
+ "kv_used_frac": round(self.engine.kv_used_frac(), 4),
338
+ "jobs": [{"request_id": j.request_id, "rid": j.rid, "age_s": round(now - j.arrival, 3),
339
+ "tokens": j.n_tokens, "max_tokens": j.request.max_new_tokens,
340
+ "deadline_in_s": round(j.deadline - now, 3)} for j in self._jobs.values()]}
341
+
342
+
343
+ def _rss_mb() -> float | None:
344
+ try:
345
+ for line in Path("/proc/self/status").read_text().splitlines():
346
+ if line.startswith("VmRSS:"):
347
+ return round(int(line.split()[1]) / 1024, 1)
348
+ except OSError:
349
+ pass
350
+ return None
351
+
352
+
353
+ def _vram_mb(device: torch.device) -> float | None:
354
+ if device.type != "cuda":
355
+ return None
356
+ return round(torch.cuda.memory_allocated(device) / 2**20, 1)
357
+
358
+
359
+ # -- routing, limits --------------------------------------------------------------------
360
+
361
+ @dataclass
362
+ class Variant:
363
+ name: str
364
+ version: str # checkpoint hash + git commit
365
+ worker: EngineWorker
366
+ tokenizer: Tokenizer
367
+ max_len: int
368
+ vocab_size: int
369
+
370
+
371
+ class Router:
372
+ """Picks the variant for a request. One variant now; 6.9 adds blue-green / canary /
373
+ shadow / A/B rules here, so handlers never change."""
374
+
375
+ def __init__(self, variants: list[Variant]):
376
+ self.variants = {v.name: v for v in variants}
377
+ self.primary = variants[0]
378
+
379
+ def pick(self, client: str) -> Variant:
380
+ return self.primary
381
+
382
+
383
+ class RateLimiter:
384
+ """Token bucket per client: `per_min` requests per minute, bursts up to `burst`.
385
+ Called from the event loop only. per_min <= 0 disables it."""
386
+
387
+ def __init__(self, per_min: float, burst: int):
388
+ self.rate = per_min / 60.0
389
+ self.burst = max(1, burst)
390
+ self._b: dict[str, tuple[float, float]] = {}
391
+
392
+ def check(self, client: str, now: float) -> float:
393
+ """0 if allowed (and counted), else seconds until the next request is allowed."""
394
+ if self.rate <= 0:
395
+ return 0.0
396
+ tokens, last = self._b.get(client, (float(self.burst), now))
397
+ tokens = min(float(self.burst), tokens + (now - last) * self.rate)
398
+ if tokens >= 1.0:
399
+ self._b[client] = (tokens - 1.0, now)
400
+ if len(self._b) > 10_000: # forget clients whose bucket is full again
401
+ self._b = {k: v for k, v in self._b.items() if v[0] + (now - v[1]) * self.rate < self.burst}
402
+ return 0.0
403
+ self._b[client] = (tokens, now)
404
+ return (1.0 - tokens) / self.rate
405
+
406
+
407
+ # -- HTTP layer -------------------------------------------------------------------------
408
+
409
+ class CompletionRequest(BaseModel):
410
+ """OpenAI /v1/completions subset, plus vLLM's top_k / ignore_eos / return_token_ids."""
411
+ model: str | None = None
412
+ prompt: str | list[int]
413
+ max_tokens: int = Field(16, ge=1)
414
+ temperature: float = Field(1.0, ge=0.0)
415
+ top_p: float | None = Field(None, gt=0.0, le=1.0)
416
+ top_k: int | None = Field(None, ge=1)
417
+ stream: bool = False
418
+ ignore_eos: bool = False
419
+ return_token_ids: bool = False
420
+
421
+
422
+ @dataclass
423
+ class ServerConfig:
424
+ max_queue: int = 32 # waiting requests beyond the slots; more → 429
425
+ timeout_s: float = 60.0 # per request, arrival → last token; then 504
426
+ rate_per_min: float = 0.0 # per client; 0 = off (the Space sets it)
427
+ rate_burst: int = 10
428
+ slow_s: float = 10.0 # requests slower than this log their timeline (warning)
429
+ trust_proxy: bool = False # take the client IP from X-Forwarded-For (HF Space)
430
+ proxy_hops: int = 1 # with trust_proxy: trusted proxies in front, each appending to it
431
+ salt: str | None = None # client-hash salt; None = resolve_salt()'s order
432
+ snapshot_s: float = 5.0
433
+ drain_s: float = 30.0
434
+
435
+
436
+ _RID = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
437
+
438
+
439
+ class HeadersMiddleware:
440
+ """Pure ASGI (keeps streaming and disconnects intact): every response gets X-Request-ID
441
+ (the client's, if it sent a sane one) and X-Model-Version."""
442
+
443
+ def __init__(self, app, default_version: str):
444
+ self.app = app
445
+ self.default_version = default_version
446
+
447
+ async def __call__(self, scope, receive, send):
448
+ if scope["type"] != "http":
449
+ return await self.app(scope, receive, send)
450
+ incoming = dict(scope.get("headers") or []).get(b"x-request-id", b"").decode("latin-1")
451
+ rid = incoming if _RID.match(incoming) else uuid.uuid4().hex[:16]
452
+ state = scope.setdefault("state", {})
453
+ state["request_id"] = rid
454
+
455
+ async def send_with_headers(msg):
456
+ if msg["type"] == "http.response.start":
457
+ version = state.get("model_version", self.default_version)
458
+ msg = {**msg, "headers": [*msg.get("headers", []), (b"x-request-id", rid.encode()),
459
+ (b"x-model-version", version.encode())]}
460
+ await send(msg)
461
+
462
+ await self.app(scope, receive, send_with_headers)
463
+
464
+
465
+ class _ClientGone(Exception):
466
+ pass
467
+
468
+
469
+ WEB = Path(__file__).resolve().parent / "web"
470
+
471
+
472
+ def _page(name: str) -> str:
473
+ """The demo and status pages (6.5b): static HTML + JS in src/tinyllm/web, read per request
474
+ (small) so an edit shows on reload. They fetch the JSON endpoints with relative URLs."""
475
+ return (WEB / name).read_text(encoding="utf-8")
476
+
477
+
478
+ def decode_completion(tok: Tokenizer, ids: list[int], context: list[int]) -> str:
479
+ """Completion text as it reads after the prompt. Decoded behind the prompt's last token(s),
480
+ because a SentencePiece decoder (TinyLlama) drops a leading space at the start of the text:
481
+ "…named Max." + " Max was" must not come back as "Max was" (byte-level BPE keeps it anyway).
482
+ Falls back to a plain decode if the context's text isn't a prefix (a partial UTF-8 byte)."""
483
+ if context:
484
+ head, text = tok.decode(context), tok.decode(context + ids)
485
+ if text.startswith(head):
486
+ return text[len(head):]
487
+ return tok.decode(ids)
488
+
489
+
490
+ class _Detokenizer:
491
+ """Incremental text for streaming: decode everything so far, emit the new suffix; hold
492
+ back while the text ends in a partial UTF-8 character."""
493
+
494
+ def __init__(self, tok: Tokenizer, context: list[int] | None = None):
495
+ self.tok, self.ids, self.sent, self.context = tok, [], "", list(context or [])
496
+
497
+ def add(self, token: int) -> str:
498
+ self.ids.append(token)
499
+ return self._delta(final=False)
500
+
501
+ def flush(self) -> str:
502
+ return self._delta(final=True)
503
+
504
+ def _delta(self, final: bool) -> str:
505
+ text = decode_completion(self.tok, self.ids, self.context)
506
+ if (not final and text.endswith("�")) or not text.startswith(self.sent):
507
+ return ""
508
+ d, self.sent = text[len(self.sent):], text
509
+ return d
510
+
511
+
512
+ def forwarded_ip(xff: str, hops: int = 1) -> str | None:
513
+ """The client's address in an X-Forwarded-For header behind `hops` trusted proxies.
514
+
515
+ Each proxy appends the address the request came from, so only the last `hops` entries
516
+ are trustworthy; anything left of them is whatever the client sent (spoofable, so never
517
+ the leftmost). With fewer entries than hops, all came from proxies: take the leftmost.
518
+ """
519
+ parts = [p.strip() for p in xff.split(",") if p.strip()]
520
+ if not parts:
521
+ return None
522
+ return parts[-hops] if len(parts) >= hops else parts[0]
523
+
524
+
525
+ def resolve_salt(salt: str | None = None) -> tuple[str, str]:
526
+ """(salt, where it came from) for the client hash. The source is logged, never the salt.
527
+
528
+ Order: an explicit salt; $TINYLLM_LOG_SALT (a Space secret, the same value in every
529
+ replica Space, D-031); a hash of $SPACE_SIGNING_SECRET (HF's per-Space secret, stable
530
+ across restarts, but different in each Space); else random per start.
531
+ """
532
+ if salt:
533
+ return salt, "config"
534
+ if s := os.environ.get("TINYLLM_LOG_SALT"):
535
+ return s, "TINYLLM_LOG_SALT"
536
+ if s := os.environ.get("SPACE_SIGNING_SECRET"):
537
+ return hashlib.sha256(f"tinyllm-log-salt:{s}".encode()).hexdigest(), "SPACE_SIGNING_SECRET"
538
+ return secrets.token_hex(16), "random"
539
+
540
+
541
+ def cpu_limit(cgroup: str = "/sys/fs/cgroup") -> int | None:
542
+ """CPUs this container may use, or None if no limit is known.
543
+
544
+ torch sizes its thread pool from the host's cores and ignores the container's CPU quota:
545
+ under `docker run --cpus 2` on a 16-core host it starts 8 threads, and decode ran ~5x
546
+ slower than with 2 (6.5b step 5, runs/bench/load/space-cpu/probe-threads-*). Order: the
547
+ cgroup v2 quota (`cpu.max`, "200000 100000" = 2 CPUs), cgroup v1, then HF's $CPU_CORES.
548
+ """
549
+ root = Path(cgroup)
550
+ try:
551
+ quota, period = (root / "cpu.max").read_text().split()[:2]
552
+ if quota != "max":
553
+ return max(1, math.ceil(int(quota) / int(period)))
554
+ except (OSError, ValueError):
555
+ pass
556
+ try:
557
+ quota = int((root / "cpu" / "cpu.cfs_quota_us").read_text())
558
+ period = int((root / "cpu" / "cpu.cfs_period_us").read_text())
559
+ if quota > 0 and period > 0:
560
+ return max(1, math.ceil(quota / period))
561
+ except (OSError, ValueError):
562
+ pass
563
+ try:
564
+ n = math.ceil(float(os.environ.get("CPU_CORES", "")))
565
+ return n if n > 0 else None
566
+ except ValueError:
567
+ return None
568
+
569
+
570
+ class Server:
571
+ """State shared by the handlers (app.state.srv)."""
572
+
573
+ def __init__(self, variants: list[Variant], cfg: ServerConfig, metrics: Metrics):
574
+ self.router = Router(variants)
575
+ self.cfg = cfg
576
+ self.metrics = metrics
577
+ self.limiter = RateLimiter(cfg.rate_per_min, cfg.rate_burst)
578
+ self.salt, self.salt_source = resolve_salt(cfg.salt)
579
+ self.started = False
580
+
581
+ def client_id(self, request: HTTPRequest) -> str:
582
+ ip = request.client.host if request.client else "unknown"
583
+ if self.cfg.trust_proxy and (xff := request.headers.get("x-forwarded-for")):
584
+ ip = forwarded_ip(xff, self.cfg.proxy_hops) or ip
585
+ return hash_client(ip, self.salt)
586
+
587
+ def record(self, rec: RequestRecord, timeline: dict | None = None) -> None:
588
+ self.metrics.observe(rec)
589
+ fields = rec.to_dict()
590
+ if rec.latency_s is not None and rec.latency_s > self.cfg.slow_s:
591
+ log.warning("slow request", extra={"fields": {**fields, "timeline": timeline or {}}})
592
+ else:
593
+ log.info("request", extra={"fields": fields})
594
+
595
+ def error(self, rec: RequestRecord, status: int, message: str, headers: dict | None = None,
596
+ err_type: str = "invalid_request_error") -> JSONResponse:
597
+ rec.status, rec.error = status, message
598
+ rec.finish_reason = rec.finish_reason or ("timeout" if status == 504 else "error")
599
+ self.record(rec)
600
+ return _error_response(status, message, err_type, headers)
601
+
602
+
603
+ def _error_response(status: int, message: str, err_type: str, headers: dict | None = None) -> JSONResponse:
604
+ return JSONResponse({"error": {"message": message, "type": err_type, "code": status}},
605
+ status_code=status, headers=headers)
606
+
607
+
608
+ def _finish_record(rec: RequestRecord, job: Job, times: list[float], toks: list[int]) -> dict:
609
+ rec.completion_tokens = len(toks)
610
+ if times:
611
+ rec.ttft_s = round(times[0] - job.arrival, 6)
612
+ rec.latency_s = round(times[-1] - job.arrival, 6)
613
+ p50, p95 = itl_stats(times)
614
+ rec.itl_p50_s = None if p50 is None else round(p50, 6)
615
+ rec.itl_p95_s = None if p95 is None else round(p95, 6)
616
+ gaps = [b - a for a, b in zip(times, times[1:])]
617
+ return {"queue_wait_s": rec.queue_wait_s, "ttft_s": rec.ttft_s,
618
+ "max_gap_s": round(max(gaps), 6) if gaps else None, "tokens": len(toks)}
619
+
620
+
621
+ def create_app(variants: list[Variant], cfg: ServerConfig | None = None,
622
+ metrics: Metrics | None = None, shipper=None) -> FastAPI:
623
+ """`shipper`: a logship.LogShipper; its last upload runs after the drain."""
624
+ cfg = cfg or ServerConfig()
625
+ metrics = metrics or variants[0].worker.metrics
626
+ srv = Server(variants, cfg, metrics)
627
+
628
+ @asynccontextmanager
629
+ async def lifespan(app: FastAPI):
630
+ for v in variants:
631
+ v.worker.start()
632
+ srv.started = True
633
+ log.info("server ready", extra={"fields": {"variants": {v.name: v.version for v in variants},
634
+ "slo_ttft_s": metrics.slo_ttft_s, "salt": srv.salt_source,
635
+ "proxy_hops": cfg.proxy_hops if cfg.trust_proxy else 0}})
636
+ yield
637
+ srv.started = False
638
+ log.info("shutting down: draining", extra={"fields": {"drain_s": cfg.drain_s}})
639
+ await asyncio.gather(*(asyncio.to_thread(v.worker.stop, cfg.drain_s) for v in variants))
640
+ log.info("stopped")
641
+ if shipper is not None:
642
+ await asyncio.to_thread(shipper.close, cfg.drain_s)
643
+
644
+ app = FastAPI(title="tinyllm", lifespan=lifespan)
645
+ app.state.srv = srv
646
+ app.add_middleware(HeadersMiddleware, default_version=srv.router.primary.version)
647
+
648
+ @app.exception_handler(RequestValidationError)
649
+ async def bad_request(request: HTTPRequest, exc: RequestValidationError):
650
+ msg = "; ".join(f"{'.'.join(str(p) for p in e['loc'][1:])}: {e['msg']}" for e in exc.errors())
651
+ rec = RequestRecord(request.state.request_id, 400, client=srv.client_id(request),
652
+ arrival_unix=time.time())
653
+ return srv.error(rec, 400, msg or "invalid request")
654
+
655
+ @app.get("/health")
656
+ async def health():
657
+ dead = [v.name for v in variants if v.worker.failed or (srv.started and not v.worker.alive)]
658
+ if dead:
659
+ return JSONResponse({"status": "failed", "variants": dead}, status_code=503)
660
+ return {"status": "ok"}
661
+
662
+ @app.get("/ready")
663
+ async def ready():
664
+ why = []
665
+ if not srv.started:
666
+ why.append("not started")
667
+ for v in variants:
668
+ if v.worker.failed:
669
+ why.append(f"{v.name}: {v.worker.failed}")
670
+ elif not v.worker.accepting:
671
+ why.append(f"{v.name}: draining")
672
+ if why:
673
+ return JSONResponse({"ready": False, "reasons": why}, status_code=503)
674
+ return {"ready": True}
675
+
676
+ @app.get("/v1/models")
677
+ async def models():
678
+ return {"object": "list", "data": [{"id": v.name, "object": "model", "owned_by": "tinyllm",
679
+ "version": v.version, "max_len": v.max_len} for v in variants]}
680
+
681
+ @app.get("/stats")
682
+ async def stats():
683
+ out = metrics.stats()
684
+ out["variants"] = {v.name: {"version": v.version, "outstanding": v.worker.outstanding(),
685
+ "n_slots": v.worker.engine.n_slots, "failed": v.worker.failed,
686
+ "norm_backend": norm_backend(v.worker.engine.model)}
687
+ for v in variants}
688
+ return out
689
+
690
+ history_cache: dict = {}
691
+
692
+ @app.get("/stats/history")
693
+ async def stats_history():
694
+ """For the status page: computed at most once a second, however many pages poll it."""
695
+ now = time.monotonic()
696
+ if history_cache.get("t", -1.0) < now - 1.0:
697
+ out = metrics.history()
698
+ out["variants"] = {v.name: {"version": v.version, "n_slots": v.worker.engine.n_slots,
699
+ "norm_backend": norm_backend(v.worker.engine.model)}
700
+ for v in variants}
701
+ history_cache.update(t=now, out=out)
702
+ return history_cache["out"]
703
+
704
+ @app.get("/", response_class=HTMLResponse)
705
+ async def demo_page():
706
+ return HTMLResponse(_page("demo.html"))
707
+
708
+ @app.get("/status", response_class=HTMLResponse)
709
+ async def status_page():
710
+ return HTMLResponse(_page("status.html"))
711
+
712
+ @app.get("/metrics")
713
+ async def prometheus():
714
+ return PlainTextResponse(metrics.prometheus(), media_type="text/plain; version=0.0.4")
715
+
716
+ @app.get("/debug/state")
717
+ async def debug_state(request: HTTPRequest):
718
+ if not request.client or request.client.host not in ("127.0.0.1", "::1"):
719
+ return _error_response(404, "not found", "not_found")
720
+ return {v.name: await v.worker.debug_state() for v in variants}
721
+
722
+ @app.post("/v1/completions")
723
+ async def completions(body: CompletionRequest, request: HTTPRequest):
724
+ arrival = time.monotonic()
725
+ client = srv.client_id(request)
726
+ rec = RequestRecord(request.state.request_id, 0, client=client, arrival_unix=time.time(),
727
+ stream=body.stream)
728
+ variant = srv.router.pick(client)
729
+ rec.variant, rec.model_version = variant.name, variant.version
730
+ request.state.model_version = variant.version
731
+ worker = variant.worker
732
+
733
+ wait = srv.limiter.check(client, arrival)
734
+ if wait:
735
+ return srv.error(rec, 429, "rate limit exceeded", {"Retry-After": str(math.ceil(wait))},
736
+ "rate_limit_error")
737
+ ids = body.prompt if isinstance(body.prompt, list) else variant.tokenizer.encode(body.prompt)
738
+ rec.prompt_tokens = len(ids)
739
+ if not ids:
740
+ return srv.error(rec, 400, "prompt is empty")
741
+ if any(not 0 <= t < variant.vocab_size for t in ids):
742
+ return srv.error(rec, 400, f"token ids must be in [0, {variant.vocab_size})")
743
+ if len(ids) + body.max_tokens > variant.max_len:
744
+ return srv.error(rec, 400, f"prompt ({len(ids)} tokens) + max_tokens ({body.max_tokens}) "
745
+ f"exceeds the context length {variant.max_len}")
746
+ rec.queue_depth_at_arrival, rec.busy_slots_at_arrival = worker.load()
747
+ sampling = Sampling(body.temperature, body.top_k, body.top_p)
748
+ eos = None if body.ignore_eos else variant.tokenizer.eos_id
749
+ job = Job(rec.request_id, Request(ids, body.max_tokens, eos, sampling), asyncio.get_running_loop(),
750
+ asyncio.Queue(), arrival, arrival + cfg.timeout_s)
751
+ refused = worker.submit(job)
752
+ if refused == "full":
753
+ return srv.error(rec, 429, "server busy: queue full, retry later", {"Retry-After": "1"},
754
+ "server_busy")
755
+ if refused:
756
+ return srv.error(rec, 503, "engine failed" if refused == "failed" else "server shutting down",
757
+ err_type="server_error")
758
+
759
+ created, cid = int(time.time()), f"cmpl-{rec.request_id}"
760
+
761
+ async def items(poll: HTTPRequest | None):
762
+ """The job's items. With `poll`, also check every 0.25 s whether the client left
763
+ (busy requests too: tokens arriving must not hide a disconnect)."""
764
+ checked = time.monotonic()
765
+ while True:
766
+ if poll is None:
767
+ item = await job.out.get()
768
+ else:
769
+ try:
770
+ item = await asyncio.wait_for(job.out.get(), 0.25)
771
+ except TimeoutError:
772
+ item = None
773
+ if time.monotonic() - checked >= 0.25:
774
+ checked = time.monotonic()
775
+ if await poll.is_disconnected():
776
+ raise _ClientGone
777
+ if item is None:
778
+ continue
779
+ if item.kind == "token" and item.queue_wait is not None:
780
+ rec.queue_wait_s = round(item.queue_wait, 6)
781
+ yield item
782
+ if item.kind == "error" or item.finish:
783
+ return
784
+
785
+ def usage(n: int) -> dict:
786
+ return {"prompt_tokens": len(ids), "completion_tokens": n, "total_tokens": len(ids) + n}
787
+
788
+ if not body.stream:
789
+ toks, times, finish = [], [], None
790
+ try:
791
+ async for it in items(request):
792
+ if it.kind == "error":
793
+ timeline = _finish_record(rec, job, times, toks)
794
+ return srv.error(rec, it.status, it.message, err_type="server_error"
795
+ if it.status >= 500 else "invalid_request_error")
796
+ toks.append(it.token)
797
+ times.append(it.t)
798
+ finish = it.finish
799
+ except _ClientGone:
800
+ worker.abort(job)
801
+ _finish_record(rec, job, times, toks)
802
+ rec.status, rec.finish_reason = 499, "abort"
803
+ srv.record(rec)
804
+ return PlainTextResponse("", status_code=499)
805
+ timeline = _finish_record(rec, job, times, toks)
806
+ rec.status, rec.finish_reason = 200, finish
807
+ srv.record(rec, timeline)
808
+ choice = {"index": 0, "text": decode_completion(variant.tokenizer, toks, ids[-1:]), "logprobs": None,
809
+ "finish_reason": finish}
810
+ if body.return_token_ids:
811
+ choice["token_ids"] = toks
812
+ return {"id": cid, "object": "text_completion", "created": created, "model": variant.name,
813
+ "choices": [choice], "usage": usage(len(toks))}
814
+
815
+ def chunk(text: str, finish: str | None, token_ids: list[int] | None = None, **extra) -> str:
816
+ choice = {"index": 0, "text": text, "logprobs": None, "finish_reason": finish}
817
+ if token_ids is not None:
818
+ choice["token_ids"] = token_ids
819
+ d = {"id": cid, "object": "text_completion", "created": created, "model": variant.name,
820
+ "choices": [choice], **extra}
821
+ return f"data: {json.dumps(d)}\n\n"
822
+
823
+ async def stream():
824
+ toks, times, detok, done = [], [], _Detokenizer(variant.tokenizer, ids[-1:]), False
825
+ try:
826
+ async for it in items(None): # disconnect: Starlette cancels this generator
827
+ if it.kind == "error":
828
+ rec.status, rec.error = it.status, it.message
829
+ rec.finish_reason = "timeout" if it.status == 504 else "error"
830
+ done = True
831
+ yield (f"data: {json.dumps({'error': {'message': it.message, 'type': 'server_error', 'code': it.status}})}\n\n"
832
+ "data: [DONE]\n\n")
833
+ return
834
+ toks.append(it.token)
835
+ times.append(it.t)
836
+ text = detok.add(it.token)
837
+ ids_ = [it.token] if body.return_token_ids else None
838
+ if it.finish:
839
+ rec.status, rec.finish_reason, done = 200, it.finish, True
840
+ yield chunk(text + detok.flush(), it.finish, ids_, usage=usage(len(toks)))
841
+ yield "data: [DONE]\n\n"
842
+ return
843
+ if text or ids_:
844
+ yield chunk(text, None, ids_)
845
+ finally:
846
+ if not done:
847
+ worker.abort(job)
848
+ rec.status, rec.finish_reason = 499, "abort"
849
+ timeline = _finish_record(rec, job, times, toks)
850
+ srv.record(rec, timeline)
851
+
852
+ return StreamingResponse(stream(), media_type="text/event-stream",
853
+ headers={"Cache-Control": "no-cache"})
854
+
855
+ return app
856
+
857
+
858
+ # -- loading and CLI --------------------------------------------------------------------
859
+
860
+ class StoryTokenizer:
861
+ """Our 30M model's TinyStories BPE (no BOS; EOS separates stories)."""
862
+
863
+ def __init__(self, path: Path | None = None):
864
+ from tinyllm.tokenizer import EOS, TOKENIZER_PATH, load_tokenizer
865
+ self.tok = load_tokenizer(path or TOKENIZER_PATH)
866
+ self.eos_id = self.tok.token_to_id(EOS)
867
+
868
+ def encode(self, text: str) -> list[int]:
869
+ return self.tok.encode(text).ids
870
+
871
+ def decode(self, ids: list[int]) -> str:
872
+ return self.tok.decode(ids)
873
+
874
+
875
+ def git_commit() -> str:
876
+ if c := os.environ.get("TINYLLM_GIT_COMMIT"):
877
+ return c[:7]
878
+ try:
879
+ out = subprocess.run(["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True,
880
+ timeout=5, cwd=Path(__file__).resolve().parent)
881
+ return out.stdout.strip() or "nogit"
882
+ except (OSError, subprocess.SubprocessError):
883
+ return "nogit"
884
+
885
+
886
+ def _file_hash(path: Path) -> str:
887
+ h = hashlib.sha256()
888
+ with open(path, "rb") as f:
889
+ for b in iter(lambda: f.read(1 << 20), b""):
890
+ h.update(b)
891
+ return h.hexdigest()[:8]
892
+
893
+
894
+ HF_MODEL_FILES = ["config.json", "generation_config.json", "model.safetensors", "tokenizer.json",
895
+ "tokenizer_config.json"]
896
+
897
+
898
+ def resolve_hf_model(ref: str, revision: str | None = None, local_only: bool = False) -> Path:
899
+ """--hf-model: a local folder as is, else a Hub repo id downloaded to the HF cache (6.5b)."""
900
+ if Path(ref).is_dir():
901
+ if revision:
902
+ raise ValueError("--revision is for a Hub repo id, not a local folder")
903
+ return Path(ref)
904
+ from huggingface_hub import snapshot_download
905
+ return Path(snapshot_download(ref, revision=revision, allow_patterns=HF_MODEL_FILES,
906
+ local_files_only=local_only))
907
+
908
+
909
+ def load_story_hf(path: Path, device: str) -> tuple[torch.nn.Module, Tokenizer]:
910
+ """Our story model as exported by scripts/export_hf.py (4.8b): fp32, as trained (like --ckpt).
911
+
912
+ Only that export: its tokenizer is our TinyStories BPE (no BOS, EOS <|endoftext|>), which
913
+ StoryTokenizer reads from the folder. Any other Llama folder would be tokenized wrongly.
914
+ """
915
+ from tinyllm.hf_llama import load_from_dir
916
+ from tinyllm.tokenizer import EOS
917
+ tc = json.loads((path / "tokenizer_config.json").read_text())
918
+ if tc.get("eos_token") != EOS or tc.get("add_bos_token") or tc.get("bos_token"):
919
+ raise ValueError(f"{path}: not a tinyllm story-model export (eos {tc.get('eos_token')!r}, "
920
+ f"bos {tc.get('bos_token')!r}); for TinyLlama use --model tinyllama")
921
+ tok = StoryTokenizer(path / "tokenizer.json")
922
+ eos_cfg = json.loads((path / "config.json").read_text()).get("eos_token_id")
923
+ if tok.eos_id != eos_cfg:
924
+ raise ValueError(f"{path}: tokenizer EOS id {tok.eos_id} != config.json eos_token_id {eos_cfg}")
925
+ return load_from_dir(path, device=device, dtype=torch.float32), tok
926
+
927
+
928
+ def load_variant(args) -> tuple[torch.nn.Module, Tokenizer, str, str]:
929
+ """(model, tokenizer, name, version) from --model tinyllama, --ckpt or --hf-model."""
930
+ from tinyllm.model import Transformer
931
+ device = args.device
932
+ if args.hf_model:
933
+ path = resolve_hf_model(args.hf_model, args.revision, args.offline)
934
+ model, tok = load_story_hf(path, device)
935
+ name = args.hf_model.rstrip("/").split("/")[-1]
936
+ return model, tok, name, f"{name}@{_file_hash(path / 'model.safetensors')}+{git_commit()}"
937
+ if args.ckpt:
938
+ from tinyllm.config import ModelConfig
939
+ path = Path(args.ckpt)
940
+ ckpt = torch.load(path, map_location=device, weights_only=True)
941
+ model = Transformer(ModelConfig.from_dict(ckpt["model_cfg"])).to(device)
942
+ model.load_state_dict(ckpt["model"])
943
+ name = f"tinyllm-{path.parent.name}"
944
+ return model.eval(), StoryTokenizer(), name, f"{name}@{_file_hash(path)}+{git_commit()}"
945
+ from tinyllm.hf_llama import TINYLLAMA_REV, HFTokenizer, download, load_from_dir
946
+ path = download(local_only=args.offline)
947
+ model = load_from_dir(path, device=device, dtype=torch.bfloat16 if device == "cuda" else torch.float32)
948
+ return model, HFTokenizer(path), "tinyllama-1.1b-chat", f"tinyllama-chat-v1.0@{TINYLLAMA_REV[:7]}+{git_commit()}"
949
+
950
+
951
+ def main(argv: list[str] | None = None) -> None:
952
+ p = argparse.ArgumentParser(description="tinyllm OpenAI-compatible server (/v1/completions)")
953
+ src = p.add_mutually_exclusive_group(required=True)
954
+ src.add_argument("--model", choices=["tinyllama"])
955
+ src.add_argument("--ckpt", help="our checkpoint, e.g. checkpoints/main/best.pt")
956
+ src.add_argument("--hf-model", metavar="DIR_OR_REPO",
957
+ help="our model in HF format (scripts/export_hf.py): a folder, or a Hub repo id")
958
+ p.add_argument("--revision", default=None, help="with a --hf-model repo id: branch, tag or commit")
959
+ p.add_argument("--offline", action="store_true", help="Hub models from the HF cache only")
960
+ p.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
961
+ p.add_argument("--host", default="127.0.0.1")
962
+ p.add_argument("--port", type=int, default=8000)
963
+ p.add_argument("--slots", type=int, default=8)
964
+ p.add_argument("--max-len", type=int, default=None, help="per-slot context; default the model's")
965
+ p.add_argument("--kv", choices=["slots", "paged"], default="slots")
966
+ p.add_argument("--cuda-graph", action="store_true")
967
+ p.add_argument("--seed", type=int, default=None)
968
+ p.add_argument("--max-queue", type=int, default=32)
969
+ p.add_argument("--timeout", type=float, default=60.0)
970
+ p.add_argument("--rate-limit", type=float, default=0.0, help="requests/min per client; 0 = off")
971
+ p.add_argument("--rate-burst", type=int, default=10)
972
+ p.add_argument("--slow-s", type=float, default=10.0)
973
+ p.add_argument("--slo-ttft-s", type=float, default=1.0,
974
+ help="TTFT SLO threshold on /status (SERVING.md: 1 s local GPU, 2 s on the Space)")
975
+ p.add_argument("--trust-proxy", action="store_true", help="client IP from X-Forwarded-For")
976
+ p.add_argument("--threads", type=int, default=None,
977
+ help="CPU only: torch threads; default the container's CPU limit (cpu_limit), else torch's")
978
+ p.add_argument("--proxy-hops", type=int, default=1,
979
+ help="with --trust-proxy: trusted proxies in front (client = that many entries from the right)")
980
+ p.add_argument("--drain-s", type=float, default=30.0)
981
+ p.add_argument("--log-level", default="info", choices=["debug", "info", "warning", "error"])
982
+ p.add_argument("--log-file", default=None, help="also write JSON lines here")
983
+ p.add_argument("--log-dir", default=None,
984
+ help="write the shipped log lines (no text, no IP) as hourly JSONL here (logship.py)")
985
+ p.add_argument("--log-dataset", default=None, metavar="REPO_ID",
986
+ help="with --log-dir: upload it to this private HF dataset every 10 min")
987
+ p.add_argument("--log-replica", default=None, help="name in the log file names; default $SPACE_ID or local")
988
+ args = p.parse_args(argv)
989
+ if args.log_dataset and not args.log_dir:
990
+ p.error("--log-dataset needs --log-dir")
991
+ if args.proxy_hops < 1:
992
+ p.error("--proxy-hops must be at least 1")
993
+ if args.threads is not None and args.threads < 1:
994
+ p.error("--threads must be at least 1")
995
+ if args.device == "cpu":
996
+ # an explicit --threads as given; else never more than torch's own choice (no SMT oversubscription)
997
+ limit = cpu_limit()
998
+ n = args.threads or (min(limit, torch.get_num_threads()) if limit else None)
999
+ if n:
1000
+ torch.set_num_threads(n)
1001
+
1002
+ setup_logging(args.log_level, args.log_file)
1003
+ shipper = None
1004
+ if args.log_dir:
1005
+ from tinyllm.logship import LogShipper
1006
+ shipper = LogShipper(args.log_dir, args.log_dataset, args.log_replica).attach()
1007
+ shipper.start_upload()
1008
+ t = time.perf_counter()
1009
+ model, tok, name, version = load_variant(args)
1010
+ gen = torch.Generator(args.device).manual_seed(args.seed) if args.seed is not None else None
1011
+ engine = ContinuousEngine(model, args.slots, args.max_len, generator=gen, kv=args.kv,
1012
+ cuda_graph=args.cuda_graph)
1013
+ log.info("model loaded", extra={"fields": {"variant": name, "version": version, "device": args.device,
1014
+ "slots": args.slots, "max_len": engine.max_len, "kv": args.kv,
1015
+ "cuda_graph": args.cuda_graph, "threads": torch.get_num_threads(),
1016
+ "load_s": round(time.perf_counter() - t, 2)}})
1017
+ cfg = ServerConfig(max_queue=args.max_queue, timeout_s=args.timeout, rate_per_min=args.rate_limit,
1018
+ rate_burst=args.rate_burst, slow_s=args.slow_s, trust_proxy=args.trust_proxy,
1019
+ proxy_hops=args.proxy_hops, drain_s=args.drain_s)
1020
+ metrics = Metrics(slo_ttft_s=args.slo_ttft_s)
1021
+ worker = EngineWorker(engine, metrics, cfg.max_queue, cfg.snapshot_s, name=name)
1022
+ app = create_app([Variant(name, version, worker, tok, engine.max_len, model.cfg.vocab_size)], cfg, metrics,
1023
+ shipper)
1024
+
1025
+ import uvicorn
1026
+ uvicorn.run(app, host=args.host, port=args.port, log_level="warning", access_log=False,
1027
+ timeout_graceful_shutdown=int(args.drain_s))
1028
+
1029
+
1030
+ if __name__ == "__main__":
1031
+ main()
src/tinyllm/tokenizer.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Byte-level BPE tokenizer, vocab 8192 (D-007).
2
+
3
+ Byte-level means every string can be encoded (unknown characters fall back to
4
+ raw bytes), and decode(encode(text)) == text exactly.
5
+ """
6
+
7
+ from collections.abc import Iterable, Iterator
8
+ from pathlib import Path
9
+
10
+ from tokenizers import Tokenizer, decoders, models, pre_tokenizers, trainers
11
+
12
+ VOCAB_SIZE = 8192
13
+ EOS = "<|endoftext|>" # separates stories in the token stream; id 0
14
+ TOKENIZER_PATH = Path(__file__).resolve().parents[2] / "tokenizer" / "tinystories-bpe8192.json"
15
+
16
+
17
+ def train_tokenizer(texts: Iterable[str] | Iterator[list[str]], vocab_size: int = VOCAB_SIZE) -> Tokenizer:
18
+ tok = Tokenizer(models.BPE())
19
+ # GPT-2-style split into words/punctuation/spaces before BPE, no prefix space
20
+ # (so the roundtrip is exact).
21
+ tok.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
22
+ tok.decoder = decoders.ByteLevel()
23
+ trainer = trainers.BpeTrainer(
24
+ vocab_size=vocab_size,
25
+ min_frequency=2,
26
+ special_tokens=[EOS],
27
+ initial_alphabet=pre_tokenizers.ByteLevel.alphabet(), # all 256 bytes always present
28
+ show_progress=True,
29
+ )
30
+ tok.train_from_iterator(texts, trainer=trainer)
31
+ return tok
32
+
33
+
34
+ def load_tokenizer(path: Path = TOKENIZER_PATH) -> Tokenizer:
35
+ return Tokenizer.from_file(str(path))
src/tinyllm/train.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training loop (Phase 3, hyperparameters in DECISIONS.md D-019).
2
+
3
+ - Step 3.2: AdamW, warmup + cosine LR, gradient accumulation, clipping, bf16 autocast.
4
+ - Step 3.3: val loss every eval_every steps, checkpoint save/resume, sample text.
5
+
6
+ The logging backend (3.4) and MFU (3.5) are added on top.
7
+
8
+ uv run python -m tinyllm.train --preset smoke --max_steps 2000
9
+ uv run python -m tinyllm.train --preset main --stop_at 3000 # train in chunks...
10
+ uv run python -m tinyllm.train --preset main --resume # ...and continue later
11
+ """
12
+
13
+ import argparse
14
+ import math
15
+ import os
16
+ import time
17
+ from dataclasses import asdict, dataclass, fields
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+ import torch
22
+ from torch import Tensor, nn
23
+
24
+ from tinyllm.config import ModelConfig, get_config
25
+ from tinyllm.data import DATA_DIR, get_batch, load_tokens
26
+ from tinyllm.generate import sample
27
+ from tinyllm.logger import Logger, make_logger
28
+ from tinyllm.model import Transformer
29
+ from tinyllm.tokenizer import EOS, load_tokenizer
30
+
31
+ REPO = DATA_DIR.parent
32
+ EVAL_SEED = 1234 # same val windows at every eval, so losses are comparable
33
+ SAMPLE_PROMPT = "Once upon a time"
34
+
35
+
36
+ @dataclass
37
+ class TrainConfig:
38
+ preset: str = "smoke"
39
+ seq_len: int = 512
40
+ micro_batch: int = 32 # sequences per forward/backward
41
+ grad_accum: int = 8 # micro-batches per optimizer step: 32 x 512 x 8 = 131,072 tokens/step
42
+ max_steps: int = 2000
43
+ lr: float = 1e-3 # peak LR, reached at the end of warmup
44
+ min_lr: float = 1e-4 # cosine floor (10% of peak)
45
+ warmup_steps: int = 200
46
+ weight_decay: float = 0.1 # matrices and embeddings only, not norm weights
47
+ beta1: float = 0.9
48
+ beta2: float = 0.95
49
+ grad_clip: float = 1.0
50
+ seed: int = 0
51
+ device: str = "cuda"
52
+ log_every: int = 10
53
+ eval_every: int = 200 # val loss + checkpoint (+ sample) every N steps and at the end
54
+ eval_batches: int = 20 # 20 x 32 x 512 = 327,680 val tokens per eval
55
+ sample_tokens: int = 200 # tokens generated at each eval; 0 = no samples
56
+ out_dir: str = "" # default: checkpoints/<preset>
57
+ resume: bool = False # continue from <out_dir>/ckpt.pt
58
+ stop_at: int = 0 # stop after this many steps (0 = max_steps); the LR schedule still uses max_steps
59
+ logger: str = "wandb" # "wandb" or "none" (D-013)
60
+ wandb_project: str = "tinyllm"
61
+ wandb_mode: str = "online" # "offline" writes to ./wandb; upload later with `wandb sync`
62
+ run_name: str = "" # default: <preset>
63
+ peak_tflops: float = 42.0 # MFU denominator: theoretical bf16 dense peak (docs/HARDWARE.md)
64
+ compile: bool = True # torch.compile the training step, +57% tok/s on main (D-020); eval + samples stay eager
65
+
66
+ @property
67
+ def tokens_per_step(self) -> int:
68
+ return self.micro_batch * self.grad_accum * self.seq_len
69
+
70
+ @property
71
+ def run_dir(self) -> Path:
72
+ return Path(self.out_dir) if self.out_dir else REPO / "checkpoints" / self.preset
73
+
74
+
75
+ def lr_at(step: int, cfg: TrainConfig) -> float:
76
+ """Linear warmup to cfg.lr, then cosine decay to cfg.min_lr at cfg.max_steps."""
77
+ if step < cfg.warmup_steps:
78
+ return cfg.lr * (step + 1) / cfg.warmup_steps
79
+ if step >= cfg.max_steps:
80
+ return cfg.min_lr
81
+ progress = (step - cfg.warmup_steps) / max(1, cfg.max_steps - cfg.warmup_steps)
82
+ return cfg.min_lr + 0.5 * (1 + math.cos(math.pi * progress)) * (cfg.lr - cfg.min_lr)
83
+
84
+
85
+ def make_optimizer(model: nn.Module, cfg: TrainConfig) -> torch.optim.AdamW:
86
+ """AdamW with weight decay on 2-D weights only. Norm gains (1-D) are not decayed."""
87
+ decay = [p for p in model.parameters() if p.dim() >= 2]
88
+ no_decay = [p for p in model.parameters() if p.dim() < 2]
89
+ groups = [
90
+ {"params": decay, "weight_decay": cfg.weight_decay},
91
+ {"params": no_decay, "weight_decay": 0.0},
92
+ ]
93
+ fused = torch.device(cfg.device).type == "cuda" # one kernel for all params instead of a loop
94
+ return torch.optim.AdamW(groups, lr=cfg.lr, betas=(cfg.beta1, cfg.beta2), eps=1e-8, fused=fused)
95
+
96
+
97
+ def train_step(
98
+ model: nn.Module,
99
+ opt: torch.optim.Optimizer,
100
+ batches: list[tuple[Tensor, Tensor]],
101
+ lr: float,
102
+ grad_clip: float,
103
+ ) -> tuple[Tensor, Tensor]:
104
+ """One optimizer step over len(batches) micro-batches. Returns (mean loss, pre-clip grad norm).
105
+
106
+ Each micro-batch loss is divided by the number of micro-batches, so the summed
107
+ gradient equals the gradient of one big batch. Results stay on the device;
108
+ calling .item() forces a GPU sync, so the caller only does it when logging.
109
+ """
110
+ for g in opt.param_groups:
111
+ g["lr"] = lr
112
+ device_type = batches[0][0].device.type
113
+ total = torch.zeros((), device=batches[0][0].device)
114
+ for x, y in batches:
115
+ with torch.autocast(device_type, dtype=torch.bfloat16, enabled=device_type == "cuda"):
116
+ _, loss = model(x, targets=y)
117
+ (loss / len(batches)).backward()
118
+ total += loss.detach()
119
+ grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip)
120
+ opt.step()
121
+ opt.zero_grad(set_to_none=True)
122
+ return total / len(batches), grad_norm
123
+
124
+
125
+ @torch.no_grad()
126
+ def evaluate(model: nn.Module, tokens: np.ndarray, cfg: TrainConfig) -> float:
127
+ """Mean loss over cfg.eval_batches fixed val batches (same windows every call)."""
128
+ model.eval()
129
+ rng = np.random.default_rng(EVAL_SEED)
130
+ device_type = torch.device(cfg.device).type
131
+ total = torch.zeros((), device=cfg.device)
132
+ for _ in range(cfg.eval_batches):
133
+ x, y = get_batch(tokens, cfg.micro_batch, cfg.seq_len, rng, cfg.device)
134
+ with torch.autocast(device_type, dtype=torch.bfloat16, enabled=device_type == "cuda"):
135
+ _, loss = model(x, targets=y)
136
+ total += loss
137
+ model.train()
138
+ return (total / cfg.eval_batches).item()
139
+
140
+
141
+ def save_checkpoint(path: Path, state: dict) -> None:
142
+ """Write to a temp file, then rename, so a crash mid-save never corrupts the last checkpoint."""
143
+ path.parent.mkdir(parents=True, exist_ok=True)
144
+ tmp = path.with_suffix(".tmp")
145
+ torch.save(state, tmp)
146
+ os.replace(tmp, path)
147
+
148
+
149
+ def load_checkpoint(path: Path, device: str) -> dict:
150
+ return torch.load(path, map_location=device, weights_only=True)
151
+
152
+
153
+ def train(
154
+ cfg: TrainConfig,
155
+ model_cfg: ModelConfig | None = None,
156
+ tokens: np.ndarray | None = None,
157
+ val_tokens: np.ndarray | None = None,
158
+ log=print,
159
+ logger: Logger | None = None,
160
+ ) -> tuple[Transformer, list[dict]]:
161
+ """Run optimizer steps up to cfg.stop_at (or cfg.max_steps). Returns the model and the history.
162
+
163
+ History rows are {"step", "loss", "lr", "grad_norm", "tok_per_s"} at log steps and
164
+ {"step", "val_loss"} at eval steps. `log` prints to the console; `logger` (built
165
+ from cfg.logger when not given) sends the same numbers to W&B.
166
+ """
167
+ run_dir = cfg.run_dir
168
+ ckpt = load_checkpoint(run_dir / "ckpt.pt", cfg.device) if cfg.resume else None
169
+ if ckpt is not None:
170
+ model_cfg = ModelConfig.from_dict(ckpt["model_cfg"]) # the checkpoint defines the model
171
+ model_cfg = model_cfg or get_config(cfg.preset)
172
+ if cfg.seq_len > model_cfg.max_seq_len:
173
+ raise ValueError(f"seq_len {cfg.seq_len} > model max_seq_len {model_cfg.max_seq_len}")
174
+ torch.manual_seed(cfg.seed)
175
+ torch.set_float32_matmul_precision("high") # TF32 for any matmul left in fp32
176
+ tokens = load_tokens("train") if tokens is None else tokens
177
+ val_tokens = load_tokens("validation") if val_tokens is None else val_tokens
178
+ tok = load_tokenizer() if cfg.sample_tokens else None
179
+ rng = np.random.default_rng(cfg.seed)
180
+
181
+ model = Transformer(model_cfg).to(cfg.device)
182
+ opt = make_optimizer(model, cfg)
183
+ start, best_val = 0, math.inf
184
+ if ckpt is not None:
185
+ model.load_state_dict(ckpt["model"])
186
+ opt.load_state_dict(ckpt["optimizer"])
187
+ rng.bit_generator.state = ckpt["rng"]
188
+ start, best_val = ckpt["step"] + 1, ckpt["best_val"]
189
+ log(f"resumed from {run_dir / 'ckpt.pt'} at step {start} (best val {best_val:.4f})")
190
+ end = min(cfg.stop_at or cfg.max_steps, cfg.max_steps)
191
+ log(f"params {model.num_params():,} | {cfg.tokens_per_step:,} tokens/step "
192
+ f"({cfg.micro_batch}x{cfg.seq_len} x {cfg.grad_accum} accum) | steps {start}..{end - 1} "
193
+ f"of {cfg.max_steps} ({cfg.tokens_per_step * cfg.max_steps / 1e6:.1f}M tokens total) | {run_dir}")
194
+ if logger is None:
195
+ logger = make_logger(
196
+ cfg.logger, project=cfg.wandb_project, name=cfg.run_name or cfg.preset,
197
+ config={**asdict(cfg), "model": model_cfg.to_dict(), "params": model.num_params()},
198
+ mode=cfg.wandb_mode, run_id=ckpt.get("wandb_id") if ckpt else None, dir=str(REPO))
199
+ try:
200
+ history = _loop(cfg, model, opt, rng, tokens, val_tokens, tok, model_cfg, run_dir,
201
+ start, end, best_val, log, logger)
202
+ finally:
203
+ logger.finish() # flush W&B even if training crashes or is interrupted
204
+ return model, history
205
+
206
+
207
+ def _loop(cfg, model, opt, rng, tokens, val_tokens, tok, model_cfg, run_dir,
208
+ start, end, best_val, log, logger) -> list[dict]:
209
+ history: list[dict] = []
210
+ flops_per_token = model_cfg.flops_per_token(cfg.seq_len)
211
+ # The compiled wrapper shares the model's weights. Only the fixed-shape training step uses it:
212
+ # eval and sampling (a new length every token) would trigger recompiles, and checkpoints
213
+ # save `model` so state_dict keys never get the `_orig_mod.` prefix.
214
+ step_model = torch.compile(model) if cfg.compile else model
215
+ t_last, step_last = time.perf_counter(), start - 1
216
+ for step in range(start, end):
217
+ lr = lr_at(step, cfg)
218
+ batches = [get_batch(tokens, cfg.micro_batch, cfg.seq_len, rng, cfg.device)
219
+ for _ in range(cfg.grad_accum)]
220
+ loss, grad_norm = train_step(step_model, opt, batches, lr, cfg.grad_clip)
221
+
222
+ if step % cfg.log_every == 0 or step == end - 1:
223
+ rec = {"step": step, "loss": loss.item(), "lr": lr, "grad_norm": grad_norm.item()}
224
+ now = time.perf_counter() # .item() above synced the GPU, so this timing is real
225
+ rec["tok_per_s"] = (step - step_last) * cfg.tokens_per_step / (now - t_last)
226
+ rec["tflops"] = rec["tok_per_s"] * flops_per_token / 1e12 # achieved model TFLOPS
227
+ rec["mfu"] = rec["tflops"] / cfg.peak_tflops
228
+ t_last, step_last = now, step
229
+ history.append(rec)
230
+ log(f"step {step:5d} | loss {rec['loss']:.4f} | lr {lr:.2e} | "
231
+ f"grad {rec['grad_norm']:.3f} | {rec['tok_per_s'] / 1e3:.1f}K tok/s | "
232
+ f"{rec['tflops']:.1f} TFLOPS | MFU {rec['mfu']:.1%}")
233
+ logger.log({"train/loss": rec["loss"], "train/lr": lr, "train/grad_norm": rec["grad_norm"],
234
+ "train/tok_per_s": rec["tok_per_s"], "train/tflops": rec["tflops"],
235
+ "train/mfu": rec["mfu"],
236
+ "train/tokens": (step + 1) * cfg.tokens_per_step}, step=step)
237
+
238
+ if step % cfg.eval_every == 0 or step == end - 1:
239
+ val = evaluate(model, val_tokens, cfg)
240
+ history.append({"step": step, "val_loss": val})
241
+ logger.log({"val/loss": val, "val/best": min(val, best_val)}, step=step)
242
+ saved = "ckpt.pt"
243
+ if val < best_val:
244
+ best_val = val
245
+ # Weights only, for sampling / export later.
246
+ save_checkpoint(run_dir / "best.pt", {"model": model.state_dict(),
247
+ "model_cfg": model_cfg.to_dict(), "step": step, "val_loss": val})
248
+ saved += " + best.pt"
249
+ save_checkpoint(run_dir / "ckpt.pt", {
250
+ "model": model.state_dict(), "optimizer": opt.state_dict(), "rng": rng.bit_generator.state,
251
+ "step": step, "best_val": best_val, "model_cfg": model_cfg.to_dict(), "train_cfg": asdict(cfg),
252
+ "wandb_id": logger.run_id,
253
+ })
254
+ log(f"eval {step:5d} | val {val:.4f} | best {best_val:.4f} | saved {saved}")
255
+ if tok is not None:
256
+ gen = torch.Generator(cfg.device).manual_seed(cfg.seed) # same sampling noise each eval
257
+ prompt = torch.tensor([tok.encode(SAMPLE_PROMPT).ids], device=cfg.device)
258
+ out = sample(model, prompt, cfg.sample_tokens, temperature=0.8, top_k=50,
259
+ eos_id=tok.token_to_id(EOS), generator=gen)
260
+ text = tok.decode(out[0].tolist())
261
+ log("sample | " + text.replace("\n", " "))
262
+ logger.log_sample(text, step=step)
263
+ t_last, step_last = time.perf_counter(), step # eval time doesn't count toward tok/s
264
+ return history
265
+
266
+
267
+ def main() -> None:
268
+ p = argparse.ArgumentParser(description="Train a tinyllm model")
269
+ for f in fields(TrainConfig):
270
+ if isinstance(f.default, bool):
271
+ p.add_argument(f"--{f.name}", action=argparse.BooleanOptionalAction, default=f.default)
272
+ else:
273
+ p.add_argument(f"--{f.name}", type=type(f.default), default=f.default)
274
+ train(TrainConfig(**vars(p.parse_args())))
275
+
276
+
277
+ if __name__ == "__main__":
278
+ main()
src/tinyllm/web/demo.html ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>tiny-llm demo</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light;
10
+ --page: #f9f9f7; --surface: #fcfcfb; --ink: #0b0b0b; --ink-2: #52514e; --muted: #898781;
11
+ --grid: #e1e0d9; --ring: rgba(11,11,11,0.10); --accent: #2a78d6; --on-accent: #fff;
12
+ --gen: #e8f1fc; --bad: #d03b3b; --warn: #fab219;
13
+ }
14
+ @media (prefers-color-scheme: dark) {
15
+ :root:not([data-theme="light"]) {
16
+ color-scheme: dark;
17
+ --page: #0d0d0d; --surface: #1a1a19; --ink: #f0efec; --ink-2: #c3c2b7; --muted: #898781;
18
+ --grid: #2c2c2a; --ring: rgba(255,255,255,0.10); --accent: #3987e5; --gen: #12243a;
19
+ }
20
+ }
21
+ :root[data-theme="dark"] {
22
+ color-scheme: dark;
23
+ --page: #0d0d0d; --surface: #1a1a19; --ink: #f0efec; --ink-2: #c3c2b7; --muted: #898781;
24
+ --grid: #2c2c2a; --ring: rgba(255,255,255,0.10); --accent: #3987e5; --gen: #12243a;
25
+ }
26
+ * { box-sizing: border-box; }
27
+ body { margin: 0; background: var(--page); color: var(--ink);
28
+ font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
29
+ main { max-width: 760px; margin: 0 auto; padding: 28px 16px 48px; }
30
+ h1 { font-size: 24px; margin: 0; font-weight: 600; }
31
+ .lede { color: var(--ink-2); margin: 4px 0 20px; }
32
+ a { color: var(--accent); }
33
+ .panel { background: var(--surface); border: 1px solid var(--ring); border-radius: 12px; padding: 16px; }
34
+ label { font-size: 14px; color: var(--ink-2); }
35
+ textarea { width: 100%; min-height: 84px; resize: vertical; margin: 6px 0 4px; padding: 10px 12px;
36
+ font: inherit; color: var(--ink); background: var(--page); border: 1px solid var(--ring); border-radius: 8px; }
37
+ textarea:focus-visible, button:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
38
+ .count { font-size: 12px; color: var(--muted); text-align: right; }
39
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 14px; }
40
+ .chip { font: inherit; font-size: 13px; padding: 4px 10px; border-radius: 999px; cursor: pointer;
41
+ color: var(--ink-2); background: transparent; border: 1px solid var(--ring); }
42
+ .chip:hover { color: var(--ink); border-color: var(--muted); }
43
+ .controls { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 20px; }
44
+ .controls div { display: flex; flex-direction: column; }
45
+ .controls output { color: var(--ink); font-variant-numeric: tabular-nums; }
46
+ input[type=range] { accent-color: var(--accent); width: 100%; }
47
+ .actions { display: flex; gap: 10px; align-items: center; margin-top: 16px; flex-wrap: wrap; }
48
+ button.primary, button.secondary { font: inherit; font-size: 15px; padding: 8px 18px; border-radius: 8px; cursor: pointer; }
49
+ button.primary { background: var(--accent); color: var(--on-accent); border: 1px solid var(--accent); }
50
+ button.secondary { background: transparent; color: var(--ink); border: 1px solid var(--ring); }
51
+ button:disabled { opacity: 0.5; cursor: default; }
52
+ .hint { font-size: 13px; color: var(--muted); }
53
+ .story { margin-top: 16px; min-height: 120px; white-space: pre-wrap; font-size: 17px; line-height: 1.65; }
54
+ .story .p { color: var(--ink-2); }
55
+ .story .g { background: var(--gen); border-radius: 3px; }
56
+ .story .placeholder { color: var(--muted); }
57
+ .cursor { display: inline-block; width: 8px; height: 1.1em; vertical-align: text-bottom; background: var(--accent);
58
+ margin-left: 1px; animation: blink 1s steps(1) infinite; }
59
+ @keyframes blink { 50% { opacity: 0; } }
60
+ @media (prefers-reduced-motion: reduce) { .cursor { animation: none; } }
61
+ .stats { display: flex; flex-wrap: wrap; gap: 6px 20px; margin-top: 12px; font-size: 13px; color: var(--ink-2);
62
+ font-variant-numeric: tabular-nums; }
63
+ .stats b { color: var(--ink); font-weight: 600; }
64
+ .msg { display: none; margin-top: 12px; padding: 8px 12px; border-radius: 8px; font-size: 14px;
65
+ border: 1px solid var(--ring); border-left: 4px solid var(--warn); }
66
+ .msg.show { display: block; }
67
+ .msg.error { border-left-color: var(--bad); }
68
+ footer { color: var(--ink-2); font-size: 13px; margin-top: 28px; }
69
+ footer p { margin: 6px 0; }
70
+ footer details { margin: 6px 0; }
71
+ footer summary { cursor: pointer; color: var(--ink); }
72
+ footer ul { margin: 6px 0; padding-left: 20px; }
73
+ @media (max-width: 520px) { .controls { grid-template-columns: 1fr; } }
74
+ </style>
75
+ </head>
76
+ <body>
77
+ <main>
78
+ <h1>tiny-llm</h1>
79
+ <p class="lede">A small language model trained from scratch on children's stories.
80
+ Start a story and it writes the rest.
81
+ It knows simple stories, not facts, so it can't answer questions or follow instructions.</p>
82
+
83
+ <section class="panel">
84
+ <label for="prompt">Story opening</label>
85
+ <textarea id="prompt" maxlength="600" spellcheck="true">Once upon a time, there was a little dog named Max.</textarea>
86
+ <div class="count" id="count"></div>
87
+ <div class="chips" id="chips" aria-label="Example openings"></div>
88
+ <div class="controls">
89
+ <div><label for="len">Length: <output id="len-out"></output> tokens</label>
90
+ <input type="range" id="len" min="20" max="250" step="10" value="120"></div>
91
+ <div><label for="temp">Temperature: <output id="temp-out"></output></label>
92
+ <input type="range" id="temp" min="0" max="1.5" step="0.1" value="0.8"></div>
93
+ </div>
94
+ <div class="actions">
95
+ <button class="primary" id="go">Write the story</button>
96
+ <button class="secondary" id="stop" disabled>Stop</button>
97
+ <span class="hint">Ctrl + Enter. Lower temperature: safer and more repetitive; higher: more surprising.</span>
98
+ </div>
99
+ </section>
100
+
101
+ <div class="msg" id="msg" role="status"></div>
102
+ <div class="story" id="story" aria-live="polite"><span class="placeholder">The story appears here, one token at a time.</span></div>
103
+ <div class="stats" id="stats"></div>
104
+
105
+ <footer>
106
+ <p>Model: <span id="model">loading…</span> ·
107
+ <a href="https://huggingface.co/CoolDip00/tiny-llm-tinystories-30m">model card</a> ·
108
+ <a href="https://github.com/cooldip00/tiny-llm">source on GitHub</a> ·
109
+ <a href="status">server status</a></p>
110
+ <p>This demo runs on a shared CPU, so it is slower than the GPU numbers in the project README,
111
+ and it can be busy or asleep for a minute.</p>
112
+ <p>Privacy: your text isn't stored. The server keeps request metadata (timings, token counts and a
113
+ salted hash of your address), never the opening or the story.</p>
114
+ <details>
115
+ <summary>What is logged</summary>
116
+ <ul>
117
+ <li>Per request: time, status, timings (queue wait, time to first token, total), token counts,
118
+ whether it streamed, why it stopped, the error message if any, and how busy the server was.</li>
119
+ <li>Your address only as a salted hash: it can tell repeat visits apart, not who you are.
120
+ Raw addresses are never stored.</li>
121
+ <li>Never the opening you type or the story the model writes.</li>
122
+ <li>The log is kept in a private dataset that only the project owner can read, and is deleted after 30 days.</li>
123
+ </ul>
124
+ </details>
125
+ </footer>
126
+ </main>
127
+ <script>
128
+ "use strict";
129
+ const EXAMPLES = [
130
+ "Once upon a time, there was a little dog named Max.",
131
+ "Lily found a shiny key under her bed.",
132
+ "The sun was hot, and Tom wanted to swim.",
133
+ "One day, a small bird could not fly.",
134
+ "Mia and her mom baked a big cake.",
135
+ ];
136
+ const MAX_CHARS = 600;
137
+ const $ = (id) => document.getElementById(id);
138
+ const prompt = $("prompt"), story = $("story"), stats = $("stats"), msg = $("msg");
139
+ let ctrl = null;
140
+
141
+ for (const ex of EXAMPLES) {
142
+ const b = document.createElement("button");
143
+ b.className = "chip"; b.type = "button"; b.textContent = ex;
144
+ b.onclick = () => { prompt.value = ex; updateCount(); prompt.focus(); };
145
+ $("chips").appendChild(b);
146
+ }
147
+ function updateCount() { $("count").textContent = `${prompt.value.length} / ${MAX_CHARS}`; }
148
+ function bindRange(id, fmt) {
149
+ const r = $(id), o = $(id + "-out");
150
+ const f = () => { o.textContent = fmt(r.value); };
151
+ r.oninput = f; f();
152
+ }
153
+ bindRange("len", (v) => v);
154
+ bindRange("temp", (v) => Number(v).toFixed(1));
155
+ prompt.oninput = updateCount; updateCount();
156
+ prompt.onkeydown = (e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); start(); } };
157
+ $("go").onclick = start;
158
+ $("stop").onclick = () => ctrl && ctrl.abort();
159
+
160
+ fetch("v1/models").then((r) => r.json()).then((d) => {
161
+ const m = d.data && d.data[0];
162
+ $("model").textContent = m ? (m.version || m.id) : "unknown";
163
+ }).catch(() => { $("model").textContent = "unavailable"; });
164
+
165
+ function say(text, kind) {
166
+ msg.textContent = text;
167
+ msg.className = "msg show" + (kind === "error" ? " error" : "");
168
+ }
169
+ function stat(label, value) {
170
+ const s = document.createElement("span");
171
+ const b = document.createElement("b"); b.textContent = value;
172
+ s.append(document.createTextNode(label + " "), b);
173
+ return s;
174
+ }
175
+ function showStats(t) {
176
+ const items = [];
177
+ if (t.ttft !== null) items.push(stat("First token", Math.round(t.ttft * 1000) + " ms"));
178
+ if (t.tokens > 1 && t.genS > 0) items.push(stat("Speed", ((t.tokens - 1) / t.genS).toFixed(1) + " tokens/s"));
179
+ items.push(stat("Tokens", String(t.tokens)));
180
+ if (t.finish) items.push(stat("Stopped because", t.finish === "stop" ? "the story ended" :
181
+ t.finish === "length" ? "it reached the length" : t.finish));
182
+ stats.replaceChildren(...items);
183
+ }
184
+ function errorText(status, body, retry) {
185
+ if (status === 429) return `The server is busy right now. Please try again in ${retry || "a few"} seconds.`;
186
+ if (status === 503) return "The model is still loading (or the server is waking up). Try again in a minute.";
187
+ if (status === 504) return "That took too long and was stopped. Try a shorter length.";
188
+ if (status === 400) return "The request wasn't accepted: " + ((body && body.error && body.error.message) || "bad input");
189
+ return `Something went wrong on the server (${status}). Please try again.`;
190
+ }
191
+
192
+ async function start() {
193
+ if (ctrl) return;
194
+ const text = prompt.value.slice(0, MAX_CHARS);
195
+ if (!text.trim()) { say("Write the start of a story first."); return; }
196
+ msg.className = "msg";
197
+ const pSpan = document.createElement("span"), gSpan = document.createElement("span"), cur = document.createElement("span");
198
+ pSpan.className = "p"; gSpan.className = "g"; cur.className = "cursor";
199
+ pSpan.textContent = text;
200
+ story.replaceChildren(pSpan, gSpan, cur);
201
+ stats.replaceChildren();
202
+ ctrl = new AbortController();
203
+ $("go").disabled = true; $("stop").disabled = false;
204
+ const t = { t0: performance.now(), ttft: null, tFirst: 0, genS: 0, tokens: 0, finish: null };
205
+ try {
206
+ const r = await fetch("v1/completions", {
207
+ method: "POST", headers: { "content-type": "application/json" }, signal: ctrl.signal,
208
+ body: JSON.stringify({ prompt: text, max_tokens: Number($("len").value),
209
+ temperature: Number($("temp").value), stream: true }),
210
+ });
211
+ if (!r.ok) {
212
+ let body = null; try { body = await r.json(); } catch (e) {}
213
+ throw { status: r.status, body, retry: r.headers.get("retry-after") };
214
+ }
215
+ const reader = r.body.getReader(), dec = new TextDecoder();
216
+ let buf = "";
217
+ for (;;) {
218
+ const { value, done } = await reader.read();
219
+ if (done) break;
220
+ buf += dec.decode(value, { stream: true });
221
+ let i;
222
+ while ((i = buf.indexOf("\n\n")) >= 0) {
223
+ const line = buf.slice(0, i).trim(); buf = buf.slice(i + 2);
224
+ if (!line.startsWith("data:")) continue;
225
+ const data = line.slice(5).trim();
226
+ if (data === "[DONE]") continue;
227
+ const ev = JSON.parse(data);
228
+ if (ev.error) throw { status: ev.error.code, body: ev };
229
+ const ch = ev.choices[0], now = performance.now();
230
+ if (t.ttft === null) { t.ttft = (now - t.t0) / 1000; t.tFirst = now; }
231
+ t.genS = (now - t.tFirst) / 1000;
232
+ if (ev.usage) t.tokens = ev.usage.completion_tokens; else t.tokens += 1;
233
+ if (ch.text) gSpan.textContent += ch.text;
234
+ if (ch.finish_reason) t.finish = ch.finish_reason;
235
+ showStats(t);
236
+ }
237
+ }
238
+ } catch (e) {
239
+ if (e && e.name === "AbortError") { t.finish = "you pressed Stop"; showStats(t); }
240
+ else if (e && e.status) say(errorText(e.status, e.body, e.retry), e.status === 429 || e.status === 503 ? "warn" : "error");
241
+ else say("Can't reach the server. Check your connection, or try again in a minute.", "error");
242
+ } finally {
243
+ cur.remove();
244
+ ctrl = null;
245
+ $("go").disabled = false; $("stop").disabled = true;
246
+ }
247
+ }
248
+ </script>
249
+ </body>
250
+ </html>
src/tinyllm/web/status.html ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>tiny-llm status</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light;
10
+ --page: #f9f9f7; --surface: #fcfcfb; --ink: #0b0b0b; --ink-2: #52514e; --muted: #898781;
11
+ --grid: #e1e0d9; --axis: #c3c2b7; --ring: rgba(11,11,11,0.10);
12
+ --s1: #2a78d6; --s2: #eb6834; --s3: #1baf7a; --track: #cde2fb;
13
+ --good: #0ca30c; --warn: #fab219; --bad: #d03b3b;
14
+ }
15
+ @media (prefers-color-scheme: dark) {
16
+ :root:not([data-theme="light"]) {
17
+ color-scheme: dark;
18
+ --page: #0d0d0d; --surface: #1a1a19; --ink: #f0efec; --ink-2: #c3c2b7; --muted: #898781;
19
+ --grid: #2c2c2a; --axis: #383835; --ring: rgba(255,255,255,0.10);
20
+ --s1: #3987e5; --s2: #d95926; --s3: #199e70; --track: #184f95;
21
+ }
22
+ }
23
+ :root[data-theme="dark"] {
24
+ color-scheme: dark;
25
+ --page: #0d0d0d; --surface: #1a1a19; --ink: #f0efec; --ink-2: #c3c2b7; --muted: #898781;
26
+ --grid: #2c2c2a; --axis: #383835; --ring: rgba(255,255,255,0.10);
27
+ --s1: #3987e5; --s2: #d95926; --s3: #199e70; --track: #184f95;
28
+ }
29
+ * { box-sizing: border-box; }
30
+ body { margin: 0; background: var(--page); color: var(--ink);
31
+ font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
32
+ main { max-width: 980px; margin: 0 auto; padding: 24px 16px 48px; }
33
+ header { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 16px; }
34
+ h1 { font-size: 22px; margin: 0; font-weight: 600; }
35
+ h2 { font-size: 16px; margin: 0 0 2px; font-weight: 600; }
36
+ a { color: var(--s1); }
37
+ .meta, .sub { color: var(--ink-2); font-size: 13px; }
38
+ .sub { margin: 0 0 10px; }
39
+ .banner { display: none; margin: 12px 0 0; padding: 8px 12px; border-radius: 8px;
40
+ border: 1px solid var(--ring); background: var(--surface); }
41
+ .banner.show { display: block; }
42
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin: 20px 0 8px; }
43
+ .card, .panel { background: var(--surface); border: 1px solid var(--ring); border-radius: 12px; padding: 14px 16px; }
44
+ .card .value { font-size: 28px; font-weight: 600; margin: 4px 0 0; }
45
+ .card .line { color: var(--ink-2); font-size: 13px; margin: 2px 0; }
46
+ .state { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; }
47
+ .state i { display: inline-grid; place-items: center; width: 16px; height: 16px; border-radius: 50%;
48
+ font-style: normal; font-size: 11px; color: #fff; flex: none; }
49
+ .state.none { font-weight: 400; align-items: flex-start; } .state.none i { margin-top: 2px; }
50
+ .state.good i { background: var(--good); } .state.bad i { background: var(--bad); }
51
+ .state.warn i { background: var(--warn); color: #0b0b0b; } .state.none i { background: var(--muted); }
52
+ .meter { height: 8px; border-radius: 4px; background: var(--track); margin: 8px 0 4px; overflow: hidden; }
53
+ .meter > div { height: 100%; border-radius: 4px; background: var(--s1); }
54
+ .window { color: var(--ink-2); font-size: 13px; margin: 0 0 16px; }
55
+ .panel { margin: 12px 0; position: relative; }
56
+ .legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 13px; color: var(--ink-2); margin: 0 0 6px; }
57
+ .legend span { display: inline-flex; align-items: center; gap: 6px; }
58
+ .key { display: inline-block; width: 14px; height: 0; border-top: 2px solid; }
59
+ .key.box { width: 10px; height: 10px; border: 0; border-radius: 2px; }
60
+ .key.ref { border-top: 1px solid var(--muted); }
61
+ .chart { width: 100%; height: 190px; display: block; outline: none; }
62
+ .chart:focus-visible { box-shadow: 0 0 0 2px var(--s1); border-radius: 4px; }
63
+ .chart text { fill: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
64
+ .tip { position: absolute; pointer-events: none; display: none; min-width: 140px; z-index: 2;
65
+ background: var(--surface); border: 1px solid var(--ring); border-radius: 8px;
66
+ padding: 8px 10px; font-size: 13px; box-shadow: 0 4px 16px rgba(0,0,0,0.12); }
67
+ .tip .t { color: var(--ink-2); margin-bottom: 4px; }
68
+ .tip .r { display: flex; align-items: center; gap: 8px; }
69
+ .tip .r b { font-weight: 600; min-width: 52px; }
70
+ .tip .r span { color: var(--ink-2); }
71
+ details { margin: 16px 0; }
72
+ summary { cursor: pointer; color: var(--ink-2); }
73
+ table { border-collapse: collapse; width: 100%; font-size: 13px; margin-top: 8px; font-variant-numeric: tabular-nums; }
74
+ th, td { text-align: right; padding: 4px 8px; border-bottom: 1px solid var(--grid); }
75
+ th:first-child, td:first-child { text-align: left; }
76
+ .tablewrap { overflow-x: auto; }
77
+ footer { color: var(--ink-2); font-size: 13px; margin-top: 28px; }
78
+ .stale { opacity: 0.55; }
79
+ </style>
80
+ </head>
81
+ <body>
82
+ <main>
83
+ <header>
84
+ <h1>Status</h1>
85
+ <span class="meta" id="meta">Loading…</span>
86
+ </header>
87
+ <p class="meta"><a href="./">← Demo</a> · numbers are measured on this server, from arrival to token sent</p>
88
+ <div class="banner" id="banner" role="status"></div>
89
+
90
+ <section class="cards" id="cards" aria-label="Service level objectives"></section>
91
+ <p class="window" id="window"></p>
92
+
93
+ <section class="panel">
94
+ <h2>Requests per minute</h2>
95
+ <p class="sub">Last 60 minutes, by outcome</p>
96
+ <div class="legend">
97
+ <span><i class="key box" style="background:var(--s1)"></i>Served</span>
98
+ <span><i class="key box" style="background:var(--s2)"></i>Busy (429)</span>
99
+ <span><i class="key box" style="background:var(--s3)"></i>Server error (5xx)</span>
100
+ </div>
101
+ <svg class="chart" id="c-req" tabindex="0" role="img" aria-label="Requests per minute, last hour"></svg>
102
+ <div class="tip"></div>
103
+ </section>
104
+
105
+ <section class="panel">
106
+ <h2>Latency</h2>
107
+ <p class="sub">Per minute, successful requests; time to first token includes the wait for a slot</p>
108
+ <div class="legend">
109
+ <span><i class="key" style="border-color:var(--s1)"></i>First token p50</span>
110
+ <span><i class="key" style="border-color:var(--s2)"></i>First token p95</span>
111
+ <span><i class="key" style="border-color:var(--s3)"></i>Whole request p95</span>
112
+ <span><i class="key ref"></i><span id="ref-label">SLO</span></span>
113
+ </div>
114
+ <svg class="chart" id="c-lat" tabindex="0" role="img" aria-label="Latency per minute, last hour"></svg>
115
+ <div class="tip"></div>
116
+ </section>
117
+
118
+ <section class="panel">
119
+ <h2>Load</h2>
120
+ <p class="sub">Engine snapshots every 5 s</p>
121
+ <div class="legend">
122
+ <span><i class="key" style="border-color:var(--s1)"></i>Busy slots</span>
123
+ <span><i class="key" style="border-color:var(--s2)"></i>Waiting in queue</span>
124
+ <span><i class="key ref"></i><span id="slots-label">Slots</span></span>
125
+ </div>
126
+ <svg class="chart" id="c-load" tabindex="0" role="img" aria-label="Busy slots and queue length, last hour"></svg>
127
+ <div class="tip"></div>
128
+ </section>
129
+
130
+ <details>
131
+ <summary>Show the last hour as a table</summary>
132
+ <div class="tablewrap"><table id="table"></table></div>
133
+ </details>
134
+
135
+ <footer>
136
+ SLOs and the error budget are defined in
137
+ <a href="https://github.com/cooldip00/tiny-llm/blob/main/docs/SERVING.md#slos-413-step-3-written-before-the-load-test">docs/SERVING.md</a>.
138
+ History is kept in memory, so it starts again when the server restarts.
139
+ The server runs on CPU here; the speed numbers in the project README come from a laptop GPU.
140
+ </footer>
141
+ </main>
142
+ <script>
143
+ "use strict";
144
+ const NS = "http://www.w3.org/2000/svg";
145
+ const SLO_NAMES = { ttft: "Time to first token", availability: "Availability", shed: "Requests not shed" };
146
+ let last = null;
147
+
148
+ function el(tag, attrs, parent, text) {
149
+ const e = document.createElementNS(NS, tag);
150
+ for (const k in attrs) e.setAttribute(k, attrs[k]);
151
+ if (text !== undefined) e.textContent = text;
152
+ if (parent) parent.appendChild(e);
153
+ return e;
154
+ }
155
+ function h(tag, cls, text) {
156
+ const e = document.createElement(tag);
157
+ if (cls) e.className = cls;
158
+ if (text !== undefined) e.textContent = text;
159
+ return e;
160
+ }
161
+ function niceStep(v) {
162
+ if (!(v > 0)) return 1;
163
+ const p = Math.pow(10, Math.floor(Math.log10(v)));
164
+ for (const m of [1, 2, 5]) if (m * p >= v) return m * p;
165
+ return 10 * p;
166
+ }
167
+ const pct = (x, d = 1) => x === null || x === undefined ? "–" : (100 * x).toFixed(d) + "%";
168
+ function secs(x) {
169
+ if (x === null || x === undefined) return "–";
170
+ return x < 1 ? Math.round(x * 1000) + " ms" : x.toFixed(2) + " s";
171
+ }
172
+ function dur(s) {
173
+ s = Math.round(s);
174
+ const d = Math.floor(s / 86400), hh = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60);
175
+ if (d) return `${d} d ${hh} h`;
176
+ if (hh) return `${hh} h ${m} min`;
177
+ return m ? `${m} min` : `${s} s`;
178
+ }
179
+ const minLabel = (ago) => ago === 0 ? "this minute" : `${ago} min ago`;
180
+
181
+ /* One chart: x in minutes from -60 to 0; series of {name, color, values: [{x, y}|null...]}.
182
+ kind "stack": columns, one per minute; kind "line": 2px lines with an end dot. */
183
+ function drawChart(svg, spec) {
184
+ const W = svg.clientWidth || 600, H = svg.clientHeight || 190;
185
+ const m = { l: 44, r: 14, t: 10, b: 24 };
186
+ const pw = W - m.l - m.r, ph = H - m.t - m.b;
187
+ svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
188
+ svg.replaceChildren();
189
+ const X = (x) => m.l + (x + 60) / 60 * pw;
190
+ let ymax = spec.ymin || 0;
191
+ for (const p of spec.points) ymax = Math.max(ymax, spec.total(p));
192
+ if (spec.ref) ymax = Math.max(ymax, spec.ref.value * 1.15);
193
+ let step = niceStep(ymax / 4); // ticks on 1 / 2 / 5 × 10^k, 3–5 gridlines
194
+ if (spec.integer) step = Math.max(1, Math.round(step));
195
+ const nTicks = Math.max(1, Math.ceil(ymax / step - 1e-9));
196
+ ymax = nTicks * step;
197
+ const Y = (y) => m.t + ph - y / ymax * ph;
198
+ for (let i = 0; i <= nTicks; i++) {
199
+ const v = +(step * i).toPrecision(12), y = Y(v);
200
+ el("line", { x1: m.l, x2: W - m.r, y1: y, y2: y, stroke: i ? "var(--grid)" : "var(--axis)", "stroke-width": 1 }, svg);
201
+ el("text", { x: m.l - 6, y: y + 4, "text-anchor": "end" }, svg, spec.fmt(v));
202
+ }
203
+ for (const ago of [60, 45, 30, 15, 0])
204
+ el("text", { x: X(-ago), y: H - 6, "text-anchor": ago === 60 ? "start" : ago === 0 ? "end" : "middle" },
205
+ svg, ago ? `−${ago} min` : "now");
206
+ if (spec.ref) {
207
+ const y = Y(spec.ref.value);
208
+ el("line", { x1: m.l, x2: W - m.r, y1: y, y2: y, stroke: "var(--muted)", "stroke-width": 1 }, svg);
209
+ el("text", { x: W - m.r, y: y - 4, "text-anchor": "end" }, svg, spec.ref.label);
210
+ }
211
+ if (spec.kind === "stack") {
212
+ const slot = pw / 60, bw = Math.max(2, Math.min(24, slot - 2));
213
+ for (const p of spec.points) {
214
+ if (!p) continue;
215
+ let base = 0;
216
+ const parts = spec.series.map((s) => ({ s, v: s.get(p) })).filter((q) => q.v > 0);
217
+ parts.forEach((q, i) => {
218
+ const x = X(p.x) - slot / 2 + (slot - bw) / 2, y0 = Y(base), y1 = Y(base + q.v);
219
+ const top = i === parts.length - 1, gap = i ? 2 : 0, hgt = Math.max(0, y0 - y1 - gap);
220
+ const r = top ? Math.min(2, bw / 2, hgt) : 0, yb = y0 - gap;
221
+ el("path", { d: `M${x},${yb} V${yb - hgt + r} Q${x},${yb - hgt} ${x + r},${yb - hgt} H${x + bw - r} ` +
222
+ `Q${x + bw},${yb - hgt} ${x + bw},${yb - hgt + r} V${yb} Z`, fill: q.s.color }, svg);
223
+ base += q.v;
224
+ });
225
+ }
226
+ } else {
227
+ for (const s of spec.series) {
228
+ let d = "", lastPt = null, prev = null;
229
+ for (const p of spec.points) {
230
+ const v = p ? s.get(p) : null;
231
+ if (v === null || v === undefined) { prev = null; continue; }
232
+ const gapBreak = prev && p.x - prev.x > (spec.maxGap || 1.01);
233
+ d += `${prev && !gapBreak ? "L" : "M"}${X(p.x).toFixed(1)},${Y(v).toFixed(1)} `;
234
+ prev = p; lastPt = [X(p.x), Y(v)];
235
+ }
236
+ if (d) el("path", { d, fill: "none", stroke: s.color, "stroke-width": 2, "stroke-linejoin": "round", "stroke-linecap": "round" }, svg);
237
+ if (lastPt) el("circle", { cx: lastPt[0], cy: lastPt[1], r: 4, fill: s.color, stroke: "var(--surface)", "stroke-width": 2 }, svg);
238
+ }
239
+ }
240
+ attachHover(svg, spec, X, m, H);
241
+ }
242
+
243
+ function attachHover(svg, spec, X, m, H) {
244
+ const tip = svg.nextElementSibling;
245
+ const pts = spec.points.filter(Boolean);
246
+ const cross = el("line", { y1: m.t, y2: H - m.b, stroke: "var(--axis)", "stroke-width": 1, visibility: "hidden" }, svg);
247
+ let idx = -1;
248
+ function show(i) {
249
+ if (!pts.length) return;
250
+ idx = Math.max(0, Math.min(pts.length - 1, i));
251
+ const p = pts[idx], x = X(p.x);
252
+ cross.setAttribute("x1", x); cross.setAttribute("x2", x); cross.setAttribute("visibility", "visible");
253
+ tip.replaceChildren(h("div", "t", spec.when(p)));
254
+ for (const s of spec.series) {
255
+ const r = h("div", "r"), k = h("i", spec.kind === "stack" ? "key box" : "key");
256
+ k.style[spec.kind === "stack" ? "background" : "borderColor"] = s.color;
257
+ r.append(k, h("b", "", spec.fmtTip(s.get(p))), h("span", "", s.name));
258
+ tip.appendChild(r);
259
+ }
260
+ tip.style.display = "block";
261
+ const box = svg.getBoundingClientRect(), panel = svg.parentElement.getBoundingClientRect();
262
+ let left = box.left - panel.left + x + 12;
263
+ if (left + tip.offsetWidth > panel.width - 8) left = box.left - panel.left + x - tip.offsetWidth - 12;
264
+ tip.style.left = Math.max(8, left) + "px";
265
+ tip.style.top = (box.top - panel.top + 8) + "px";
266
+ }
267
+ function hide() { cross.setAttribute("visibility", "hidden"); tip.style.display = "none"; }
268
+ function nearest(clientX) {
269
+ const box = svg.getBoundingClientRect(), x = clientX - box.left;
270
+ let best = -1, bd = Infinity;
271
+ pts.forEach((p, i) => { const d = Math.abs(X(p.x) - x); if (d < bd) { bd = d; best = i; } });
272
+ return best;
273
+ }
274
+ svg.onpointermove = (e) => show(nearest(e.clientX));
275
+ svg.onpointerleave = hide;
276
+ svg.onblur = hide;
277
+ svg.onfocus = () => show(pts.length - 1);
278
+ svg.onkeydown = (e) => {
279
+ if (e.key === "ArrowLeft") { show(idx - 1); e.preventDefault(); }
280
+ if (e.key === "ArrowRight") { show(idx + 1); e.preventDefault(); }
281
+ if (e.key === "Escape") hide();
282
+ };
283
+ }
284
+
285
+ function state(cls, icon, label) {
286
+ const s = h("span", "state " + cls); s.append(h("i", "", icon), document.createTextNode(label)); return s;
287
+ }
288
+
289
+ function renderCards(d) {
290
+ const cards = document.getElementById("cards");
291
+ cards.replaceChildren();
292
+ for (const key of ["ttft", "availability", "shed"]) {
293
+ const s = d.slo[key], c = h("div", "card");
294
+ const target = key === "ttft" ? `${pct(s.target, 0)} of requests get a first token within ${secs(s.threshold_s)}`
295
+ : key === "availability" ? `${pct(s.target, 0)} of requests not failed by the server`
296
+ : `at most ${pct(1 - s.target, 0)} of requests turned away (429) at rated load`;
297
+ c.append(h("h2", "", SLO_NAMES[key]), h("div", "line", "Target: " + target));
298
+ c.appendChild(h("div", "value", s.total ? pct(s.compliance, 2) : "–"));
299
+ c.appendChild(h("div", "line", s.total ? `${s.good.toLocaleString()} of ${s.total.toLocaleString()} requests` : "No requests yet"));
300
+ if (key === "shed") c.appendChild(state("none", "i", "Shown, not judged: the rated load for this server is not measured yet"));
301
+ else if (!s.total) c.appendChild(state("none", "–", "No data"));
302
+ else c.appendChild(s.met ? state("good", "✓", "Met") : state("bad", "✕", "Missed"));
303
+ if (s.total && key !== "shed") {
304
+ const left = Math.max(0, s.budget_left), mtr = h("div", "meter"), fill = h("div");
305
+ fill.style.width = (100 * left).toFixed(1) + "%"; mtr.appendChild(fill);
306
+ mtr.setAttribute("role", "meter"); mtr.setAttribute("aria-valuenow", (100 * left).toFixed(1));
307
+ mtr.setAttribute("aria-valuemin", "0"); mtr.setAttribute("aria-valuemax", "100");
308
+ mtr.setAttribute("aria-label", "Error budget left");
309
+ c.append(mtr, h("div", "line", s.budget_left > 0 ? `Error budget left: ${pct(s.budget_left, 0)}` : "Error budget used up"));
310
+ }
311
+ if (s.burn_rate_1h !== null) {
312
+ const line = h("div", "line", `Last hour: burn rate ${s.burn_rate_1h.toFixed(1)}× over ${s.requests_1h} requests `);
313
+ if (s.fast_burn && key !== "shed") line.appendChild(state("warn", "!", `fast burn (> ${d.fast_burn_threshold}×)`));
314
+ else if (s.requests_1h < d.burn_min_requests) line.appendChild(document.createTextNode(`(too few to flag, < ${d.burn_min_requests})`));
315
+ c.appendChild(line);
316
+ }
317
+ cards.appendChild(c);
318
+ }
319
+ document.getElementById("window").textContent =
320
+ `SLO window: rolling 7 days, counted since the last restart (${dur(d.slo_window_s)} so far). ` +
321
+ "Burn rate = the last hour's bad share ÷ the budgeted share; a fast burn above " +
322
+ `${d.fast_burn_threshold}× would spend ~9% of the 7-day budget in an hour.`;
323
+ }
324
+
325
+ function render(d) {
326
+ const v = Object.entries(d.variants)[0] || ["?", {}];
327
+ document.getElementById("meta").textContent =
328
+ `${v[1].version || v[0]} · norm ${v[1].norm_backend || "?"} · ${v[1].n_slots || "?"} slots · up ${dur(d.uptime_s)}`;
329
+ renderCards(d);
330
+ const mins = d.minutes.map((p) => p.covered ? { ...p, x: -p.minutes_ago } : null);
331
+ const threshold = d.slo.ttft.threshold_s;
332
+ document.getElementById("ref-label").textContent = `SLO: first token ≤ ${secs(threshold)}`;
333
+ drawChart(document.getElementById("c-req"), {
334
+ kind: "stack", points: mins, total: (p) => p ? p.ok + p.shed + p.failed : 0, ymin: 4, integer: true,
335
+ fmt: (v) => Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1),
336
+ fmtTip: (v) => (v || 0).toLocaleString(), when: (p) => minLabel(p.minutes_ago),
337
+ series: [{ name: "Served", color: "var(--s1)", get: (p) => p.ok },
338
+ { name: "Busy (429)", color: "var(--s2)", get: (p) => p.shed },
339
+ { name: "Server error", color: "var(--s3)", get: (p) => p.failed }] });
340
+ drawChart(document.getElementById("c-lat"), {
341
+ kind: "line", points: mins, ymin: 0.1,
342
+ total: (p) => p ? Math.max(p.ttft_p95_s || 0, p.latency_p95_s || 0) : 0,
343
+ ref: { value: threshold, label: "SLO " + secs(threshold) },
344
+ fmt: (v) => v < 1 ? Math.round(v * 1000) + " ms" : (Number.isInteger(v) ? v : v.toFixed(1)) + " s",
345
+ fmtTip: secs, when: (p) => minLabel(p.minutes_ago),
346
+ series: [{ name: "First token p50", color: "var(--s1)", get: (p) => p.ttft_p50_s },
347
+ { name: "First token p95", color: "var(--s2)", get: (p) => p.ttft_p95_s },
348
+ { name: "Whole request p95", color: "var(--s3)", get: (p) => p.latency_p95_s }] });
349
+ const slots = v[1].n_slots || 1;
350
+ document.getElementById("slots-label").textContent = `${slots} slots`;
351
+ const snaps = d.snapshots.map((s) => ({ ...s, x: -s.seconds_ago / 60 }));
352
+ drawChart(document.getElementById("c-load"), {
353
+ kind: "line", points: snaps, maxGap: 0.5, ymin: slots, integer: true,
354
+ total: (p) => p ? Math.max(p.busy_slots, p.queue_len) : 0,
355
+ ref: { value: slots, label: `${slots} slots` },
356
+ fmt: (v) => Number.isInteger(v) ? String(v) : v.toFixed(1),
357
+ fmtTip: (v) => String(v), when: (p) => `${Math.round(p.seconds_ago)} s ago`,
358
+ series: [{ name: "Busy slots", color: "var(--s1)", get: (p) => p.busy_slots },
359
+ { name: "Waiting in queue", color: "var(--s2)", get: (p) => p.queue_len }] });
360
+ renderTable(d);
361
+ }
362
+
363
+ function renderTable(d) {
364
+ const t = document.getElementById("table");
365
+ t.replaceChildren();
366
+ const head = h("tr");
367
+ for (const c of ["Minute", "Requests", "Served", "429", "5xx", "First token p50", "p95", "Whole p95", "Tokens"])
368
+ head.appendChild(h("th", "", c));
369
+ t.appendChild(head);
370
+ for (const p of [...d.minutes].reverse()) {
371
+ if (!p.covered) continue;
372
+ const r = h("tr");
373
+ for (const c of [minLabel(p.minutes_ago), p.requests, p.ok, p.shed, p.failed, secs(p.ttft_p50_s),
374
+ secs(p.ttft_p95_s), secs(p.latency_p95_s), p.completion_tokens.toLocaleString()])
375
+ r.appendChild(h("td", "", String(c)));
376
+ t.appendChild(r);
377
+ }
378
+ }
379
+
380
+ async function refresh() {
381
+ const banner = document.getElementById("banner");
382
+ try {
383
+ const r = await fetch("stats/history", { cache: "no-store" });
384
+ if (!r.ok) throw new Error("HTTP " + r.status);
385
+ last = await r.json();
386
+ banner.classList.remove("show");
387
+ document.querySelector("main").classList.remove("stale");
388
+ render(last);
389
+ } catch (e) {
390
+ banner.textContent = "Can't reach the server right now (it may be starting or asleep). Retrying every 5 s.";
391
+ banner.classList.add("show");
392
+ document.querySelector("main").classList.add("stale");
393
+ }
394
+ }
395
+ refresh();
396
+ setInterval(refresh, 5000);
397
+ let rt;
398
+ window.addEventListener("resize", () => { clearTimeout(rt); rt = setTimeout(() => last && render(last), 150); });
399
+ </script>
400
+ </body>
401
+ </html>
tokenizer/tinystories-bpe8192.json ADDED
The diff for this file is too large to render. See raw diff