File size: 21,125 Bytes
d656a30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
"""Step 4.4: serving many requests: static batching vs continuous batching.

Workload model: offline, every request is queued at t=0 (no arrival process yet).

- run_static: take requests in order, B at a time. Left-pad the prompts, prefill once,
  decode until the batch's *longest* request is done. Rows that finish early keep
  computing (wasted work), and the next batch waits for the slowest row.
- run_continuous: S slots (SlotKVCache). Every step, queued requests are admitted into
  free slots (one batched, padded prefill for the newcomers), then every slot decodes
  one token together. A finished request frees its slot at once.

Both return per-request Results (generated tokens, TTFT, latency) and Stats (throughput,
wasted decode rows, KV memory reserved vs used). Correctness: each request's tokens must
equal its solo generate() output (tests/test_engine.py).
"""

import math
import time
from collections import deque
from dataclasses import dataclass, field

import torch
from torch import Tensor

from tinyllm.generate import sample_next
from tinyllm.kv_cache import KVCache, SlotKVCache
from tinyllm.model import Transformer


@dataclass(frozen=True)
class Sampling:
    """How to pick tokens (sample_next's settings). Frozen, so equal settings group together."""
    temperature: float = 0.0             # 0 = greedy
    top_k: int | None = None
    top_p: float | None = None

    def __post_init__(self):
        if not self.temperature >= 0:
            raise ValueError(f"temperature must be >= 0, got {self.temperature}")
        if self.top_k is not None and self.top_k < 1:
            raise ValueError(f"top_k must be >= 1, got {self.top_k}")
        if self.top_p is not None and not 0.0 < self.top_p <= 1.0:
            raise ValueError(f"top_p must be in (0, 1], got {self.top_p}")


@dataclass
class Request:
    prompt: list[int]
    max_new_tokens: int
    eos_id: int | None = None
    sampling: Sampling | None = None     # ContinuousEngine only; None = the engine's settings


@dataclass
class Result:
    tokens: list[int]          # generated tokens only (EOS included if it was produced)
    ttft_s: float              # engine start → first generated token
    latency_s: float           # engine start → last generated token


@dataclass
class Stats:
    wall_s: float = 0.0
    useful_tokens: int = 0     # generated tokens that belong to a request
    decode_steps: int = 0
    computed_rows: int = 0     # rows run through decode (incl. finished rows / free slots)
    useful_rows: int = 0       # rows whose output was kept
    kv_reserved_tokens: int = 0            # KV capacity held (max over the run)
    kv_used_frac: list[float] = field(default_factory=list)  # per decode step: real tokens / capacity
    kv_alloc_frac: list[float] = field(default_factory=list)  # per decode step: real tokens / claimed
    kv_bytes_per_token: int = 0
    preemptions: int = 0
    peak_active: int = 0
    kv_allocated_at_end: int = 0           # leak check: must be 0 once every request is done

    def summary(self, results: list[Result]) -> dict:
        lat = sorted(r.latency_s for r in results)
        ttft = sorted(r.ttft_s for r in results)
        p95 = lambda xs: xs[min(len(xs) - 1, math.ceil(0.95 * len(xs)) - 1)]
        return {
            "requests": len(results),
            "wall_s": self.wall_s,
            "useful_tokens": self.useful_tokens,
            "tok_s": self.useful_tokens / self.wall_s,
            "decode_steps": self.decode_steps,
            "row_util": self.useful_rows / max(1, self.computed_rows),
            "latency_mean_s": sum(lat) / len(lat),
            "latency_p95_s": p95(lat),
            "ttft_mean_s": sum(ttft) / len(ttft),
            "ttft_p95_s": p95(ttft),
            "kv_reserved_mib": self.kv_reserved_tokens * self.kv_bytes_per_token / 2**20,
            "kv_used_mean": sum(self.kv_used_frac) / max(1, len(self.kv_used_frac)),
            "kv_used_peak": max(self.kv_used_frac, default=0.0),
            "kv_alloc_used_mean": (sum(self.kv_alloc_frac) / len(self.kv_alloc_frac)
                                   if self.kv_alloc_frac else None),
            "preemptions": self.preemptions,
            "peak_active": self.peak_active,
            "kv_allocated_at_end": self.kv_allocated_at_end,
        }


