Skip to content

Commit 1269c01

Browse files
committed
Execution counters, MR3 to depth 3, MR9 retired
Three times a path has looked tested and never executed: a corpus that never reached a second attention split, a chunk-size row that never chunked, and an admission check that made the eviction path unreachable. Each was caught by noticing afterwards. engine/audit/counters.py makes it a mechanism: 26 declared paths, incremented by the engine, and require_fired() turns a relation that passes without firing its own mechanism into a failure. It generalizes what MR3 and MR4 were already doing by hand. The counters are also the fuzzer's coverage substrate, so the coverage report and the vacuity guards read the same numbers rather than two parallel sets. MR3 now sweeps two dimensions: the preemption point across every decode step at depth 1, and depths 2 and 3 explicitly. Depth matters because preempt-resume- preempt is where allocator state gets interesting, a block freed by the first preemption can be handed to another request and then be needed again by the second. Depth 3 never being reached fails the relation. MR9 is retired in the architecture doc with its reasoning rather than left listed as pending: it assumes an explicit pad region and this engine packs sequences, so there is nothing to mask. MR5 covers the occupancy intent.
1 parent 7dde770 commit 1269c01

6 files changed

Lines changed: 217 additions & 25 deletions

File tree

docs/02-technical-architecture.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,14 +144,14 @@ The alphabet in section 2, constrained by feasibility rules (cannot resume a req
144144

145145
Three components, all reported. Coverage is what proves exploration rather than sampling the easy middle.
146146

147-
1. **Lifecycle n-grams.** All feasible 2-grams and 3-grams of per-request events, reported as a percentage of the feasible set
147+
1. **Lifecycle n-grams.** All feasible 2-grams and 3-grams of per-request events, reported as a percentage of the feasible set. The feasible set is *derived* in `engine/sched/lifecycle.py` by search over a declared transition relation, never hand-listed: a hand-written set can reach 100 percent by having omitted the hard transitions and nothing in the number would show it. Every n-gram a real run produces is checked against the derived set, so a transition the table forgot fails loudly rather than being silently dropped
148148
2. **Boundary predicates**, each required at value, value minus 1, and value plus 1:
149149
- chunk end versus block size
150150
- cache-hit length versus block size (the live SGLang bug is literally `prefix_len == block_size`; make this the poster child)
151151
- split boundary versus KV length
152152
- batch size transitions across {1, 2, 3, 4, 8, 16, 31, 32}
153153
- free-block count in {0, 1, low}
154-
3. **Preemption depth** per request, up to 3
154+
3. **Preemption depth** per request, up to 3. MR3 sweeps the preemption point across every decode step at depth 1, and drives depths 2 and 3 explicitly, because preempt-resume-preempt is where allocator state gets interesting: a block freed by the first preemption can be reallocated to another request and then be needed again by the second
155155

156156
### 8.3 Generation strategy
157157

@@ -173,9 +173,9 @@ ddmin (Zeller and Hildebrandt, IEEE TSE 2002) applied in three staged passes: re
173173
| MR6 | Replay. Identical inputs twice, identical trajectory hash |
174174
| MR7 | RNG isolation. Deleting request A leaves request B's tokens unchanged |
175175
| MR8 | Temperature-0 tie-break. Argmax stable under logit-preserving permutations of the batch |
176-
| MR9 | Mask no-op, fidelity-side. A fully-masked pad region changes nothing within tolerance |
176+
| MR9 | ~~Mask no-op, fidelity-side~~. **Retired.** The relation assumes an explicit pad region, and this engine packs sequences rather than padding them, so there is no masked region to be a no-op over. MR5 covers the occupancy intent: a cohabitant that only shifts where the packed token count lands against the GEMM tile must leave other requests bitwise unchanged |
177177

178-
MR1 through MR8 run both in-process and black-box. MR9 is in-process only.
178+
MR1 through MR8 run both in-process and black-box. MR9 is retired; see its row.
179179

180180
## 10. Mutation testing
181181

engine/audit/counters.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Execution counters over every path under claim.
2+
3+
Three times now a path has looked tested and never executed: a corpus that never
4+
reached a second attention split, a chunk-size row that never chunked, and an
5+
admission check that made the eviction path unreachable. Each was caught by
6+
noticing afterwards. This makes it a mechanism.
7+
8+
Every relation asserts not only its outcome but that the paths it exists to
9+
exercise had nonzero counts. A relation that passes without firing its own
10+
mechanism is a failure, not a pass.
11+
12+
The counters are also the fuzzer's coverage substrate, so the coverage report and
13+
the vacuity guards read the same numbers. A coverage denominator derived from
14+
somewhere else than the thing being counted is how a coverage number ends up
15+
describing a denominator someone chose.
16+
17+
Counters are observation only. They are incremented after a decision is taken,
18+
never read by one, and nothing in a kernel config path can see them: a counter
19+
that fed back into a decision would be a batch-derived quantity by another name.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
from collections import Counter as _Counter
25+
from dataclasses import dataclass, field
26+
27+
# Every path under claim, with the one-line statement of what firing it means.
28+
# The fuzzer's coverage report enumerates this dict rather than a hand-written
29+
# list, so a path added here is covered by both without a second edit.
30+
PATHS: dict[str, str] = {
31+
# admission and lifecycle
32+
"admit": "a waiting request was admitted",
33+
"admit_refused": "the policy declined to admit a waiting request",
34+
"finish": "a request reached its limit or an EOS and released its blocks",
35+
# prefill and chunking
36+
"prefill_chunk": "a partial prefill chunk was computed",
37+
"prefill_complete": "a prefill finished and the request began decoding",
38+
"decode_step": "a single-token decode step was computed",
39+
"chunk_boundary_mid_block": "a chunk ended part-way through a KV block",
40+
"chunk_boundary_mid_split": "a chunk ended part-way through an attention split",
41+
"chunk_boundary_on_block": "a chunk ended exactly on a block boundary",
42+
# attention shape
43+
"attention_multi_split": "attention ran with two or more splits",
44+
"attention_single_split": "attention ran within one split",
45+
# preemption
46+
"preempt_fired": "a running request was preempted for recompute",
47+
"preempt_depth_2": "a request was preempted for the second time",
48+
"preempt_depth_3_plus": "a request was preempted a third time or beyond",
49+
"resume": "a preempted request was re-admitted and recomputed",
50+
# prefix cache
51+
"cache_hit": "a prefix hit was found and honoured",
52+
"cache_hit_refused": "a prefix hit was found and the policy declined it",
53+
"cache_miss": "a lookup found no usable prefix",
54+
"cache_insert": "whole blocks were indexed after a request finished",
55+
"cache_full_prompt_trimmed": "a whole-prompt hit gave its last block back",
56+
# allocator
57+
"eviction_taken": "a cached block was reclaimed under pressure",
58+
"eviction_pass": "one pass of the reserve-with-eviction loop",
59+
"cow_performed": "a shared block was copied on write",
60+
"block_reclaimed_at_zero": "a block's last reference went away and it was freed",
61+
"out_of_blocks": "the pool could not satisfy a reservation",
62+
}
63+
64+
65+
@dataclass
66+
class Counters:
67+
"""Per-run execution counts. Created by the Scheduler, shared with the pool.
68+
69+
Not a module global: a global would make one test's counts visible to the
70+
next and would not survive the cross-process replay, where two interpreters
71+
must produce identical trajectories.
72+
"""
73+
74+
counts: _Counter = field(default_factory=_Counter)
75+
76+
def hit(self, path: str, amount: int = 1) -> None:
77+
if path not in PATHS:
78+
raise KeyError(
79+
f"{path!r} is not a declared path. Add it to PATHS with a "
80+
"one-line statement of what firing it means, so the coverage "
81+
"report enumerates it too."
82+
)
83+
self.counts[path] += amount
84+
85+
def __getitem__(self, path: str) -> int:
86+
return self.counts.get(path, 0)
87+
88+
def fired(self, *paths: str) -> bool:
89+
return all(self.counts.get(path, 0) > 0 for path in paths)
90+
91+
def missing(self, *paths: str) -> list[str]:
92+
"""Which of `paths` never fired. What a vacuity guard reports."""
93+
return [path for path in paths if self.counts.get(path, 0) == 0]
94+
95+
def merge(self, other: "Counters") -> None:
96+
self.counts.update(other.counts)
97+
98+
def as_dict(self) -> dict[str, int]:
99+
return {path: self.counts.get(path, 0) for path in PATHS if self.counts.get(path, 0)}
100+
101+
def summary(self) -> str:
102+
live = self.as_dict()
103+
if not live:
104+
return "no paths fired"
105+
width = max(len(path) for path in live)
106+
return "\n".join(f" {path:<{width}} {count}" for path, count in sorted(live.items()))
107+
108+
109+
class VacuousRun(AssertionError):
110+
"""A relation passed without firing the paths it exists to exercise."""
111+
112+
113+
def require_fired(counters: Counters, *paths: str, what: str = "this relation") -> None:
114+
"""Assert the mechanism actually ran. The generalization of the hand checks.
115+
116+
MR3 reported "7 of 10 actually preempted" and MR4 reported "12 of 12
117+
registering real hits" by hand. This is that, for every relation.
118+
"""
119+
missing = counters.missing(*paths)
120+
if missing:
121+
raise VacuousRun(
122+
f"{what} passed without firing: {', '.join(missing)}. "
123+
"A relation that never executes its own mechanism is not evidence. "
124+
f"Paths that did fire: {sorted(counters.as_dict())}"
125+
)

engine/cache/prefix.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class PrefixCache:
6363
"""
6464

6565
block_size: int
66+
counters: object = None
6667
entries: dict[bytes, CacheEntry] = field(default_factory=dict)
6768
stats: dict = field(
6869
default_factory=lambda: {"lookups": 0, "hit_blocks": 0, "inserts": 0, "evictions": 0}
@@ -102,6 +103,8 @@ def lookup(self, tokens: list[int]) -> tuple[int, list[int]]:
102103
entry.hits += 1
103104
blocks.append(entry.physical_block)
104105
self.stats["hit_blocks"] += len(blocks)
106+
if self.counters is not None:
107+
self.counters.hit("cache_hit" if blocks else "cache_miss")
105108
return len(blocks) * self.block_size, blocks
106109

107110
def insert(self, tokens: list[int], block_ids: list[int], pool) -> int:
@@ -122,6 +125,8 @@ def insert(self, tokens: list[int], block_ids: list[int], pool) -> int:
122125
pool.pin(physical)
123126
created += 1
124127
self.stats["inserts"] += created
128+
if created and self.counters is not None:
129+
self.counters.hit("cache_insert", created)
125130
return created
126131

127132
def evictable_blocks(self, pool) -> list[int]:
@@ -145,6 +150,8 @@ def evict(self, physical_block: int, pool) -> bool:
145150
del self.entries[key]
146151
pool.unpin(physical_block)
147152
self.stats["evictions"] += 1
153+
if self.counters is not None:
154+
self.counters.hit("eviction_taken")
148155
return True
149156
return False
150157

engine/kv/paged.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
import torch
3737

38+
from engine.audit.counters import Counters
3839
from engine.kernels import registry
3940

4041
# Block size is configuration, not a constant, and it is parameterized now while
@@ -98,7 +99,9 @@ def __init__(
9899
dtype: torch.dtype = torch.float16,
99100
poison_on_free: bool = True,
100101
block_size: int = DEFAULT_BLOCK_SIZE,
102+
counters: Counters | None = None,
101103
):
104+
self.counters = counters if counters is not None else Counters()
102105
if registry.KV_TILE % block_size != 0:
103106
raise ValueError(
104107
f"block_size {block_size} does not divide the KV tile "
@@ -138,6 +141,7 @@ def free_blocks(self) -> int:
138141

139142
def _take_block(self) -> int:
140143
if not self._free:
144+
self.counters.hit("out_of_blocks")
141145
raise OutOfBlocks(
142146
f"all {self.num_blocks} blocks are held; the policy must evict or "
143147
"preempt before asking for more"
@@ -158,6 +162,7 @@ def _release_block(self, block: int) -> None:
158162
self.v[layer][block].fill_(POISON)
159163
heapq.heappush(self._free, block)
160164
self.stats["freed"] += 1
165+
self.counters.hit("block_reclaimed_at_zero")
161166

162167
# -- sequences -----------------------------------------------------------
163168

@@ -246,6 +251,7 @@ def ensure_writable(self, uid: str, logical_block: int) -> int:
246251
sequence.block_ids[logical_block] = new
247252
self.refcount[old] -= 1
248253
self.stats["cow_copies"] += 1
254+
self.counters.hit("cow_performed")
249255
return new
250256

251257
# -- views ---------------------------------------------------------------

engine/sched/policy.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,31 @@ def should_preempt(self, state: SchedulerState, uid: str) -> bool:
258258
return True
259259

260260

261+
class PreemptAtStepsPolicy(DefaultPolicy):
262+
"""Preempt one request at several named steps, for depth sweeps.
263+
264+
Preemption depth is a coverage dimension in architecture doc 8.2, up to 3.
265+
Depth matters because preempt-resume-preempt is where allocator state gets
266+
interesting: a block freed by the first preemption can be reallocated to
267+
another request and then be needed again by the second.
268+
"""
269+
270+
def __init__(self, uid: str, at_steps: tuple[int, ...], max_running: int = 32):
271+
super().__init__(max_running=max_running)
272+
self.uid = uid
273+
self.at_steps = set(at_steps)
274+
self.fired_at: list[int] = []
275+
self.name = f"preempt({uid}@{sorted(at_steps)})"
276+
277+
def should_preempt(self, state: SchedulerState, uid: str) -> bool:
278+
if uid != self.uid or state.step not in self.at_steps:
279+
return False
280+
if state.step in self.fired_at:
281+
return False
282+
self.fired_at.append(state.step)
283+
return True
284+
285+
261286
class NoCacheHitPolicy(DefaultPolicy):
262287
"""Refuse every prefix hit. The cold half of MR4."""
263288

0 commit comments

Comments
 (0)