The serQ IR¶
The IR is the definition of a serQ program. Everything else is built around it:
program text (.sq) ──parse + link──▶ IR (ir::Program, JSON) ──▶ interpreter (interp)
tools (Rust / JSON) ──────build/edit──▶ ──▶ Lean model (generated)
──▶ checks, diffs, archives
The text syntax (docs/language.md) is one frontend. It exists because a
deployment is easier to read and write as text, not because the text is
the program. A tool that knows what it wants to run (a scenario from JSON,
a parameter sweep, a trace replay) builds or edits the IR as data instead
of generating text.
Source: src/ir.rs. Version: IR_VERSION = 10 (2 added the sessions' turns;
3 renamed the route field to session; 4 replaced CStep's two booleans
exclusive_prefill and decode_first by the one order serve;
5 added KV transfer and leases; 6 added renewal arrivals and finite open runs;
7 makes Choose.key a list of keys; 8 lets a Run hold several stages at
once, also, under the program's share; 9 makes queue keys a list,
reevaluates them at selection and supplies Waited).
Why an IR first¶
- One program, several consumers. The interpreter runs the IR, the Lean
model of serving-queue-theory is generated from the IR, and the vLLM
oracle tests run the IR. Before the IR existed, the vLLM request program
had three hand-kept copies: the Rust test built it as a string per
scenario, the Lean generator held a hand-written Lean version, and
examples/multi-turn/vllm.sqwas a third variant. Now there is one file,examples/oracle/vllm_request.sq, compiled once per scenario intotools/oracle/<name>.ir.json, and both the Rust test and the Lean theorems read those files. The multi-turn cache scenario is the IR ofexamples/replay/vllm_replay.sqwith its trace inlined (tools/oracle/cache_trace.ir.json), so its Lean program is generated too. - The workload instance is data. Which sessions arrive with which
attributes, and which turns each one replays, is part of the IR
(
CArrival::Sessions), not of the program text or a separate trace file. A scenario's requests are no longer encoded as nested conditionals onserial, andserq ir --inline-traceturns a trace file into the sessions' turns. - Closed and checkable. The IR has no closures and no host-language
code: every expression is a tree over a fixed set of operators, context
variables and functions. That is what makes a formal semantics possible,
and
Program::validatecan check any IR, however it was produced.
Format¶
JSON (serde): structs are objects with the field names below, enums are
externally tagged ({"Num": 3.0}, {"Binary": ["Add", a, b]}, unit
variants as strings, "Lru"). JSON has no infinity: an infinite constant
(inf in a program, a pool without cap) is the string "inf" or
"-inf", and a reader of Num or cap takes a number or that string. serq ir FILE prints it;
serq run/check FILE.json reads it.
Program¶
| Field | Meaning |
|---|---|
version |
IR_VERSION; a different version is rejected |
attrs |
attribute names; an attribute is referenced by its index (slot) |
observes |
observation names, by index |
pools |
CPool: name, index (the member's index in an array declared pool kv[N], a one-member array's too, and a queue family's, queue D[1] included; omitted for a single pool; a report label the run does not read), cap (units), block (allocation granularity), evict (Lru or By([key exprs])), preempt (None, Lifo), queue (null for FIFO, otherwise a nonempty list of pure selection keys), spill, admit_via (stage whose scheduler admits waiting holders) |
stages |
CStage: name, index (as for CPool, stage E[N]), kind: Fifo(servers), Ps(capacity expr), Delay, Step(CStep) with budget, cost, chunk, serve (how the iteration serves its residents, said once: an order, By([key exprs]) (keys at the Serve moment, ties in admission order; no keys is admission order, serve admission; decode first is By([decoding ? 0 : 1]); a key may not draw), or the rule ExclusivePrefill, which is not an order and so cannot be combined with one), memory (pool index) |
arrival |
Poisson(rate), Renewal(gap expression), Closed(n), Batch(n), Sessions([{attrs: [[slot, value], …]}]), None |
trace, trace_ordered |
a trace corpus the workload draws turns from (path, resolved against the program's directory unless overridden) |
init, turn, session |
block indices: the workload's init and turn blocks and the session program |
blocks |
the statement blocks (an arena; bodies of holds, branches and loops refer to blocks by index) |
horizon, warmup, seed, arrivals |
the run; arrivals requires exactly N open arrivals and draining by horizon |
hidden |
attribute slots the scheduler may not read (hidden o;): legal at the Session moment only, below |
share |
MaxMin or Bottleneck: how the flows of runs over several stages divide the stages' capacity; present exactly when some Run has a non-empty also, omitted otherwise |
gauges |
[{name, expr}]: functions of the state whose time average the report gives, each read at the Gauge moment after every instant; omitted when empty. They read and do not act, so a reader that ignores them runs the same program |
slot_cached, slot_serial, … |
slots of the built-in attributes (cached, serial, turn_no, new, out, think, more, forced, computed) |
Sessions: all the sessions arrive at time 0; each one runs init, then
its preset attrs overwrite what init set. A session may carry turns
(each a list of [slot, value]): its turn statements then read them in
order instead of the trace corpus, with the corpus's rule (turn_no counts
turns, the turn's values are set, more is 1 while another turn remains
and 0 after the last). Program::with_sessions builds sessions from
attribute names; Program::inline_trace (CLI serq ir
--inline-trace) replaces an ordered trace by its sessions' turns, which
runs identically (tests/ir.rs).
Statements (CStmt)¶
| Statement | Meaning |
|---|---|
Turn |
draw the next turn's attributes (workload turn block or trace) |
Set(slot, e), Observe(k, e) |
assign an attribute, record an observation |
Hold {pools: [(pool, units, reserve?)], reuse?, body, cache?, lease?} |
acquire units of every pool (admission gate reserve if given), run body, release; with cache the admission consumes the own cached prefix, reuse bounds how much, and cache is the units left cached; without cache the hold leaves the session's cached prefix where it is (10); lease: (pool, t) keeps that pool's allocation past the scope, neither evictable nor a preemption victim, until the session's Release of it, t seconds, or its end (vLLM's delay_free_blocks) |
Grow(pool, e), Drop(pool) |
grow the current hold, drop the own cached entry |
Release(pool) |
give the innermost enclosing hold's allocation on the pool back now, or end the session's lease of it, caching per the hold's cache; nothing held or leased there is a no-op. A KV transfer between instances is Run (the link), Load (the destination) and Release (the source's lease) |
Load(pool, e) |
the KV of e tokens arrived from outside the engine (a NIXL read): the innermost enclosing hold's computed position on the pool advances by e, within its allocation |
Run {stage, mode, work, growing?, also?} |
work at a stage; mode Plain, Prefill, Decode (step stages); growing the pool that grows with the tokens computed; also further stages the same job holds at once (a flow of share), omitted when empty |
Branch(e, then, else), Loop(body), Choose {var, count, key} (key a list, compared in order), End |
control; End ends the session |
Expressions (CExpr)¶
Num, Attr(slot), Ctx(var) (Now, Waited, Size, Age, Last, Queued,
N, Ntok, Ndec, Npre, Nres, Kvb, Kvp, Attn: each exists at
one moment, below, and Now at every one), Sample(dist, args),
Call(fun, args) (arithmetic functions, pool and stage queries such as
CachedIn(pool), BudgetLeft(stage)), Unary, Binary, Cond. Pool
and stage references are CRef {base, count, index?} (a family of
count pools from base, selected by index).
Validation¶
Program::validate checks the version, that every block, attribute,
observation, pool and stage index exists, the run parameters, that
every context variable is read at the moment that supplies it, and the
flows: every stage array a Run holds with another (also) is a ps of
a constant capacity, every run on such a shared stage is Plain with no
growing, a run names each stage array once, and share is present
exactly when some also is non-empty (Program::shared_stages).
Program::from_json, run_ir and the linker (compile_source) call it,
so a text program meets the same check as IR from files and tools.
Moments. An expression is evaluated at one moment, fixed by its position in the IR, and a context variable exists at one of them:
Moment (ir::Moment) |
Positions | Context variables |
|---|---|---|
Session |
statements of init, turn, session; a run's work; a hold's cache (read when the session releases); Grow, Load, Branch, Choose |
Now |
Admit |
a hold's units, reserve, reuse, admission bindings |
Now |
Select |
a pool's queue keys, reevaluated for each waiting hold before every admission attempt | Waited, Now |
Evict |
eviction keys, a spill's work and when |
Size, Age, Last, Queued, Now |
Ps |
a ps stage's capacity |
N, Now |
Budget |
a step stage's budget and chunk, evaluated before the iteration from its residents |
Nres, Ndec, Kvb, Kvp, Now |
Step |
a step stage's cost, evaluated after the iteration is scheduled |
Ntok, Ndec, Npre, Nres, Kvb, Kvp, Attn, Now |
Serve |
a step stage's serve by keys, evaluated for one resident once the residents are known |
Decoding, Admission, Remaining, Nres, Ndec, Kvb, Kvp, Now |
Gauge |
a gauge, evaluated on the state an instant ends with and held until the next, with no session (an Attr, a Sample, CachedIn, Now, Work or BudgetLeft is rejected, and an index is a Num in range, so reading it cannot fail the run) |
none |
The index of a pool or stage reference (CRef.index) is evaluated with the
expression around it, so at that expression's moment: evict by (size +
used(kv[size])) is legal. The table is what the interpreter fills into its
context at each position (interp.rs: Ctx), not a policy: tokens in a
budget would read 0 because the tokens are not scheduled yet, so the budget
may not read it.
An attribute listed in hidden may be read at Session only; at every
other moment it is what the scheduler would be peeking at. The field is a
check, not a semantics: a program that validates runs the same with or
without it, so the Lean generator does not read it.
Before this check the variable read as 0 anywhere else and the program ran
(age in a session statement, tokens in a queue key); the doc comment said
"meaningful only where the semantics supplies them", which is what an
undefined behaviour is. The check is stricter validation of IR whose types
did not change, so it is not a version bump; an IR file that used to pass
and now fails was reading a value the semantics never supplied.
Ties¶
The IR carries no field for what breaks a tie: every ordered collection
has one rule, stated in docs/language.md §3 (Ties), so a tie field
(the IR v4 RFC's COrder) would carry no information. A Lean model of a
collection uses the same event number (SerqExec.lean's lru: release
order).
Stability¶
IR_VERSION identifies what a reader must understand, not the shape of the
file. serving-queue-theory's scripts/gen_serq_oracle.py pins it and reads
the IR by field name, so a bump is a handshake between the two repositories,
priced as such. What a change to src/ir.rs does to the version:
- Removed, renamed or retyped field or variant: bump. An old reader
fails loudly.
routebecamesessionin 3, and the generator'sir["route"]moved with it; under 4,servewent from the variantAdmissiontoBy([])(#50) and the generator'sFragmentcheck caught it. - Old files still parse, different meaning: bump, and say so in the
release note. An old reader parses the file and disagrees with the new
one about what it means; the version is the only warning it gets. The
signal in a diff is a change to
docs/language.md§3 orsrc/engine/interp.rsthat alters what an existing statement or field does whilesrc/ir.rskeeps the field or node. The interpreter settingcomputedon a preemption while the Lean fragment does not (docs/language.md§3,computed) is the standing example of the gap this line closes. - Added field: no bump when an old reader still reads every file right.
The test is not whether the field has a default but whether it carries
meaning: a check (
hidden), a label or a legend a reader may ignore does not bump; a field that changes what the program does bumps as a change of meaning, whatever its serde attributes —turnsin 2 (the sessions a program runs) had a default and was omitted when empty, and bumped. A field without a default also makes a new reader reject old files (missing field), so the committed IR files are regenerated in the same change; the generator reads by name and ignores what it does not know.gaugesdoes not bump: it changes what the report says, not what the program does, and a reader that drops it (the Lean generator) runs the same sessions to the same end, since a gauge reads no draw, plans no iteration and names its pools and stages by number. - Same shape, a stricter check: no bump. An IR file that validated before
and is rejected now was reading a context variable at a moment that never
supplied it (Moments, above), or a new file lists in
hiddenan attribute the scheduler reads: the check got stricter, the format did not change. - Committed IR files (
tools/oracle/*.ir.json, incl.cache_trace.ir.json) are regenerated bymake oracle-irand checked bymake check(tests/vllm_oracle.rs::oracle_ir_files_are_current). tests/ir.rs: every example program survives a JSON round trip exactly and runs to the same report from IR as from text.
A version is a release, and the lines above decide one thing: whether a
change to a tagged version opens the next number. While the version at
IR_VERSION has no tag (10 in v0.1.1, 9 in v0.1.0 and v0.1.0-rc7, 8 in v0.1.0-rc6, 7 in v0.1.0-rc5, 6 in v0.1.0-rc4,
5 in v0.1.0-rc1;
v0.1.0-rc0 is 3), no line bumps; the
change is listed in the coming tag's message, which is the release note,
and the handshake happens once, at the tag. A reader on an untagged version
reads a commit, not a version: under 4, serve moved twice and the
generator followed twice (#45, #50), while slot_computed and hidden
joined without a reader noticing.
Version 5 carries Release, Load and Hold.lease for KV transfer and
allocations that outlive their hold scope. The oracle programs use none of
these mechanisms; their regenerated IR has the new version and a null
lease. The generator pins 5 in the matching serving-queue-theory change.
Version 6 adds Renewal(CExpr) and Program.arrivals. A renewal gap may
be constant or sampled, uses only constants and distributions, and the
first arrival follows one gap. Poisson(rate) retains its initial arrival
at zero. Finite runs must generate the requested arrivals and drain by the
horizon; completion before or at warmup is an error. Both additions are
outside the Lean fragment (which accepts explicit sessions); the matching
generator pins 6 and rejects an arrival limit instead of ignoring it.
Version 7 makes Choose.key a list, compared in order, ties to the smallest
index (#140): a router that prefers a cache-warm pod and then the least
loaded one says so, where version 6 packed the two into one number
(warm * 1e9 + load), which is wrong once the load exceeds the multiplier.
One key is a list of one. Choose is outside the Lean fragment, so the
matching generator only moves its pin to 7.
Version 8 also strengthens CServe::ExclusivePrefill from resident-only
isolation to a whole-batch constraint: a prefill runs alone, including a
waiting prefill that displaces tentative resident decodes. Cancelled work
does not advance computed KV. This landed while 8 was untagged, so the
number stayed 8 under the policy above, and it is in v0.1.0-rc6's release
note; v0.1.0-rc6 carries 8, so the next change to the IR opens 9. JSON shape and the ordinary serving
order are unchanged. ExclusivePrefill is outside the Lean fragment;
this change neither extends it nor changes the oracle IR files.
Version 9 replaces cached enqueue keys with selection-time evaluation and
changes CPool.queue from Option<CExpr> to Option<Vec<CExpr>>. None
remains FIFO; a nonempty list is compared lexicographically with enqueue-order
ties. CtxVar::Waited exists at the new Select moment. Keys may not draw,
and scheduler-hidden attributes remain rejected. Existing time-dependent
queue policies change meaning; static one-key policies keep their order.
Resumed holds keep prepend priority and the selected non-fitting request
still blocks the queue. The current interpreter rejects older IR versions;
regenerate JSON from source. v0.1.0-rc6 carried 8, so this
meaning/shape change opened 9, and it is in v0.1.0-rc7's release note;
v0.1.0-rc7 and v0.1.0 carry 9, so the next change to the IR opens 10.
The companion serving-queue-theory/scripts/gen_serq_oracle.py reads version 9
for its FIFO fragment and rejects non-FIFO queues. It also retains support
for its pinned version 7 and version 8 FIFO programs. No waiting-selection
proof is claimed. Oracle JSON files move to version 9; their schedules and
generated Lean programs remain unchanged.
Version 10 makes Hold.cache the clause that admits a hold to the prefix
cache (#230): a hold without it consumes nothing of the session's own
entry and sets cached to 0, where 9 consumed the entry at every admission,
so a hold admitted inside another on the same pool found none. A hold written
around the request's on the same pool (a reservation given back before the
request is admitted) thus no longer costs the request its hit. A hold
without cache on a pool where the session has an entry no longer drops
it, whether or not its body reads cached; cache (0) is the 9 meaning.
The shape is unchanged; the meaning of an absent cache is, on a tagged
version, so this opened 10, which v0.1.1 carries; the next change to the
IR opens 11. No oracle program holds a pool with entries
without cache, so the oracle schedules and the trace are unchanged; the
files carry the new version. The Lean fragment's admit
(SerqExec.lean) consumes unconditionally and moves with the generator's
pin in the matching serving-queue-theory change. The linker rejects a
cached read, or a reuse, in a hold without cache (a stricter check,
which catches the body that would read a different number, not every
program whose numbers move).
The Lean fragment¶
The Lean model (serving-queue-theory, SerqExec.lean) runs a fragment of
the IR on a step clock over natural numbers: pools with LRU eviction and
LIFO preemption, one step engine (stage 0) with unit iteration cost, delay
stages, explicit sessions with preset attributes and turns, and the
statements Turn, Hold, Run, Set, Observe, Branch, Loop, End
(not Release, Load or a hold with a lease: a program with a KV
transfer is outside the fragment until SerqExec.lean gives a hold's pool
its own release)
with expressions built from integer constants, attributes, Now,
CachedIn, BudgetLeft, min, max, +, - (truncated at 0), *,
floor(a / b), comparisons and conditionals. A constant expression over
context variables with zero coefficients (the replay's cost at a = b = 0)
counts as a constant. Its generator translates an IR file into Lean and
fails on anything outside the fragment. The Lean Branch takes the first
block when the guard is non-zero; the interpreter admits only 0 or 1
(docs/language.md, Branching), so the two agree on every program that
runs.