def _autocast(device: torch.device):
    return torch.autocast(device.type, dtype=torch.bfloat16, enabled=device.type == "cuda")


def _kv_bytes_per_token(model: Transformer, device: torch.device) -> int:
    elem = 2 if device.type == "cuda" else 4          # bf16 under autocast, else fp32
    return sum(layer.self_attn.cache_bytes_per_token(elem) for layer in model.layers)


@torch.no_grad()
def prefill_padded(model: Transformer, prompts: list[list[int]], max_len: int,
                   pad_id: int = 0) -> tuple[Tensor, KVCache, list[int]]:
    """Left-pad prompts to one length, run one prefill. Returns last-position logits (B, V),
    the filled cache (pos = padded length) and each row's pad count."""
    device = next(model.parameters()).device
    L = max(len(p) for p in prompts)
    pad = [L - len(p) for p in prompts]
    idx = torch.tensor([[pad_id] * n + p for n, p in zip(pad, prompts)], device=device)
    cache = KVCache(model.cfg.n_layers, max_len, pad_lens=torch.tensor(pad, device=device))
    with _autocast(device):
        logits, _ = model(idx, cache=cache)
    return logits[:, -1], cache, pad


def _done(req: Request, toks: list[int]) -> bool:
    return len(toks) >= req.max_new_tokens or (req.eos_id is not None and toks[-1] == req.eos_id)


@torch.no_grad()
def run_static(model: Transformer, requests: list[Request], batch_size: int,
               temperature: float = 0.0, top_k=None, top_p=None, generator=None,
               ) -> tuple[list[Result], dict]:
    if any(r.sampling is not None for r in requests):
        raise ValueError("run_static uses one sampling setting; per-request sampling needs ContinuousEngine")
    model.eval()
    device = next(model.parameters()).device
    pick = dict(temperature=temperature, top_k=top_k, top_p=top_p, generator=generator)
    stats = Stats(kv_bytes_per_token=_kv_bytes_per_token(model, device))
    results: list[Result | None] = [None] * len(requests)
    t0 = time.perf_counter()
    for b0 in range(0, len(requests), batch_size):
        batch = requests[b0:b0 + batch_size]
        L = max(len(r.prompt) for r in batch)
        new_max = max(r.max_new_tokens for r in batch)
        if L + new_max > model.cfg.max_seq_len:
            raise ValueError(f"padded prompt {L} + new {new_max} exceeds max_seq_len")
        logits, cache, _ = prefill_padded(model, [r.prompt for r in batch], L + new_max)
        capacity = len(batch) * (L + new_max)
        stats.kv_reserved_tokens = max(stats.kv_reserved_tokens, capacity)
        outs: list[list[int]] = [[] for _ in batch]
        ttft = [0.0] * len(batch)
        done = [False] * len(batch)
        while True:
            nxt = sample_next(logits, **pick)
            toks = nxt.squeeze(-1).tolist()                      # GPU sync: tokens to the host
            now = time.perf_counter() - t0
            for j, (r, t) in enumerate(zip(batch, toks)):
                if done[j]:
                    continue
                outs[j].append(t)
                if len(outs[j]) == 1:
                    ttft[j] = now
                if _done(r, outs[j]):
                    done[j] = True
                    results[b0 + j] = Result(outs[j], ttft[j], now)
            if all(done):
                break
            live = [j for j in range(len(batch)) if not done[j]]
            stats.decode_steps += 1
            stats.computed_rows += len(batch)
            stats.useful_rows += len(live)
            stats.kv_used_frac.append(
                sum(len(batch[j].prompt) + len(outs[j]) for j in live) / capacity)
            with _autocast(device):
                logits, _ = model(nxt, cache=cache)
            logits = logits[:, -1]
    stats.wall_s = time.perf_counter() - t0
    stats.useful_tokens = sum(len(r.tokens) for r in results)
    return results, stats.summary(results)


