2. Memory is a resource¶
In chapter 1 a request waited for a server. In an LLM system it mostly waits for memory: the KV cache is finite, and a request that has nowhere to put its keys and values cannot start, however idle the GPU is.
serQ has one abstraction for this — a pool — and it covers KV bytes, request
slots (max_num_seqs), a cap on live sessions and offload tiers alike. A pool
is a counted resource with a capacity, a queue and, later, a cache.
The program¶
// Chapter 2: memory is a resource.
// Four servers, so compute is not the bottleneck — a pool of 10 memory
// units is. A request holds the units it needs for as long as it runs.
let Lambda = 0.8;
let S = 1.0;
let C = 10; // memory units
pool mem { cap C; }
stage server : fifo(4);
workload {
arrive poisson(Lambda);
turn {
set s = ~exp(S);
set c = ceil(~uniform(1, 5)); // units this request needs
}
}
session {
turn;
set t0 = now;
hold mem (c) {
observe admit_wait = now - t0;
run server (s);
}
observe response = now - t0;
end;
}
run { horizon 100000; warmup 5000; seed 1; }
Four servers now, so compute is not the constraint. Ten memory units are.
hold is the whole idea¶
hold is a scope. Entering it means: join the pool's queue, wait until
c units are free, take them. Leaving it means: give them back. The lecture's
language had admit and free as separate statements; making them one scoped
statement is the single most important design decision in serQ, for three
reasons:
- Balance is syntactic. You cannot forget to free.
- The memory invariant becomes a lemma about one statement.
allocated + cached ≤ capholds in every reachable configuration, and that is a theorem in Lean (SerqLang.Step.invariant). - Preemption is "abort the scope and re-run the statement" — which,
it turns out, is exactly what vLLM's
_preempt_requestdoes (chapter 5).
When are the units counted?
c is evaluated when the session is admitted, not when it joins the
queue. This matters as soon as the expression reads something that changes
while you wait — the cache, or the engine's remaining budget. Chapter 5
depends on it entirely.
Running it¶
run: horizon 100000 end 100000 warmup 5000 seed 1 events 158920 arrivals 79460 ended 75462 turns 75462 mean live 0.871
observe count mean 95% CI cv2 p99
---------- ----- ------ ------- ------ ------
admit_wait 75462 0.0947 ±0.0050 13.756 1.8649
response 75462 1.0959 ±0.0090 0.944 4.8777
stage number util done thru wait service iters
------ ------ ----- ----- ------ ------ ------- -----
server 0.795 0.560 75462 0.7943 0.0000 1.0012 0
pool used cached queue holders wait admits evict(n) evict(u) preempt spill rej stuck
---- ---- ------ ----- ------- ------ ------ -------- -------- ------- ----- --- -----
mem 2.8 0.0 0.075 0.795 0.0947 79460 0 0 0 0 0 0
Note wait 0.0000 at the stage: nobody queues for a server. All the waiting
has moved to the pool.
The sweep¶
cap |
admit wait | response | rejected |
|---|---|---|---|
| 4 | 1.1893 | 2.1902 | 19 925 |
| 6 | 1.5358 | 2.5369 | 0 |
| 10 | 0.0947 | 1.0959 | 0 |
| 20 | 0.0006 | 1.0039 | 0 |
Two things are worth stopping on.
cap 4 waits less than cap 6. It is not better — it is refusing work.
A request drawing c = 5 can never fit in a pool of 4, so it is rejected
outright and never waits. 19 925 of them. serQ counts that in the rej column.
vLLM would not even start this way: it refuses an engine whose memory cannot
hold one request of its max_model_len, and refuses a longer prompt at the
API, so a request that can never fit never reaches its scheduler.
The knee is sharp. Between cap 6 and cap 10 the wait falls by a factor
of 16. Capacity planning for memory is not a smooth trade-off; you are either
comfortably above the knee or you are in trouble. Every later chapter sharpens
this.
Head-of-line blocking¶
A pool's queue is FIFO by default, and only the head can be admitted. If
the request at the front needs 5 units and 3 are free, the request behind it
needing 2 units waits anyway. That is not an accident of the implementation —
it is vLLM's behaviour (if new_blocks is None: break), and it is why a
long-context request can stall a queue of short ones.
queue by (expr) changes the order if you want to model a priority scheduler
instead.
Next: real sessions come back. → Sessions and turns