Queues: a station declared whole¶
Everything a request passes through inside a deployment is a queue: a
waiting line, an admission, a service, memory of its own. The router, the
prefill instances, the NICs and the decode instances of
examples/pd-disaggregation/llmd_nixl_pull.sq are four of them, and the
program wrote them as pools, stages and hold … at admission (…) at the
call site. This document is the design of queue, RFC #72: one construct,
four roles, three rules, all of it parse-time sugar over the IR. Before is
programs/llmd_pd.seq at a8e8370, the program's name then; After is the
program in the repository.
The problem¶
The server block of llmd_pd.seq held the code of two repositories. The
llm-d router's (choose j, the remote decision) and the sidecar's (wait
for the prefiller's answer, then send the decode request), and vLLM's
scheduler's: admit if reqsP[i] (1), kvP[i] (…) reserve (prompt) fit where
… once for the prefiller and twice for the decoder (remote, local), each a
copy of the engine of programs/vllm.seq. The oracle checks vllm.seq;
nothing said the three copies were the same program. A reader who came for
the router passed vLLM's admission three times. The link's cost, x0 +
(prompt - c) / Bw, is the NIC's and sat in the router's body.
The design¶
One construct. A queue owns pools, a stage (serve kind;, named after
the queue) and one entry per verb of the roles it plays. An entry's body
is the server's statements: hold, prefill, decode, transfer,
set, observe, branch, choose. The serving forms find the queue's
own step engine; a stage that is not a step engine (a link's, a delay)
declared above the queue the body may name. stage and top-level pool stay
the kernel's forms; the client's tool is a stage, since it is the
client's thinking time and not a station of the deployment.
item := queue NAME [ '[' expr ']' ] [ : ROLE [, ROLE]* ] { qitem* } -- expr a let constant: queue D[ND]
qitem := pool NAME { poolopt* } -- the queue's own; only its entries hold it
| serve kind [ latency expr ] ; -- the Q of admit via Q, budget_left(Q), work(Q); latency: a link's
| VERB [ ( NAME, ... ) ] [ from NAME ] block
stmt += QUEUE [ '[' expr ']' ] . VERB ( expr, ... ) [ from QUEUE [ '[' expr ']' ] ] [ to POOL ( expr ) ] ;
| request QUEUE ; -- workload session: this gateway's route
| mark NAME ; -- in an entry: now, read by the caller as Q.NAME
expr += QUEUE [ '[' expr ']' ] . POOL -- from outside, a pool's reads: holders(D[j].kv)
| QUEUE . NAME -- a mark or a set of QUEUE's entry: the request's, no member index (D.first_token)
| self -- a member's own index; a bare Q inside Q is Q[self]
Four roles, the serving vocabulary's, and a queue says which it plays.
| Role | Entries | |
|---|---|---|
gateway |
route |
the workload's request Q; selects this queue's route; no implicit registration |
prefill |
prefill (prompt) |
computes the prompt; how the KV is left (lease, cache, a transfer) is the entry's |
decode |
decode (prompt), decode (prompt) from Q |
told apart by the from |
link |
transfer (n), or none |
the body sees n; from S to P (m) are the call's, and the linker writes load P (m); release S after the run. A link with no entry is its serve: transfer on L[k], M[l] (n) from S to P (m) holds it, with another link, at once, after the latency of each that has one |
Three rules.
- An entry sees its own. The header — the units,
reserve,reuse, theat admissionbindings — reads the parameters, the queue's pools and stage and theletconstants. The body also readsnow,cached, the context variables and the request'shiddenattributes; any other session attribute, and another queue'sQ.x, is an error at the entry, and so is one read in the index of a reference the body writes. The body holds only the queue's own pools and the pool itsfromnames. This is thehiddenrule generalised to the queue's boundary, and whydecode (prompt)has noo: the scheduler knowsmax_tokens, not the length, and the body alone readso. - An entry's
setis the queue's; itsobserveis the program's; a moment is amark.set c = cachedbecomes the attributeP.c;mark first_tokenisset D.first_token = now, read by the gateway asD.first_token, with no member index: the attribute is the request's. The gateway keeps the session's names, since it sets what the session reads back (prompt). from P[i]is the poolP's entry leases. The queue is the handle; there is no value for a leased allocation. Inside the entry thefromname is that pool, and in an index it is the member's index. Afromon a queue whose entries lease nothing does not link, nor on one with an entry that leases nothing: the request may have gone through it, andReleaseof no lease is the kernel's no-op, not an error. The rule is at the call, not on the role: aprefillentry that ends in a transfer (push mode) or acache(store and forward) is a program's to write.
The kernel. Every construct is the parser's.
| Surface | Kernel |
|---|---|
queue Q[N] { pool p {…} serve k; … } |
pool Q.p[N] {…}; stage Q[N] : k; |
Q[i].verb (e, …) |
the body in place, parameters substituted as an at admission binding is (no ~), set names renamed |
Q[i].decode (…) from S[k] |
the body's src := S.kv[k] |
L[j].transfer (n) from src to kv (m) |
run L[j] (t); load Q.kv[i] (m); release src, t the link entry's run (…) |
nic k; in queue Q[N] |
stage Q.nic[N] : k; |
D pull P latency x share s;, and transfer (n) from src to kv (m) in D[j]'s decode … from src, called from P[i] |
stage D.nic.latency[N] : delay; share s;, and run D.nic.latency[j] (x); run P.nic[i], D.nic[j] (n); load D.kv[j] (m); release P.kv[i] (The pull relation) |
queue L[N] : link { serve k latency x; }, and a transfer on L[k] … |
stage L[N] : k; stage L.latency[N] : delay;, and run L.latency[k] (x); before the transfer's run |
mark x; / Q.x |
set Q.x = now; / the attribute |
request Q; |
the named gateway's route body in place; no request node in the IR |
IR_VERSION does not move, serving-queue-theory's generator is
untouched, and tools/oracle/*.ir.json did not move: the oracle programs
(vllm_request.sq, vllm_replay.sq) are not written with queues.
Gateway selection is explicit: see Explicit gateways for the replacement of the original implicit registration and the boundary between language syntax, predefined roles and program names.
Before and After¶
Before (programs/llmd_pd.seq at a8e8370, lines 42–62 and 112–158): four
pools and four stages named by suffix, and the router's branch (remote)
holding the prefiller's admission, the decoder's remote admission and the
decoder's local admission, with the link's cost in the transfer statement:
pool reqsP[2] { cap max_seqsP; admit via P; }
pool kvP[2] { cap blocksP * bs; block bs; evict lru; preempt lifo; }
…
admit if reqsP[i] (1), kvP[i] (min(prompt, hit + budget_left(P[i]))) reserve (prompt) fit
where hit = min(cachedin(kvP[i]), hitmax) {
set c = cached;
prefill on P[i] (prompt - c) growing kvP[i];
} keep (prompt) lease kvP[i] (inf);
set t1 = now;
admit if kvD[j] (known) reserve (known), reqsD[j] (0) reserve (1) fit
reuse (floor((known - 1) / bs) * bs)
where known = computed < prompt ? prompt : computed + 1 {
…
transfer[j] (x0 + (prompt - c) / Bw) from kvP[i] to kvD[j] (prompt - 1 - c);
…
After is examples/pd-disaggregation/llmd_nixl_pull.sq: the gateway's
route has no hold; P's prefill entry and D's two decode entries
hold vLLM's admissions once each; the two NICs are link queues whose serve
is their bandwidth, and the decoder's remote entry holds both at once. The
program runs as the flat one it replaced (the file at origin/main
133a5f9): at seeds 1 and 7 the event, arrival, end and turn counts and
every stage's and pool's statistics are equal, and so is every observable
but two. ttft and lease are observed when the route reaches them rather
than at the moment marked, so at seed 1 one request that straddles the
warm-up adds a sample to each (their means move in the fourth digit) and
at seed 7 the batch-means estimator sees lease in another order (its
interval moves by 0.1 %). The IR is not byte-identical: set transferred =
0 moved from the router into the decoder's entry (it is the pull
scheduler's per-request flag, nixl/pull_scheduler.py:187-189), two
marks are two sets, and the pools are P.kv, not kvP.
What it earns¶
- The gateway's body says what the llm-d repository says;
PandDsay what the vLLM repository says. The program's boundaries are the code's. - Three link errors that were comments: an own pool held outside its queue,
a
fromon an entry that leases nothing, a header that reads past its parameters. - No IR change: the joint read is the kernel's
Run.also.
Self-critique¶
mark exports one moment, not an observation. ttft and lease are
the deployment's names and the gateway observes them; but a queue may still
observe its own (local_tokens, remote_tokens), so the boundary is not
clean.
The router reads pod internals. holders(D[j].kv), cachedin(P[i].kv),
work(P[i]). llm-d's router reads metrics and its own prefix-cache
estimate. An expose clause naming what a queue lets the deployment read
is the next step, and the moment the router's estimate and the pod's truth
part in the notation.
src in an index is a pun. A pool where a pool is read, the source
member in an index. Nothing else can be meant in either place, so no
program reads two ways, but a reader has to know the rule. The NICs no
longer need it: a pod owns its nic, and the pull
relation names the read's two ends.
Two ways to cross a link. A link with a transfer (n) entry is called
(nic[self].transfer (n) from src to kv (m)) and runs its body; a link with
none is named by the serving form (transfer on L[k], M[l] (n) …) and
runs its serve. The first says what one NIC costs; the second
is the only way to hold two at once. A joint call over entries — which
body's run holds both? — has no answer yet, so the form is not offered.
P and D still write vLLM twice. D's local entry is vllm.sq's
engine and P's entry is that minus decode. One definition deployed
twice needs a parameterised declaration (queue P[NP] = vllm { … }), the
model/instance split of the frontend sketch, item 4. This
document makes the unit that split will parameterise.
No role declaration. The four roles are the vocabulary; a program
cannot add one, and no import syntax exists yet. Moving reusable role
definitions into a standard library needs a role-definition mechanism first
(design direction). Criterion 2 is met by the queue choosing its roles and the
entry choosing how the KV leaves; a fifth role would be an issue.
The oracle programs are not queues. vllm.sq and its three workload
variants are held to the same text by tests/workloads.rs, and the oracle
harness programs feed serving-queue-theory; moving them is its own change
and the reason the IR-identity claim is a test on small programs
(tests/queues.rs) and a run comparison on llmd_nixl_pull.sq.