@dataclass
class Event:
    """One generated token, as step() reports it (a server streams these)."""
    rid: int
    token: int
    done: bool                 # the request finished with this token (EOS or max_new_tokens)


class ContinuousEngine:
    """Step 4.13: run_continuous's loop as an object, so requests can arrive while others
    decode. add_request() queues a request at any time; each step() admits queued requests
    into free slots (one padded prefill), decodes one token for every active slot and
    returns the new tokens as Events. A finished request's Result is in `results` (pop it).

    Times are seconds on the engine clock (since construction). TTFT and latency count from
    the request's arrival, so with every request added at once they match 4.4's numbers.
    Sampling: the engine's settings are the default; a request's `sampling` overrides them.
    Rows with equal settings are sampled in one call, so when every row uses the same
    settings the result (and RNG use) is exactly as with per-engine settings. One generator
    is shared by all rows. Not thread-safe: call add_request / step / abort from one thread.

    kv="slots": SlotKVCache, every slot reserves max_len positions (4.4).
    kv="paged": PagedKVCache with kv_blocks blocks of block_size tokens (4.5). Default
    kv_blocks = the same memory as the slot cache. Sequences beyond what the blocks hold are
    preempted and recomputed later.
    cuda_graph (4.11b, CUDA, kv="slots" only): the decode step is replayed as a CUDA graph per
    attention-window bucket (cuda_graph.SlotGraphDecoder); prefill stays eager. True builds a
    decoder for this engine; pass a SlotGraphDecoder to reuse its cache and captured graphs.
    """

    def __init__(self, model: Transformer, n_slots: int, max_len: int | None = None,
                 temperature: float = 0.0, top_k=None, top_p=None, generator=None,
                 kv: str = "slots", block_size: int = 16, kv_blocks: int | None = None,
                 cuda_graph=False, buckets: tuple[int, ...] | None = None):
        model.eval()
        cfg = model.cfg
        self.model = model
        self.device = device = next(model.parameters()).device
        self.n_slots = n_slots
        self.max_len = max_len = max_len or cfg.max_seq_len
        self.sampling = Sampling(temperature, top_k, top_p)    # the default for every request
        self.generator = generator
        dtype = torch.bfloat16 if device.type == "cuda" else torch.float32
        if cuda_graph is not False and kv != "slots":
            raise ValueError("cuda_graph supports kv='slots' only (paged: not yet)")
        self.decoder = None
        if cuda_graph is not False and not isinstance(cuda_graph, bool):   # a SlotGraphDecoder
            self.decoder, slots = cuda_graph, cuda_graph.slots
            if slots.n_slots != n_slots or slots.max_len != max_len or any(slots.active):
                raise ValueError(f"decoder cache has {slots.n_slots} slots of {slots.max_len} "
                                 f"(active: {sum(slots.active)}); need {n_slots} idle slots of {max_len}")
        elif kv == "slots":
            slots = SlotKVCache(cfg.n_layers, n_slots, cfg.n_kv_heads, cfg.head_dim, max_len, dtype, device)
        elif kv == "paged":
            from tinyllm.paged_kv import PagedKVCache
            n_blocks = kv_blocks or n_slots * math.ceil(max_len / block_size)
            slots = PagedKVCache(cfg.n_layers, n_slots, cfg.n_kv_heads, cfg.head_dim, n_blocks,
                                 block_size, max_len, dtype, device)
        else:
            raise ValueError(f"kv must be 'slots' or 'paged', got {kv!r}")
        if cuda_graph is True:
            from tinyllm.cuda_graph import SlotGraphDecoder
            self.decoder = SlotGraphDecoder(model, slots, buckets=buckets)
        self.slots = slots
        self.stats = Stats(kv_bytes_per_token=_kv_bytes_per_token(model, device),
                           kv_reserved_tokens=slots.reserved_tokens())
        self.results: dict[int, Result] = {}              # finished, until the caller pops them
        self._req: dict[int, Request] = {}                # unfinished requests only
        self._arrival: dict[int, float] = {}
        self._outs: dict[int, list[int]] = {}
        self._ttft: dict[int, float] = {}
        self._slot_req: list[int | None] = [None] * n_slots
        self._last_tok = torch.zeros(n_slots, 1, dtype=torch.long, device=device)
        self._queue: deque[int] = deque()
        self._next_id = 0
        self._t0 = time.perf_counter()

    # -- public API ------------------------------------------------------------
    def now(self) -> float:
        return time.perf_counter() - self._t0

    def add_request(self, req: Request) -> int:
        """Queue a request; returns its id. Raises at once if it can never fit a slot."""
        if not req.prompt or req.max_new_tokens < 1:
            raise ValueError("a request needs a non-empty prompt and max_new_tokens >= 1")
        if len(req.prompt) + req.max_new_tokens > self.max_len:
            raise ValueError(f"prompt {len(req.prompt)} + new {req.max_new_tokens} "
                             f"exceeds slot max_len {self.max_len}")
        rid, self._next_id = self._next_id, self._next_id + 1
        self._req[rid] = req
        self._arrival[rid] = self.now()
        self._queue.append(rid)
        return rid

    def has_work(self) -> bool:
        return bool(self._queue) or any(self.slots.active)

    def load(self) -> tuple[int, int]:
        """(requests waiting for a slot, slots decoding a request)."""
        return len(self._queue), sum(self.slots.active)

    def kv_used_frac(self) -> float:
        return self.slots.used_tokens() / max(1, self.slots.reserved_tokens())

    def abort(self, rid: int) -> bool:
        """Drop an unfinished request (e.g. its client went away), freeing its slot.
        Returns False if it is unknown or already finished. No Result is kept."""
        if rid not in self._req:
            return False
        if rid in self._queue:
            self._queue.remove(rid)
        if rid in self._slot_req:
            s = self._slot_req.index(rid)
            self._slot_req[s] = None
            self.slots.release(s)
        self._forget(rid)
        return True

    @torch.no_grad()
    def step(self) -> list[Event]:
        """Admit what fits, decode one token per active slot. Returns the new tokens."""
        events: list[Event] = []
        slots = self.slots
        free = slots.free_slots()
        rids: list[int] = []
        if free and self._queue:                              # admit newcomers: one padded prefill
            q = self._queue
            if not self._fits_padded([q[0]]):
                r = q[0]
                raise ValueError(f"request {r}: prompt {len(self._context(r))} + new {self._left(r)} "
                                 f"exceeds slot max_len {self.max_len}")
            while (q and len(rids) < len(free) and self._fits_padded(rids + [q[0]])
                   and slots.can_admit([len(self._context(r)) for r in rids + [q[0]]])):
                rids.append(q.popleft())
            if not rids and not any(slots.active):
                raise ValueError(f"request {q[0]} does not fit in the KV cache even alone")
        if rids:
            prompts = [self._context(r) for r in rids]
            L = max(len(p) for p in prompts)
            logits, pre, pad = prefill_padded(self.model, prompts, max_len=L)
            nxt = self._sample(logits, [self._sampling(r) for r in rids])
            taken = free[:len(rids)]
            slots.admit(taken, pre, pad)
            self._last_tok[torch.tensor(taken, device=self.device)] = nxt
            toks = nxt.squeeze(-1).tolist()
            now = self.now()
            for s, rid, t in zip(taken, rids, toks):
                self._slot_req[s] = rid
                self._outs.setdefault(rid, [])
                events.append(self._record(s, rid, t, now))
        for s in slots.reserve_step():                        # paged: out of blocks → preempt newest
            self._queue.appendleft(self._slot_req[s])         # recompute later from its context
            self._slot_req[s] = None
            self.stats.preemptions += 1
        if not any(slots.active):
            return events
        st = self.stats
        live = [s for s in range(self.n_slots) if slots.active[s]]
        st.peak_active = max(st.peak_active, len(live))
        st.decode_steps += 1
        st.computed_rows += self.n_slots
        st.useful_rows += len(live)
        used = slots.used_tokens()
        st.kv_used_frac.append(used / slots.reserved_tokens())
        st.kv_alloc_frac.append(used / max(1, slots.allocated_tokens()))
        if self.decoder is not None:
            logits = self.decoder.step(self._last_tok)        # static buffer: sample at once
        else:
            with _autocast(self.device):
                logits, _ = self.model(self._last_tok, cache=slots)   # one token per slot
        rows = [self._sampling(r) if r is not None else self.sampling for r in self._slot_req]
        self._last_tok = self._sample(logits[:, -1], rows)    # idle rows: engine default
        toks = self._last_tok.squeeze(-1).tolist()
        now = self.now()
        for s in live:
            events.append(self._record(s, self._slot_req[s], toks[s], now))
        return events

    # -- internals -------------------------------------------------------------
    def _context(self, rid: int) -> list[int]:
        """Prompt plus tokens generated so far (non-empty only after a preemption)."""
        return self._req[rid].prompt + self._outs.get(rid, [])

    def _sampling(self, rid: int) -> Sampling:
        return self._req[rid].sampling or self.sampling

    def _sample(self, logits: Tensor, rows: list[Sampling]) -> Tensor:
        """Next token per row (B, V) → (B, 1), each row with its own settings. One
        sample_next call per distinct setting, in order of first appearance."""
        groups: dict[Sampling, list[int]] = {}
        for i, s in enumerate(rows):
            groups.setdefault(s, []).append(i)
        if len(groups) == 1:
            (s,) = groups
            return sample_next(logits, s.temperature, s.top_k, s.top_p, self.generator)
        out = torch.empty(logits.size(0), 1, dtype=torch.long, device=logits.device)
        for s, idx in groups.items():
            ix = torch.tensor(idx, device=logits.device)
            out[ix] = sample_next(logits[ix], s.temperature, s.top_k, s.top_p, self.generator)
        return out

    def _left(self, rid: int) -> int:
        return self._req[rid].max_new_tokens - len(self._outs.get(rid, []))

    def _fits_padded(self, rids: list[int]) -> bool:
        """Every row of a padded prefill sits at positions up to L + its remaining tokens, so
        that must fit max_len (the RoPE table, and slot storage). A preempted request comes back
        with a longer context, so this can fail for a batch even when each request fits alone."""
        return max(len(self._context(r)) for r in rids) + max(self._left(r) for r in rids) <= self.max_len

    def _record(self, s: int, rid: int, tok: int, now: float) -> Event:
        outs = self._outs[rid]
        outs.append(tok)
        if len(outs) == 1:
            self._ttft[rid] = now
        done = _done(self._req[rid], outs)
        if done:
            t_in = self._arrival[rid]
            self.results[rid] = Result(outs, self._ttft[rid] - t_in, now - t_in)
            self.stats.useful_tokens += len(outs)
            self._slot_req[s] = None
            self.slots.release(s)
            self._forget(rid)
        return Event(rid, tok, done)

    def _forget(self, rid: int) -> None:
        for d in (self._req, self._arrival, self._outs, self._ttft):
            d.pop(rid, None)


@torch.no_grad()
def run_continuous(model: Transformer, requests: list[Request], n_slots: int,
                   max_len: int | None = None, temperature: float = 0.0, top_k=None,
                   top_p=None, generator=None, kv: str = "slots", block_size: int = 16,
                   kv_blocks: int | None = None, cuda_graph=False,
                   buckets: tuple[int, ...] | None = None) -> tuple[list[Result], dict]:
    """Offline continuous batching (4.4): every request queued at t=0, run to completion.
    A thin wrapper over ContinuousEngine; see it for kv / cuda_graph."""
    eng = ContinuousEngine(model, n_slots, max_len, temperature, top_k, top_p, generator,
                           kv, block_size, kv_blocks, cuda_graph, buckets)
    t0 = time.perf_counter()
    rids = [eng.add_request(r) for r in requests]
    while eng.has_work():
        eng.step()
    st = eng.stats
    st.kv_allocated_at_end = eng.slots.allocated_tokens()
    st.wall_s = time.perf_counter() - t0
    results = [eng.results.pop(r) for r in rids]
    return results, st.summary(results)