tiny-llm 75e4579: 6.5b step 5: 5a Space created, 5b secrets added
Browse files- .dockerignore +7 -0
- Dockerfile +58 -0
- LICENSE +21 -0
- README.md +25 -5
- src/tinyllm/__init__.py +1 -0
- src/tinyllm/attention/__init__.py +7 -0
- src/tinyllm/attention/base.py +48 -0
- src/tinyllm/attention/gqa.py +65 -0
- src/tinyllm/attention/mha.py +45 -0
- src/tinyllm/config.py +98 -0
- src/tinyllm/cuda_graph.py +304 -0
- src/tinyllm/data.py +130 -0
- src/tinyllm/engine.py +431 -0
- src/tinyllm/generate.py +140 -0
- src/tinyllm/hf_llama.py +192 -0
- src/tinyllm/kernels/__init__.py +4 -0
- src/tinyllm/kernels/int8_gemv.py +75 -0
- src/tinyllm/kernels/rmsnorm.py +88 -0
- src/tinyllm/kv_cache.py +238 -0
- src/tinyllm/logger.py +58 -0
- src/tinyllm/logit_compare.py +64 -0
- src/tinyllm/logship.py +220 -0
- src/tinyllm/model.py +245 -0
- src/tinyllm/obs.py +362 -0
- src/tinyllm/paged_kv.py +236 -0
- src/tinyllm/plots.py +294 -0
- src/tinyllm/provenance.py +237 -0
- src/tinyllm/quant.py +139 -0
- src/tinyllm/results.py +654 -0
- src/tinyllm/safetensors_io.py +90 -0
- src/tinyllm/server.py +1031 -0
- src/tinyllm/tokenizer.py +35 -0
- src/tinyllm/train.py +278 -0
- src/tinyllm/web/demo.html +250 -0
- src/tinyllm/web/status.html +401 -0
- tokenizer/tinystories-bpe8192.json +0 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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""
|
| 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
|
|
|