Skip to content

Commit c497e07

Browse files
committed
docs(flowcontrol): add in-flight eviction design proposal
Design proposal for demand-driven in-flight eviction (issue llm-d#1119): a reclamation controller that revokes sheddable leases when higher-priority demand is blocked by pool saturation, with confirmation-gated pacing and sizing validated in closed-loop simulation. Accompanied by the exploratory capacity-ledger north star that the eviction design is the scalar projection of. Signed-off-by: Luke Van Drie <lukevandrie@google.com>
1 parent 8ab3cc9 commit c497e07

2 files changed

Lines changed: 782 additions & 0 deletions

File tree

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
# Flow Control North Star: The Capacity Ledger
2+
3+
Status: **exploratory north star**. This document records direction, not commitment. Each major
4+
section carries a confidence label:
5+
6+
- **Proposed**: settled shape; implementation may begin against it.
7+
- **Directional**: the argument is made and the seam is fixed, but the mechanism behind the seam
8+
is expected to evolve.
9+
- **Open**: known problem, no chosen answer.
10+
11+
Related: [`flow-control-eviction.md`](flow-control-eviction.md). The v1 eviction design is the
12+
scalar projection of this design; its controller survives this redesign with type upgrades only.
13+
14+
## Summary
15+
16+
Flow control today reasons about pool capacity through a single delayed scalar (the saturation
17+
gauge). This document proposes replacing that with a **capacity ledger**: every request is modeled
18+
as a multi-dimensional resource **footprint**, held as a **lease** against per-endpoint ledgers
19+
that roll up to a pool ledger. Admission, holdback, and eviction all become bookkeeping operations
20+
against the ledger rather than threshold comparisons against a gauge.
21+
22+
The engine's own scheduler already runs this accounting model inside each replica. This design
23+
raises it to pool scope, the one place the engine cannot.
24+
25+
The design also unifies the QoS story: tiers are admission against different confidence levels of
26+
the same ledger. Guaranteed traffic reserves against the pessimistic bound; sheddable traffic is
27+
statistically multiplexed against the expected value; and revocation (eviction) is the enforcement
28+
mechanism that makes the overcommit safe.
29+
30+
## Motivation: what a scalar gauge cannot do
31+
32+
*(Proposed.)*
33+
34+
Recurring capacity-management defects in the flow control layer trace to the same root:
35+
heterogeneous, multi-dimensional, lifecycle-varying resource claims are collapsed into one delayed
36+
dimensionless number.
37+
38+
- **Eviction sizing** ([#1119](https://github.com/llm-d/llm-d-router/issues/1119)): "how many
39+
requests must be evicted" is unanswerable in gauge units. Requests have unrelated footprints,
40+
and the gauge reflects an eviction only after abort, GC, and a scrape. The v1 eviction design
41+
works around this with a mean-footprint estimate and pending-reclaim debits, a deliberately
42+
crude scalar shadow of this design.
43+
- **Holdback stranding**: ceilings reserve a fraction of a gauge, not capacity for a class of
44+
footprints; the reserve cannot be sized to expected burst demand.
45+
- **Token-mode under-count**: in-flight token accounting that releases at first-token conflates
46+
the prefill-compute claim (which does end at TTFT) with the KV-residency claim (which does not),
47+
admitting far past capacity during decode-heavy load. The bug is a lifecycle distinction the
48+
scalar cannot express.
49+
- **Admission/scheduling interference**: gauges built from means mask per-endpoint skew; gating on
50+
a pool mean shapes the load scorers see and can hold the system at pathological equilibria.
51+
52+
The remedy is to account in the units the hardware enforces.
53+
54+
## The resource model
55+
56+
*(Proposed: the residency axes and the lifecycle split. Directional: the prefill axis. Open: the
57+
shared-resource extension.)*
58+
59+
### Why these axes
60+
61+
Continuous-batching inference has three distinct per-request physical bottlenecks, each with a
62+
distinct saturation mode and a distinct lifecycle. These are the ledger's vector dimensions;
63+
shared, non-additive resources (see LoRA below) are handled outside the vector.
64+
65+
| Axis | Physical bottleneck | Saturates as | Lifecycle |
66+
|---|---|---|---|
67+
| `Prefill` (tokens) | SM compute: prompt evaluation is high-intensity GEMM over the input | TTFT spikes; decode preemption | **Transient**: claimed at admission, released the moment the first decode step is scheduled (TTFT) |
68+
| `Residency.KVTokens` | VRAM capacity: KV history storage (PagedAttention blocks) | OOM / swap thrashing | **Persistent**: claimed at admission, released at end of stream |
69+
| `Residency.Slots` | HBM bandwidth: decode is memory-bound; weights stream per active sequence per token | TPOT degradation; scheduler queue saturation (`max_num_seqs`) | **Persistent**: as above |
70+
71+
The transient/persistent split is the central modeling decision. Prefill compute and KV residency
72+
have different release events and different failure modes; any accounting that merges them is
73+
wrong in one direction or the other. The token-mode under-count above is this error observed in
74+
production configuration.
75+
76+
The two residency axes have hardware-enforced stock limits (the block pool; `max_num_seqs`), which
77+
is what earns them the Proposed label. The prefill axis is different in kind: prefill compute is a
78+
rate, not a stock, and treating chunked-prefill tokens as inventory claimed against a pool-level
79+
limit leaves that limit's semantics and units undefined. The axis is therefore Directional: the
80+
lifecycle split it encodes is settled, but its limit model is not.
81+
82+
### Types
83+
84+
Two representations, one translation seam:
85+
86+
```go
87+
// Prediction carries the per-request quantities the translation is computed from.
88+
type Prediction struct {
89+
PromptTokens int64 // ISL, known at admission
90+
OutputTokens int64 // predicted OSL (see Stochastic layer)
91+
CachedTokens int64 // prefix-cache hit, known at scheduling; zero at admission
92+
Branching int64 // decode width (best_of, beam, n)
93+
BlockSize int64 // engine KV block size
94+
ChunkSize int64 // engine chunked-prefill cap
95+
}
96+
97+
// Footprint is the hardware-agnostic resource claim of one request. Pool-level
98+
// admission reasons exclusively in these units.
99+
type Footprint struct {
100+
Prefill int64 // transient: unshared prompt tokens, capped by chunked-prefill size
101+
Residency Residency // persistent: held until end of stream
102+
}
103+
104+
type Residency struct {
105+
KVTokens int64 // (prompt - cached prefix) + predicted output * branching
106+
Slots int64 // concurrent sequences (branching factor; maps to engine max_num_seqs)
107+
}
108+
109+
// EngineFootprint is the engine-specific physical claim on one replica: block-granular,
110+
// fragmentation- and copy-on-write-aware. Endpoint-level commits use these units.
111+
type EngineFootprint struct {
112+
Prefill int64
113+
Residency EngineResidency // KVBlocks, Slots
114+
}
115+
116+
type Translator interface {
117+
ToFootprint(p Prediction) Footprint
118+
ToEngineFootprint(p Prediction) EngineFootprint
119+
}
120+
```
121+
122+
Design rules:
123+
124+
- **The Translator is a calibrated estimator.** Block-exact math (copy-on-write duplication,
125+
fragmentation, prefix-boundary rounding) couples the router to engine-version internals with no
126+
contract. Estimates carry error acknowledged by the reconciliation layer; the long-term fix is
127+
an engine API reporting actual per-request block usage (see Engine co-design).
128+
- **Underflow is an error.** Footprints support coordinate-wise `Add`/`Sub`; underflow is
129+
surfaced as ledger corruption rather than silently clamped. Correctness comes from zero-sum
130+
discipline (a lease releases exactly what it committed).
131+
- **Shared resources stay out of the vector** *(Open)*. LoRA adapter slots are set-union-scoped
132+
(the first request pays, co-tenants ride free), which breaks per-request additivity. They
133+
require a reference-counted side ledger, not a fourth coordinate.
134+
135+
## The ledger architecture
136+
137+
*(Proposed.)*
138+
139+
### Hold, then lease
140+
141+
Admission is a two-phase reservation protocol:
142+
143+
```
144+
TryAcquireHold(footprint) Commit(endpoint, engineFootprint)
145+
request -------------------------> HOLD ----------------------------------> LEASE
146+
| TTL expiry / cancel |
147+
v | ReleasePrefill (TTFT)
148+
dropped v
149+
LEASE (residency only)
150+
| Release (EOS) natural
151+
| Revoke (eviction) forced
152+
v
153+
reclaiming --> reclaimed
154+
```
155+
156+
- A **hold** is a tentative, TTL-bounded, endpoint-unbound reservation taken before scheduling, in
157+
`Footprint` units. Holds exist only for the scheduling window (from the admission decision to
158+
commit or cancellation), not for queued requests, so the holds table stays small and a deep
159+
queue cannot zero out available capacity. Holds close the admit-then-schedule race at pool
160+
granularity: capacity checked at admission cannot be double-promised while scheduling runs. The
161+
race is not closed at endpoint granularity (two holds can each pass the fit check against the
162+
same lone endpoint); that residual is caught at commit time. TTL expiry cancels the admission
163+
and the request is rejected to the client, the same outcome as failing the admission check: the
164+
TTL reclaims capacity from scheduling stalls rather than acting as a queueing mechanism.
165+
- A **lease** is the committed claim, bound to an endpoint, in `EngineFootprint` units. The
166+
*escalation guard* enforces `commit <= hold` per dimension, compared in logical units: the
167+
committed footprint's blocks are converted at the endpoint's block size, rounding the committed
168+
side up, so the guard is typed consistently at the logical/physical boundary and rounding can
169+
never excuse an escalation. Scheduling may not discover a larger footprint than admission
170+
approved.
171+
- **Two release events**, per the lifecycle split: `ReleasePrefill` at TTFT frees the transient
172+
axis; `Release` at end of stream frees residency. **Revocation** (eviction) is a forced release:
173+
the same ledger operation with a different initiator, entering the same reclaiming/reclaimed
174+
accounting (released by the EPP, not yet acknowledged freed by the engine).
175+
- Pool admission requires both an **aggregate check** (pool-wide available capacity covers the
176+
footprint) and a **fit check** (at least one healthy endpoint can hold it). Aggregate room with
177+
no single endpoint able to fit the request is not admissible capacity.
178+
179+
### Endpoint and pool ledgers
180+
181+
- `EndpointLedger`: the deterministic map of committed leases on one replica, plus the
182+
reclaiming-state accounting for released-but-unacknowledged capacity. Hot-path reads are
183+
lock-free snapshots.
184+
- `PoolLedger`: registration/draining of endpoints, the holds table, and the roll-up:
185+
`Available = sum(limits) - sum(committed) - sum(holds) - sum(reclaiming)`.
186+
- Every consumer that today reads the saturation gauge becomes a view over the ledger: the
187+
dispatch gate asks "does the head request's hold fit"; holdback reserves footprint-denominated
188+
headroom per tier; the eviction controller computes a per-dimension deficit from a hold-fit
189+
failure. The `SaturationDetector` abstraction survives as a derived, backwards-compatible view
190+
(saturation approximately equals the max over dimensions of used/limit), not as the source of
191+
truth.
192+
193+
### What this does to the eviction controller
194+
195+
Nothing structural; that invariance is the design goal (see `flow-control-eviction.md`). Three
196+
type upgrades:
197+
198+
| v1 (scalar) | Ledger world |
199+
|---|---|
200+
| `deficit = saturation - ceiling` | per-dimension deficit: `blocked hold's Footprint - Available` |
201+
| `credit = saturation / leases` (mean estimate) | exact per-lease `EngineFootprint` from the ledger |
202+
| pending-reclaim debits (controller-local) | the ledger's reclaiming-state accounting |
203+
204+
Victim selection graduates from heap-order to subset selection: choose the minimum-waste set of
205+
revocable leases whose footprints cover the deficit vector (`VictimSelector(candidates, deficit)`).
206+
207+
## Reconciliation: two sources of truth
208+
209+
*(Directional. The seam and the argument are fixed; the estimator behind the seam is not.)*
210+
211+
The ledger is a predicted view (footprints are estimates over predicted output lengths); scraped
212+
engine telemetry is a delayed view of reality. Something must close the loop. The design
213+
principle: correct the ledger at the events that reveal actual values, and model only the one
214+
quantity no event reveals (time to release).
215+
216+
Filtering approaches (Kalman-style or observer-based bias estimation) are rejected. They earn
217+
their complexity when a system has continuous hidden dynamics observable only indirectly. Here,
218+
capacity changes in discrete jumps at knowable events (commit, TTFT, EOS, abort), and the dominant
219+
reconciliation errors are systematic (output-length over-prediction, prefix-cache discounts,
220+
translation drift) and correctable at those events. A filter also faces an identification problem:
221+
it cannot distinguish "predictions run 20% high" from "three requests completed and the scrape has
222+
not landed," and the guard machinery that distinction demands grows without bound. An estimator
223+
that needs that much protection signals a model mismatch.
224+
225+
Event truth-up instead:
226+
227+
- **At scheduling**: actual cached-prefix tokens are known; replace the zero-cache-hit pessimistic
228+
prefill/KV estimate.
229+
- **At EOS**: actual output length is known; the lease releases its committed footprint exactly
230+
(zero-sum), and the prediction error feeds the predictor, not the ledger.
231+
- **At abort/eviction**: the revocation event marks the lease reclaiming; engine acknowledgment
232+
(completion/abort counters, block counts) retires it. *(Open: engines count aborts and natural
233+
completions in different metrics; the acknowledgment channel must include both or reclaiming
234+
entries stall.)*
235+
- **Per scrape**: telemetry validates the roll-up and catches drift (translation error, missed
236+
events); persistent per-endpoint discrepancy is surfaced as calibration error on the Translator
237+
rather than silently absorbed.
238+
239+
## Stochastic layer: hazard-based release modeling
240+
241+
*(Directional: the framing and its uses. Open: estimator choice.)*
242+
243+
After truth-up, one quantity remains uncertain: when each active lease will release. Output length
244+
is a random variable; everything the ledger wants to know about the future is a function of its
245+
distribution.
246+
247+
### The framing
248+
249+
Let `L` be a request's output length (tokens). Define, per flow (or model/workload class):
250+
251+
- Survival: `S(n) = P(L > n)`, the probability a request generating its n-th token continues.
252+
- Hazard: `h(n) = P(L = n | L >= n)`, the completion intensity at age n.
253+
- Mean residual life: `m(n) = E[L - n | L > n]`, the expected remaining tokens given age n.
254+
255+
A lease at decode age `n` then has an expected remaining residency time of roughly
256+
`m(n) / decode_rate`, and the pool has an expected capacity supply schedule. For horizon `t`:
257+
258+
```
259+
ExpectedRelease(t) = sum over active leases i of P(L_i <= n_i + r_i*t | L_i > n_i) * footprint_i
260+
```
261+
262+
(`n_i` = current age, `r_i` = decode rate; `footprint_i` is the lease's committed footprint, an
263+
approximation of the release amount that truth-up corrects at EOS.) This is the quantity a scalar
264+
gauge cannot provide: not how full the pool is, but how fast it will empty.
265+
266+
### Why age-conditioning is justified
267+
268+
If output lengths were geometric (memoryless), age would carry no information: `m(n)` constant,
269+
every lease equally close to completion, and dispatch-time ordering heuristics vacuous.
270+
Empirically, LLM output-length distributions are strongly non-memoryless (mode near typical
271+
response lengths, heavy right tails from long generations), so `h(n)` and `m(n)` vary with age and
272+
age-conditioned decisions dominate age-blind ones. This is the first-principles justification for
273+
hazard modeling over both filtering (which models the wrong noise) and static heuristics (which
274+
discard the age information).
275+
276+
### Where it plugs in
277+
278+
Each consumer reads the same two views of the ledger: the **guaranteed bound** (deterministic,
279+
worst-case) and the **expected view** (hazard-discounted).
280+
281+
- **Tiered admission.** Guaranteed-tier holds are checked against the pessimistic bound: capacity
282+
is reserved, with OOM-shield semantics. Sheddable-tier holds may be checked against the expected
283+
view: capacity is statistically multiplexed (overcommitted), and revocation is the enforcement
284+
mechanism that makes the overcommit safe. When the gamble loses (a tail event: releases arrive
285+
slower than predicted), sheddable leases are revoked to restore the guaranteed tier's bound.
286+
This is the architecture of airline overbooking and effective-bandwidth admission in telecom,
287+
applied to KV residency. In this frame, eviction is a prerequisite for efficient admission
288+
rather than a repair mechanism bolted onto it.
289+
- **Eviction: wait-vs-evict.** Revoke only when expected natural supply misses demand:
290+
`ExpectedRelease(t) < deficit` for the blocked demand's tolerance horizon `t`. This is the
291+
principled answer to "how many to evict"; often the answer is zero because capacity frees itself
292+
within the tolerance. It is the term the v1 eviction design approximates with
293+
confirmation-gated pacing.
294+
- **Holdback sizing.** Reserve headroom sized to expected burst demand over the reclaim horizon
295+
(arrival model * footprint distribution), replacing hand-tuned ceiling fractions.
296+
- **Prediction.** The same distributions serve as priors for per-request OSL prediction
297+
(`m(0)` is the unconditional mean), tightened per-request by any upstream predictor; EOS
298+
truth-up supplies the training signal for free.
299+
300+
### Open questions
301+
302+
- Estimator form: empirical per-flow survival curves (histogram or Kaplan-Meier style; cheap,
303+
assumption-free) vs. parametric fits (compact; extrapolate tails better). Likely start
304+
empirical.
305+
- Conditioning variables: flow identity, prompt length, model. How much stratification before data
306+
sparsity dominates?
307+
- Non-stationarity: workload mix shifts; windowing/decay policy for the curves.
308+
- Decode-rate variability under load: the rate itself depends on batch occupancy, a second-order
309+
coupling ignored until first-order value is proven.
310+
311+
## Migration path
312+
313+
*(Proposed.)*
314+
315+
Each stage subsumes the previous stage's bookkeeping; no stage requires rework of the eviction
316+
controller or the dispatch gate's structure.
317+
318+
1. **Scalar eviction**: gauge-unit deficit and pending-reclaim debits
319+
(`flow-control-eviction.md`).
320+
2. **Dual ledger**: `KVTokens` + `Slots` held dispatch-to-EOS with TTFT prefill release. This is
321+
the two-axis degenerate Footprint; it fixes the token-mode under-count and the dispatch race,
322+
and needs token estimation but no block-level translation.
323+
3. **Footprint ledger**: full types, hold-then-lease protocol, event truth-up;
324+
`SaturationDetector` becomes a derived view.
325+
4. **Stochastic layer**: hazard curves, expected-release schedules, tiered admission against dual
326+
confidence levels; `VictimSelector` with deficit-covering subset selection.
327+
5. **Engine co-design**: the engine reports actual per-request block usage (retiring Translator
328+
estimation) and accepts priority (local preemption; EPP handles only the cross-endpoint case).
329+
330+
## Relationship to existing components
331+
332+
- `concurrency-detector` + `inflight-load-producer` are a proto-ledger (stage 2 grows out of
333+
them); `utilization-detector` becomes a reconciliation input rather than the primary gauge.
334+
- `UsageLimitPolicy` survives as the tier-policy seam; its ceilings become footprint-denominated
335+
reserves in stage 4.
336+
- The eviction plumbing (`RequestEvictor`, `Evictor`, `EvictionRegistry`, ext_proc channel) is
337+
unchanged throughout; only selection and sizing upgrade.
338+
339+
## Prior art and what is claimed
340+
341+
Every component has direct precedent: multi-dimensional resource vectors (Borg/Kubernetes,
342+
DRF), two-phase TTL'd reservations (slot booking, allocators), overcommit with revocation (airline
343+
overbooking, statistical multiplexing and effective bandwidth in telecom), survival analysis
344+
(reliability theory). The claimed contribution is the synthesis and its placement: engine-grade,
345+
lifecycle-split, revocation-capable capacity accounting at the fleet choke point.

0 commit comments

Comments
 (0)