Description
Progress update — 2026-10-22
Baseline: main 4a350344027960a48384626aa646f1765fda6d4f.
- [Architecture] Unify stage execution with loop-level scheduling and mixed batching #118 merged StageClient/StageWorker/StageEngine, a local Executor and shared ModelRuntime, execution batches and shared metadata preparation.
- [Refactor] Move runner result application to Scheduler (M2/R1) #107 merged typed ModelRunnerOutput and Scheduler-owned result/submission updates on top of [Architecture] Unify stage execution with loop-level scheduling and mixed batching #118. The engine coordinates pending tickets, runner/connector side effects and retirement.
- [Bugfix] [M5] Restore the device token when an async request resumes in a new slot #109's resumed-device-token fix was already merged. [REFACT] [M5 1/N] Document worker and ModelRunner ownership, findings and migration plan #110 remains a draft documentation PR.
- The findings and five-step migration plan below remain pinned to
9d601ce; statements such as “no Worker or Executor” describe that historical baseline, not current main. - Reconcile each remaining finding with the merged implementation before opening another M5 refactor. Typed results and the Worker/Executor foundation should be reused. Full runner-owned device-state isolation, explicit submission retirement and KV/storage boundary completion are not declared finished by this update.
- Preserve the agreed shared RngRegistry ownership, GPU-resident dependency paths and cancellation/reuse lifetimes. Existing M5/M2/M3/M4/M7/M9 claims and contract discussions remain in place.
Current ownership reference: unified execution. This is a progress reconciliation, not a fresh code/GPU audit of F1–F16.
Motivation.
Refs #32 (M5: Executor, Worker, and ModelRunner). This issue follows the review on #110: the code walkthrough, the findings, and the migration plan live here, and the repository keeps only stable ownership and lifecycle contracts in docs/design.md. The code walkthrough is in the appendix at the end. Everything else reflects main at 9d601ce.
M5 gives the scheduler one entry point for device execution. The scheduler describes what runs this round: which requests, which stage, which loop. The execution side manages device resources, runs the work, and reports results. The scheduler uses the results to decide what runs next, and the engine connects the two. For example, the plan asks request A to run loop 3 at position 20; the runner returns A's gate signal; the scheduler decides whether A enters loop 4 or CODA (with delayed exit, one loop later).
Done when the engine runs requests through an execution plan, submissions, and results, including cancellation, preemption, and resume, without knowing how hidden states, slots, or CUDA streams are organized.
Today this loop works, but its boundaries are implicit:
- The runner receives the scheduler's mutable
Requestinside eachScheduledItemand reads position, loop depth, prompt tokens, the last delivered token, output index, and sampling parameters from it while the scheduler keeps changing them. - Per-request device state (
Request.hidden_state,Request.input_token_tensor) lives on the scheduler's object and is written by six components. F1 ([Bugfix] [M5] Restore the device token when an async request resumes in a new slot #109) was a missed write in one of them: a request resumed into a new slot embedded another request's token. - The engine reads and writes runner internals (
model_runner.events,core_stream,boundary_stream,ticket.device_values), and each caller decodesSubmission.valuesby stage. - Prepared KV batches are built in several runner-side places from private
KVCacheManagerstate. - There is no Worker or Executor. Device setup, memory planning, and buffer allocation are split across the engine,
core/memory.py, the cache manager, and the runner.LLMEngine.close()drains GPU work and aborts requests, but the runner has no terminal state or resource release of its own.
M5 needs agreement on these boundaries before restructuring, and on how they meet M2, M3, M4, M7 (#80), and M9 (#111).
Proposed Change.
Boundary
| Layer | Owns |
|---|---|
| Executor | Dispatch of execution and control calls to both runners, in one process |
| Worker | Initialize devices, load models, plan memory, initialize resources, and shut them down |
| ModelRunner | Batch preparation and execution, per-request device state, submissions and their results |
Ordinary ModelRunner path only. Purple: the proposed layers. Dashed: the couplings this RFC removes.
flowchart TB
subgraph before["Before"]
direction LR
S1["Scheduler"] -->|"mutable Request"| E1["LLMEngine"]
E1 -->|submit| R1["ModelRunner"]
E1 -.->|"reads runner internals"| R1
W1["6 writers: Scheduler, LLMEngine,<br/>preemption, PD worker,<br/>AsyncState, ModelRunner"] -.->|write| Q1[("Request.hidden_state<br/>Request.input_token_tensor")]
end
subgraph after["After"]
direction LR
S2["Scheduler"] <-->|"plan / result"| E2["LLMEngine"]
E2 <-->|"plan / result"| X2["Executor"]
X2 <-->|"plan / result"| R2
subgraph worker["Worker"]
direction LR
R2["ModelRunner"] --> Q2[("slot table<br/>hidden, device token")]
end
end
before ~~~ after
classDef plain fill:#ffffff,stroke:#1f2328,color:#1f2328
classDef layer fill:#efe9fb,stroke:#6f42c1,stroke-width:1.5px,color:#1f2328
class S1,E1,R1,W1,Q1,S2,Q2 plain
class E2,X2,R2 layer
Execution plan
RFC #32 section 2 asks SchedulerOutput to replace execution interfaces that carry mutable Request objects. The scheduler (M3) produces the plan; M5 lists what it consumes, and M2, M3, and M5 agree on the fields. On 9d601ce the runner reads these Request fields:
| Field the runner reads | Becomes |
|---|---|
request_id |
Request identity, with generation, in the plan |
position, loops_done |
Position and loop depth in the plan |
prompt_token_ids (PREFILL) |
Token range in the plan |
num_scheduled_outputs |
Output index in the plan |
sampling_params |
Sampling parameters in the plan |
input_token_id (the last delivered token, a host value) |
Input token in the plan, for synchronous PRELUDE and the first round of both speculative runners |
hidden_state, input_token_tensor |
Runner-owned device state (step 2), not plan fields |
KV rows come from M4's public descriptions (step 3). ModelRunner.prepare already snapshots depth, position, and output index for an asynchronous submission; the plan makes that snapshot the runner's input.
Findings on current main
Sixteen findings on 9d601ce. F1 is fixed; each migration step below lists the findings it addresses.
Findings F1–F16
IDs match the walkthrough. Classes: behavioral needs its own fix; structural is a boundary problem for this refactor; performance is a cost to measure; open question needs a decision; necessary is complexity that stays; capacity choice is a sizing decision; documentation is a doc fix. Except F1, no finding is a confirmed wrong result: findings were checked against call paths on 9d601ce, not against GPU runs.
| ID | Where | Class | What happens | Step |
|---|---|---|---|---|
| F1 | AsyncState.ensure_slot |
Behavioral, fixed | On CUDA async, a request preempted in PRELUDE resumed into a new slot imported its hidden state but not its device token, so it embedded another request's token | Fixed by #109 |
| F2 | Request.hidden_state, Request.input_token_tensor |
Structural | Written by Scheduler.finish, LLMEngine when it submits CODA, PreemptionManager, the PD worker, AsyncState.ensure_slot, and the runner (_save, _save_batch, PRELUDE) |
2 |
| F3 | AsyncState.prepare, Workspace, PrefillMetadataBank, RecurrentGraphs, CodaGraphs, SpeculativeRunner, AsyncSpeculativeRunner, the Huginn boundary paths in ModelRunner (_execute, _prefill_tokens, _populate_coda), plan_cache |
Structural | Prepared KV batches are built from private M4 state (_get_allocation, _validate_rows, _plane, _prepare_batch, _require_live_batch, _require_prefix), and graph replay updates written itself. #82 added the Huginn paths |
3, after M4 exposes row data. Preparation, validation, and written bookkeeping each need a contract |
| F4 | ModelRunner.__init__, _free_runner_slot |
Structural | Two slot pools. With CUDA async and static_buffers, the static states tensor is allocated and then replaced by aliases into AsyncState, so both exist during construction; the old tensor is not kept afterwards. _free_runner_slot branches on which pool is live |
2: one owner. Lowers the construction peak, not steady-state memory |
| F5 | LLMEngine (model_runner.events read and written, core_stream/boundary_stream, ticket.device_values), pd/worker.py (core_stream) |
Structural | Callers read and write runner internals. An uncollected ticket returns its readback slot only when it is dropped | 1 |
| F6 | Submission.values (ordinary runner) |
Structural | Scores, tokens, or nothing, decided by stage; decoded again by each caller. The speculative path already returns SpeculativeResult |
1 |
| F7 | LLMEngine.__init__, ModelRunner.__init__, core/memory.py |
Structural | No Worker or Executor. core/memory.py imports worker/buffers.py. LLMEngine.close() drains GPU work and aborts requests, but the runner has no terminal state or resource release |
4 |
| F8 | finalize_many → AsyncState.prepare(finalize=True) → ensure_slot |
Open question | A finalize bank never calls transfer, so a table upload queued during finalize would be dropped while table_versions advances. Unreachable today because finalize follows the same requests' RECURRENT prepare; nothing enforces it |
2: finalize looks up existing slots and asserts |
| F9 | _save_batch, submit |
Performance, unmeasured | On CUDA async, PRELUDE and RECURRENT update one Request.hidden_state view per request, and submit adds a wait_event and a record_stream per request that has an event or a hidden state |
Partly goes away with 2 (the view updates); cross-stream waits stay. Measure in 5 |
| F10 | _prefill_tokens |
Performance, unmeasured | On CUDA with static_buffers and non-packed prefill (anything but LAST_EXITED with packed-prefill support, so SHARED with FA4 too), prefill re-acquires a workspace per depth and can stall the host once per depth. The wait protects metadata still in use |
3 |
| F11 | prepare for PREFILL |
Open question | PREFILL needs the slot registration, table upload, and state import that prepare and transfer perform, but _prefill does not use the metadata kernel's output |
3: drop the unused metadata work, keep the rest |
| F12 | execution_buffer_bytes |
Open question | No explicit term for PrefillMetadataBank growth. Static states is counted although async replaces it after construction; it does exist at the construction peak. Profile peak and reserve also apply, so the net effect on the budget is not established |
4: separate startup peak, resident capacity, and dynamic growth |
| F13 | Submission.__del__, _readback_slot |
Necessary, fragile | collect waits, copies into an independent list, and returns the slot. An uncollected ticket returns it on garbage collection, so the engine keeps tickets in _inflight until their copy completes. Reuse also waits slot.event, so no DMA overwrite race is known |
1: explicit retire, drop as the safety net |
| F14 | events[request_id] (ordinary runner) |
Necessary | One event per request orders every later stage and gates slot reuse. Speculative rounds and PD keep their own events and leases | Keep; documented in design.md |
| F15 | Banks, workspaces, readback slots | Capacity choice | Four routing banks, two finalize banks, and max_num_seqs + 4 readback slots per dtype. They size the current pipeline (two core rounds plus one boundary stage in flight); safety also relies on waiting before reuse. The minimum is not proven |
Keep; document the assumed bounds next to the constants |
| F16 | _save; docs/cdb_runtime.md |
Documentation | The "async without a slot" clone branch in _save is reachable: async speculative PREFILL calls execute() without prepare (#66). cdb_runtime.md says --async-scheduling enables static buffers automatically; async creates AsyncState and routing banks, but static_buffers stays off |
2: keep the branch until that path has a slot; fix the doc |
Migration plan
No step introduces a temporary adapter that a later step deletes. Huginn requires synchronous scheduling today, so every step keeps the synchronous path working alongside CUDA async.
-
Typed results from the runners and
Submission(F5, F6, F13). Following its 2026-10-12 review, [Refactor] Move runner result application to Scheduler (M2/R1) #107 makesModelRunnerandSpeculativeRunnerreturn typed results directly; this step lands there. A submission has three phases, and M5 asks that the contract keep them separate:- Submit returns at once with what later device work depends on: the completion handle and the sampled token on the device. Alternatively the token stays in the runner's slot and the engine never handles it. The next PRELUDE must not wait for host collection;
test_prelude_consumes_device_sample_before_cpu_deliveryis an acceptance test. - Collect returns host values as typed fields that carry the request generation, so a stale result for a reused request ID is rejected.
- Retire belongs to the runner: a readback buffer is reused only after its copy completes and its result has been collected into independent host data or explicitly discarded. Drop stays as a safety net.
The engine stops reading or writing
events,core_stream,boundary_stream, anddevice_values, and boundary overlap becomes a runner capability._inflightsplits in two: buffer lifetime moves to the runner, and the scheduling hint (prefer_recurrent: is a RECURRENT submission still running?) usesready()on the engine's own submissions. PD stream ordering moves to the worker-side connector (M9, [RFC]: PD coordination and KV transfer contracts #111).M5 reviews the runner side of that PR and can validate it on GPU.
- Submit returns at once with what later device work depends on: the completion handle and the sampled token on the device. Alternatively the token stays in the runner's slot and the engine never handles it. The next PRELUDE must not wait for host collection;
-
Runner-owned request state (F2, F4, F8, F9, F16). The runner becomes the only owner of per-request device state (hidden state and device token), for sync and async alike, through one slot table interface keyed by request generation. Sync and async may keep different physical layouts. The runner reads the execution plan and its own state, not
Request. The runner does not hold generators: sampling RNG stays in the engine'sRngRegistry([REFACT] [M7 2/3] Unify the sampling module, move the sampling RNG into the engine, and split suspend from release #80), and the runner keeps callingrng.acquirewhere it draws randomness (sampling, and Huginn's recurrent-state initialization in PRELUDE and prefill). RNG continuity follows from that:- preemption calls
suspend, which frees the slot and keeps the generator; - resume may land in a different slot; the generator is keyed by request ID, not by slot, so sampling continues from the same stream;
- slot reuse never touches generators;
- termination calls
release, which drops the generator, so a reused request ID starts fresh.
Scheduler.finishstops clearing device tensors; the engine calls the runner'srelease. Preemption asks the runner to export and import device state instead of copyingRequestfields (RFC [RFC] Module boundaries and incremental refactoring of the RLT inference runtime #32 section 3: the scheduler picks the victim, the execution side saves and restores). PD takes the final hidden state through a runner operation that also returns its completion event; the runner does not own or recycle transfer buffers, whose lease and teardown order are agreed in [RFC]: PD coordination and KV transfer contracts #111. Coordinate with M3 and M9.AsyncSpeculativeRunnerfollows the same request identity, release, and shutdown rules but keeps its own state (device positions, submitted and delivered rounds, up to two rounds in flight). Its payload is not merged into the slot table. Both speculative runners start their first round from the plan's input token and position, keeping today's startup order. - preemption calls
-
One prepared-batch builder (F3, F10, F11), after M4 exposes host row data. M5 owns one prepared-batch contract, checked against M4's public row and validity descriptions. The contract states allocation identity (today
_require_live_batch), borrowing scope (one submission), the completion dependency (the consuming work's event), and reuse only after that event, including after cancellation and before a request ID is reused. Its backends keep their own data paths: host-filled (Workspace), GPU-routed (AsyncState.prepare),PrefillMetadataBank, the Huginn boundary paths, the dynamic path, and device-resident speculative positions, which are never read back to the host. Graph replay uses public operations on a prepared batch; today's standalonewrite()rebuilds the descriptor on every call and does not belong in the per-layer path. M8's metadata types define what the attention side consumes. -
Thin Worker and Executor (F7, F12). The Worker initializes and shuts down devices and resources: it owns
plan_cacheandexecution_buffer_bytes(moved out ofcore/) and creates the runners. The runner, buffers, graphs, and KV storage keep the tensors they use.shutdown()is idempotent, rejects new submissions, drains or discards in-flight work of both runners, and handles a partially initialized Worker. The Executor is a plain single-process class withsubmit,collect,suspend,release, andsynchronize. -
Measured hot-path work (F9, F10), each with a matched before/after comparison.
Invariants every step keeps
These are part of docs/design.md through #110:
- No
.item(),.tolist(), or host readback of tokens, positions, or batch metadata on a device dependency path; GPU-resident paths stay on the GPU. - With delayed exit, submit round r before reading the signal of round r − 1; never launch an extra loop.
- PREFILL and RECURRENT on the core stream; PRELUDE, CODA, and finalize on the boundary stream; routing on the copy stream; joined only by events.
- A slot, bank, readback buffer, or KV page is reused only after the event of the last work that used it.
- Padding rows never get a KV address, a signal, or a sample.
- LAST_EXITED finalize copies the exit plane into every deeper plane; SHARED finalize only validates.
- Results for a replaced request are dropped, and coda outputs are delivered in order.
- A request's release completes before its ID can be added again. Today
releaseis synchronous, andadd_requestfirst drains a retiring speculative request with the same ID.
Non-goals
- A multi-process or distributed Executor (Ray, RPC). RFC [RFC] Module boundaries and incremental refactoring of the RLT inference runtime #32 section 4 starts with a thin single-process entry point.
- Redesigning sampling (M7), attention backends (M8), logical KV allocation (M4), or the PD protocol (M9, [RFC]: PD coordination and KV transfer contracts #111). M5 consumes their public contracts.
- Merging the speculative runners' state into the ordinary slot table.
- Performance claims without a matched measurement.
Alternatives considered
- An engine-side adapter that converts today's
Submission. Rejected in review: it adds a layer that step 1 would delete. - Narrow accessors on the runner for the engine (events, stream identity). Keeps the engine coupled to how the runner orders work; capabilities and completion in the result remove the need.
- Runner-owned generators. Would create a second RNG owner beside
RngRegistry([REFACT] [M7 2/3] Unify the sampling module, move the sampling RNG into the engine, and split suspend from release #80).
Impact
- Performance: steps 1 to 4 intend no change. Step 2 removes the per-request hidden-state view updates in F9; the required stream dependencies remain. Any performance gain is measured in step 5.
- Memory: step 2 lowers the construction peak by not allocating the static
statesthat async replaces (F4); steady-state memory is unchanged. Step 4 separates startup peak, resident capacity, and dynamic growth in the budget (F12). - API: internal only.
LLM, the serving entrypoints, and configuration options are unchanged.
Validation for each step
CPU and GPU suites (--run-gpu with CUDA_VISIBLE_DEVICES), plus focused regression tests for sync and async execution, delayed signals, preemption and resume, cancellation and request-ID reuse, and cleanup ordering. Every code step adds a matched before/after comparison of latency, throughput, and memory; gains for F9 and F10 stay unclaimed until measured.
Feedback Period.
One week after posting, or longer if the M2, M3, M4, M7, or M9 owners need more time.
CC List.
Module owners for contract dependencies: @carsontung666 (M2) @YzXiao101 (M3) @BuckMulligan99 (M4) @ycjcx123 (M7) @Levius-Fubuki (M8) @YouHengfei (M9)
Anything else.
Decisions requested:
- Do you agree with the plan/result boundary, and with splitting a submission into submit, collect, and retire?
- Do you agree that the runner owns the hidden state and device token, while the speculative runners keep their own state?
- Do you agree with the migration order and with the interface split with M2, M3, M4, M7, and M9?
- Every release now completes before its ID can be reused, so bare-ID
releaseandRngRegistry.dropare safe. If release later becomes asynchronous, should release, slot ownership, andRngRegistryshare one(request_id, generation)key? The RNG part needs M7's agreement ([REFACT] [M7 2/3] Unify the sampling module, move the sampling RNG into the engine, and split suspend from release #80).
Status of related fixes. Merged means on main. Proposed means in an open PR.
| Item | Status |
|---|---|
| F1: on CUDA async, a request preempted in PRELUDE resumes in a new slot and embeds another request's token | Merged: #109 |
| Refill admission never scheduled requests restored into PRELUDE or CODA (prerequisite for F1's GPU test) | Merged: #113. #101 proposed the same change |
Finalize KV bookkeeping through public KVCacheManager views (part of F3) |
Merged: #57 |
Sampling RNG owned by the engine through RngRegistry; runner suspend versus release |
Merged: #80 |
Runners and Submission return typed results, applied by Scheduler.update_from_output() (F5, F6, F13) |
Proposed: #107 |
Before submitting a new issue...
- Make sure you already searched for relevant issues in the issue tracker, and looked for the answer in the documentation.
Appendix: code walkthrough at 9d601ce.
Worker and ModelRunner walkthrough
Baseline. This walkthrough describes
9d601ce(currentmain). It was first written at11b1d34and moved here from #110. Line numbers refer to9d601ce. The findings (F1–F16) and the migration plan are in the issue description above.
This walkthrough explains how device execution works at 9d601ce: what ModelRunner owns, how the asynchronous path keeps tokens and hidden states on the GPU, which streams and events order the work, and which other modules reach into it.
Files in scope:
- model_runner.py: stage execution, submissions, readback leases, finalization, slot release.
- async_state.py and kernels/routing.py: resident request slots, routing banks, gather/scatter, the batched finalize kernel.
- buffers.py, cuda_graph.py, prefill_metadata.py with kernels/prefill_metadata.py.
- core/memory.py: startup KV budget.
- The device half of kv_cache_manager.py (storage tensors, writes, finalize copies), which the RFC moves to M5 later. Backend selection and attention metadata moved to
vllm_rlt/attentionin [M8 1/3] Extract attention contracts and add automatic backend selection #46 (M8). - Callers: llm_engine.py, preemption.py, pd/worker.py, and the device fields of request.py.
Why the runner looks the way it does
A recurrent model runs the same transformer core several times per token, and different requests in one batch can be at different loop depths. The runner therefore executes four kinds of batches, one stage at a time:
PREFILL prompt chunk -> embed -> every loop depth -> keep last hidden
PRELUDE sampled token -> embed -> keep hidden
RECURRENT kept hidden -> one loop at each row's own depth -> keep hidden, return gate score
CODA kept hidden -> LM head -> sample -> return token
The hidden state has to survive between batches, so the runner keeps one per request. In synchronous mode this is easy: run a stage, copy results to the host, let the engine decide what comes next. Huginn runs only in this mode (llm_engine.py:50-52); its prelude and coda also keep depth-0 KV.
Asynchronous mode overlaps the CPU with the GPU. It is built on three ideas:
- Tokens never come back to the CPU on the critical path. CODA scatters sampled IDs into a resident device pool. The next PRELUDE gathers them from there. The host copy is read later, only to report output.
- Exit decisions are one loop late. The engine submits loop
r, then reads loopr-1's gate score to decide whetherr+1is needed. No speculative extra loop runs. The policies that allow this areouro_delayed,random_lookahead, andtrace. - Events, not host waits, order the work. Every request carries the event of its last submitted work. Any later stage on any stream waits for it. The host blocks only to reuse a buffer, to read a result, or to recycle a slot.
Apart from Huginn, everything else in the scope is machinery for those three ideas: reusable buffers so nothing allocates mid-step, pinned host memory so copies do not serialize streams, and leases so a buffer is not reused while a copy is still reading it.
ModelRunner
prepare(batch) snapshot depths/positions/output indices, build routing
submit(batch) pick stream, wait request events, _execute, D2H into a leased slot, record event
_execute(batch, prepared) four stages x four execution paths (section 4.3)
finalize / finalize_many early-exit KV copies into skipped depths
suspend(request_id) wait the request's last event, recycle its slot, keep its RNG
release(request_id) same as suspend, then drop its RNG
execute(batch) synchronous path: _execute + .tolist()
1. Baseline and boundary
Baseline: upstream main at 9d601ce. Changes since 11b1d34 that touch this scope:
- [REFACT] [M4 1/N] Document KV cache ownership and add public allocation views #57 (M4):
finalize_manyuses the publictoken_writtenandmark_finalized(model_runner.py:605,647). - [FEAT] add cross-round async self-speculative decoding #66, [BUGFIX] Fix async speculative cleanup and serving failure isolation #115: async speculative mode runs PREFILL and the first CODA through
execute()withoutprepare(llm_engine.py:571-579). - [REFACT] [M7 2/3] Unify the sampling module, move the sampling RNG into the engine, and split suspend from release #80 (M7): the sampling RNG moved to the engine's
RngRegistry.Request.generatorandworker/sampling.pyare gone.suspendkeeps the RNG,releasedrops it (model_runner.py:677-692). - [MODEL] Add native Huginn support and recurrent-core CUDA Graph E2E #82: Huginn paths in
_prefill_tokens,_populate_codaand_execute,CodaGraphsin the runner,recurrent_layersin the KV manager. - [Bugfix] [M5] Restore the device token when an async request resumes in a new slot #109:
ensure_slotimports the device token too (F1). - [PD] Prioritize ready handoffs before prefill submission #122: PD
prefill_steplets ready handoffs advance before it submits (pd/worker.py:280-281).
Not in scope: worker/sampler.py (M7), the model's layer code (M6), attention kernels and backend selection (M8), and the speculative runners (RFC #43; callers in section 4.8). They share only the engine's RngRegistry with ModelRunner, and the proposed Executor has to dispatch to both.
Who touches the runner and its device state at the baseline:
| Caller | Module | What it uses |
|---|---|---|
LLMEngine.__init__ |
M2 | Constructs ModelRunner directly with the engine's RngRegistry (llm_engine.py:150, 162-169). Calls plan_cache before the KV manager exists (122-128) |
LLMEngine._step (sync) |
M2 | execute, finalize, release, synchronize (319-357, 430, 292, 473) |
LLMEngine._update |
M2 | model_runner.events.get(request_id) to defer prefix publication (402) |
LLMEngine._step_async |
M2 | submit, finalize_many (692, 746). Reads boundary_stream and core_stream (698-699). Reads ticket.device_values, ticket.batch, ticket.output_indices, ticket.depths, ticket.ready(), ticket.collect() (617-747) |
LLMEngine._step_async_speculative |
M2 | execute on the speculative stream, then writes model_runner.events (571-579). release (486) |
PreemptionManager |
M3 | synchronize, suspend (preemption.py:87, 99). Reads hidden_state and input_token_tensor to snapshot and clears them, writes them back on resume (92-95, 105, 127-130) |
PDWorker |
M9 | submit for prefill, then drops the ticket (pd/worker.py:301, 326). Reads core_stream (302). release, synchronize (112, 355, 415). Copies request.hidden_state into its own transfer buffer and later points hidden_state at that buffer (312, 356, 390) |
Scheduler.finish |
M3 | Clears hidden_state, input_token_tensor (scheduler.py:91-92) |
What the runner reads from each Request, which is what a typed SchedulerOutput (M3) has to carry instead:
| Field | Read at | Purpose |
|---|---|---|
request_id |
everywhere | Slot, event, KV allocation, and RNG key |
loops_done |
prepare 385, _execute 471, 487, finalize* 604, 657 |
Zero-based depth of the loop being run. After an exit, loops_done - 1 is the exit depth |
position |
prepare 386, _execute 446, 472, 488, 501, finalize* |
Token position. Derived from prompt length and scheduled outputs |
num_scheduled_outputs |
prepare 387 |
Output index, used to check coda delivery order |
token_start, token_count, prompt_token_ids |
_prefill 262-276, _populate_coda 290 |
Prompt chunk. The first two are on the ScheduledItem |
input_token_id |
_execute 436 |
Sync PRELUDE input, a host int |
input_token_tensor |
prepare 390, _execute 434, ensure_slot 168-169 |
Async PRELUDE input, a device scalar. Imported by ensure_slot, cleared by the runner |
hidden_state |
submit 558, _gather 233, 237, ensure_slot 164-166 |
Hidden state. Written by the runner (section 2) |
sampling_params |
_sample_row 706, _prefill_tokens 309, _execute 453 |
Sampling and Huginn's initial state, with a generator from RngRegistry |
2. Classes and state
ModelRunner
| Field | What it holds | Written by | Lives for |
|---|---|---|---|
model, cache_manager, device, sampler |
Dependencies | __init__ |
Runner |
rng |
Engine-owned RngRegistry, shared with speculative runners |
__init__ 86 |
Engine |
graphs |
RecurrentGraphs when cuda_graphs |
__init__ 90-99 |
Runner |
coda_graphs |
CodaGraphs for Huginn with cuda_graphs |
__init__ 100-104 |
Runner |
lookahead_head |
Frozen random Linear(hidden, 1) for random_lookahead, built on a forked CPU RNG |
__init__ 107-119 |
Runner |
core_stream, boundary_stream, copy_stream |
Three streams on CUDA async. One shared stream with multi_stream=False. None otherwise |
__init__ 120-134 |
Runner |
readback_slots |
Pinned host buffers: max_num_seqs + 4 per dtype (float32 scores, int64 tokens), each max_num_batched_tokens long |
__init__ 135-147. Leased by _readback_slot, returned by Submission |
Runner |
submission_events |
Events of the last submissions, used to bound in-flight work to three | submit 537-540, 581 |
Rolling |
events[request_id] |
The event of each request's latest submitted work: a submission, a finalize copy, or a finalize kernel | submit 580, finalize 662, finalize_many 646, llm_engine.py:578 (F5). Popped by _free_runner_slot 667 |
Request |
workspaces, workspace_index |
Two Workspace per group (core, boundary) when static_buffers |
__init__ 163-167, _workspace |
Runner |
states, state_slots, free_state_slots |
Hidden-state slot pool. Static pool when static_buffers, replaced by aliases into AsyncState on CUDA async (188-190, F4) |
_save, _free_runner_slot |
Runner |
async_state |
AsyncState on CUDA async |
__init__ 178-192 |
Runner |
_prefill_banks, _prefill_bank_index |
Three PrefillMetadataBank for FA4 packed prefill with prefill_uva, created lazily |
_prefill_tokens 322-325 |
Runner |
last_effective_size, last_submitted_size |
Real versus padded row count of the last batch (diagnostics) | _size 198 |
Rolling |
Submission (the ticket returned by submit)
| Field | What it holds |
|---|---|
batch |
The SchedulerOutput that was submitted |
values |
Host view of the result in a leased ReadbackSlot. None for PREFILL, PRELUDE, and trace-mode RECURRENT. On CPU, the result tensor itself |
device_values |
The device result. The engine hands each CODA row to the next PRELUDE |
event |
Recorded after the device-to-host copy. None on CPU |
slot |
The leased ReadbackSlot. Returned by collect() or by __del__ |
cached |
The list produced by collect() |
depths, output_indices |
Snapshots taken in prepare, so delivery does not read state that has already moved on |
The result means different things by stage: nothing, gate probabilities (float32, after sigmoid), or token IDs (int64). The caller has to know the stage to read it (finding F6).
AsyncState and RoutingBank
| Field | What it holds | Lifetime |
|---|---|---|
AsyncState.hidden [max_num_seqs, hidden] |
Resident hidden state, one row per slot | Runner |
AsyncState.tokens [max_num_seqs, 1] int64 |
Resident sampled token, one per slot. Written by the CODA scatter and the new-slot import (#109), read by the PRELUDE gather | Runner |
AsyncState.tables [max_num_seqs, planes, width] int32 |
Device copy of each slot's block tables | Runner |
slots[request_id], free |
Slot assignment. free is a LIFO list |
Admission (first prepare) to release |
owners[request_id] |
(id(request), id(allocation)), to catch a slot reused before release |
Same |
table_versions[request_id] |
The block-table tuple last uploaded | Same |
banks (4) |
Routing banks for PREFILL, PRELUDE, RECURRENT, CODA. Rotate round robin | Runner |
finalize_banks (2) |
Control-only banks: a pinned descriptor, no device tensors | Runner |
RoutingBank.host [rows, 3] pinned int64 |
(slot, depth, position) per row. Read by the GPU through UVA, or copied to the device on HIP |
One use |
RoutingBank.slots/positions/blocks/offsets/lengths/tables |
Per-row metadata produced on the device by metadata_kernel |
One use |
RoutingBank.hidden, RoutingBank.tokens |
Gather output buffers | One use |
RoutingBank.uploads, imports |
Pending table uploads, and hidden and token imports into a new slot | Until acquire |
RoutingBank.ready_event, done |
Metadata ready (on copy_stream), last reader finished (on the execution stream) |
One use |
A bank is reused only after acquire() waits for done (async_state.py:39-44). A slot is reused only after _free_runner_slot waits for events[request_id] (model_runner.py:664-675, via suspend or release).
Workspace and PrefillMetadataBank
Workspace (buffers.py:29-96) holds a hidden buffer plus pinned host and device copies of the batch metadata; acquire waits for the last release. PrefillMetadataBank (prefill_metadata.py:12-110) builds all FA4 packed-prefill metadata at once and grows by powers of two (section 4.6).
Device state on Request
Two tensors that the RFC assigns to the worker still live on Request:
| Field | Writers |
|---|---|
hidden_state |
ModelRunner._save 214, 219, _save_batch 229, AsyncState.ensure_slot 166, PreemptionManager 105, 127, PDWorker 356, 390, Scheduler.finish 91 |
input_token_tensor |
LLMEngine._step_async 705, ModelRunner._execute 434, PreemptionManager 105, 128-130, Scheduler.finish 92 |
The sampling generator moved to the engine's RngRegistry (#80).
Without AsyncState, hidden_state is a private clone or a view of a static slot. On CUDA async it is a view of AsyncState.hidden[slot] once the request has a slot, and a clone before that (_save 211-214, reached by async speculative PREFILL and bootstrap, F16).
3. Functions and arguments
Shapes use n for real rows, size for submitted rows (n rounded up to a power of two when padding), and H for the hidden size. Huginn is sync only, without prefill UVA (llm_engine.py:50-52).
| Function | Expects | Does | Side effects |
|---|---|---|---|
ModelRunner.prepare(batch) |
A scheduled batch. For PRELUDE, every request has input_token_tensor |
Snapshots depths, positions, output indices, and PRELUDE tokens. On CUDA async, fills the next routing bank and, for RECURRENT, a _PreparedKVBatch over it (section 4.2) |
May wait on the bank's done. Assigns slots, queues uploads and imports |
ModelRunner.submit(batch) |
Same | Bounds in-flight submissions to three, picks the stream by stage, routes on copy_stream, waits request events, runs _execute, copies the result into a leased pinned slot, records one event for the batch (section 4.2) |
Returns a Submission. The host can block on the readback slot, the bound, the bank, and PREFILL buffer reuse (F10) |
ModelRunner.execute(batch) |
Sync engine, or async speculative PREFILL and bootstrap without prepare (llm_engine.py:574) |
_execute, then .cpu().tolist() |
Host sync per step, by design |
ModelRunner._execute(batch, prepared) |
prepared from prepare, or None on the sync path |
Runs one stage (section 4.3), including the Huginn boundary-KV variants | Writes KV, saves hidden states, samples with _sample_row, scatters tokens |
ModelRunner._sample_row(logits, request) |
One logits row | Greedy argmax, else Sampler.sample with self.rng.acquire on the logits device |
Creates or advances the generator |
ModelRunner.finalize(request) |
Every recurrent layer has written the token's KV at depth loops_done - 1 |
On boundary_stream after the request's event: finalize_token copies the exit-depth KV to every deeper plane. Records a new event on CUDA async |
Marks written for the deeper planes |
ModelRunner.finalize_many(requests) |
Same, for a list | CUDA async LAST_EXITED only: checks token_written, takes a finalize bank (F8), launches one finalize_kernel on boundary_stream over the slots' device tables. Otherwise loops finalize |
Calls cache.mark_finalized. Sets each copied request's event to the bank's done |
ModelRunner.release(request_id), suspend(request_id) |
Any ID | Pop and synchronize the request's last event, free its slot. release also drops the RNG, suspend (preemption) keeps it |
Host block until that request's work completes |
ModelRunner.synchronize() |
— | Synchronizes all three streams | Host block |
AsyncState.ensure_slot(request, bank) |
The request has a KV allocation | Returns the request's slot, allocating one on first use. Queues a table upload when the block-table tuple changed. On first use, also queues hidden and token imports and repoints request.hidden_state at the slot (F1) |
Raises if the slot belongs to another (Request, allocation) pair, or no slot is free |
RoutingBank.transfer(batch) |
Runs on copy_stream |
Uploads tables, runs the imports, launches metadata_kernel over size rows, records ready_event. Returns batch with device metadata swapped in |
Writes AsyncState.tables, hidden, and tokens |
RoutingBank.gather(tokens=False) |
transfer has run |
[size, H] hidden, or [size] tokens, gathered by slot. Padding rows read 0 |
Kernel launch |
RoutingBank.scatter(values, tokens=False) |
values has count real rows first |
Writes rows into the slot pool | Kernel launch |
Workspace.prepare(cache, ids, depths, positions, size) |
Live allocations | Fills pinned host metadata, copies it non-blocking, returns a _PreparedKVBatch over device views |
Uses the M4 private _validate_rows and _plane |
plan_cache(model, cache, scheduler, execution, backend) |
Called before KVCacheManager exists |
Returns (num_blocks, plan): overrides first, 256 blocks on CPU, otherwise a profiled probe pass minus scratch, buffers, and reserves (section 4.6) |
Synchronizes the device and resets peak statistics |
execution_buffer_bytes(...) |
Same config as the runner | Device bytes of workspaces, static states, routing banks, the async pools, and the finalize descriptors | Pure |
4. Line-by-line coverage
Construction, submission, execution, finalization and release, AsyncState and kernels, buffers and graphs, the device half of the KV manager, and callers.
Sections 4.1–4.8
4.1 Construction (model_runner.py:69-192)
| Lines | What happens | Notes |
|---|---|---|
| 79-89 | Takes device and dtype from the first parameter. Stateless Sampler. Injected or new RngRegistry |
Shared with the speculative runner (#80) |
| 90-99 | RecurrentGraphs when cuda_graphs. compute_gate is true only for ouro and ouro_delayed |
The engine rejects graphs off CUDA (llm_engine.py:93-94); the backend's cuda_graphs capability decides (#46) |
| 100-106 | CodaGraphs only when graphs are on and the model requires_boundary_kv (Huginn, #82). Prefill banks are built lazily |
|
| 107-119 | random_lookahead: builds the head on CPU inside fork_rng(devices=[]), then moves it |
Weight init belongs to M6/Worker |
| 120-134 | Three streams on CUDA async. With multi_stream=False they are one stream. Each waits the current stream |
|
| 135-147 | Pinned readback slots, max_num_seqs + 4 per dtype, allocated up front: cudaHostAlloc during submission can serialize streams |
Section 4.6 explains the count |
| 150-157 | submission_events, events, empty workspaces, a default slot pool of max_num_seqs, states=None |
|
| 158-176 | static_buffers: four workspaces (2 x core, 2 x boundary), padded to a power of two when padding. states [max_num_seqs, H]. Core and boundary streams wait again |
|
| 178-192 | CUDA async: AsyncState with the same padded row count. states, state_slots, free_state_slots are rebound to the async pools. Streams wait again |
The static states from 168 is no longer referenced (F4) |
4.2 Submission (model_runner.py:382-407, 526-590)
| Lines | What happens | Branches and failure paths |
|---|---|---|
| 384-387 | Snapshots depths, positions, output indices from the requests | |
| 388-392 | PRELUDE collects input_token_tensor |
Raises if any is missing: async prelude needs the device sample from CODA |
| 393-401 | CUDA async: AsyncState.prepare. recurrent=True also builds KV metadata. A new slot imports hidden state and resume token (#109, F1). PREFILL needs only the slot, table and import (F11) |
Bank rotation may block on done |
| 402-407 | CPU async: RECURRENT without workspaces gets _prepare_batch. Everything else leaves kv=None for _execute |
|
| 537-540 | Drops completed submission events. With three still pending, synchronizes the oldest | Host block. prepare runs before this (comment at 148-149) |
| 541-545 | Stream by stage: core for PREFILL and RECURRENT, boundary for PRELUDE and CODA | None on CPU |
| 546-552 | Routing: if imports are queued, copy_stream first waits the current stream, since imports may come from it (preemption restore, PD). transfer runs on copy_stream. The execution stream waits ready_event |
|
| 553-560 | On the execution stream: wait each request's event, record_stream its hidden state |
One wait_event per request even when requests share an event (F9) |
| 561-565 | _execute. record_done on the routing bank in finally, so the bank is retired even on failure |
Exceptions propagate. The engine then synchronizes and aborts everything (llm_engine.py:325-338) |
| 566-581 | CUDA: lease a readback slot of the result's dtype, copy non-blocking, record one event, attach it to the slot, to every request in the batch, and to submission_events. No result (PREFILL, PRELUDE, trace mode): event only |
_readback_slot (526-533) raises if exhausted and first waits the slot's previous event (F13) |
| 582-590 | Returns the Submission, with the device result in device_values |
The engine feeds it to the next PRELUDE |
Submission.collect 54-65 |
Waits the event, converts to a list, returns the lease | Idempotent |
Submission.__del__ 47-49 |
Returns the lease if the ticket is dropped without collect |
Relies on CPython reference counting (F13) |
4.3 Execution (model_runner.py:194-380, 409-524)
Four execution paths:
| Path | Selected when | Hidden in | Hidden out | KV metadata |
|---|---|---|---|---|
| Sync dynamic | sync, no static_buffers |
torch.stack(hidden_state) |
_save: clone per request |
model.recurrent → _prepare_batch |
| Sync static | sync, static_buffers |
_gather: zero the workspace, copy per request |
_save: copy into a static slot |
Workspace.prepare |
| CPU async | async_scheduling on CPU |
As sync, by static_buffers |
As sync | prepare: _prepare_batch (dynamic) or Workspace.prepare (static) |
| CUDA async | async_scheduling on CUDA |
routing.gather() |
routing.scatter(), then repoint hidden_state per request |
AsyncState.prepare + metadata_kernel |
Crossed with the stages:
| Stage | Sync dynamic / static | CUDA async | Graphs |
|---|---|---|---|
| PREFILL, LAST_EXITED, non-FA4 | _prefill_tokens 352-380: prelude, then one _core per depth. With a workspace, each depth re-acquires the same workspace. |
Same code on core_stream. Routing is ignored |
Not used |
| PREFILL, LAST_EXITED, FA4 | 315-351: packed ragged prefill. With prefill_uva, one of three PrefillMetadataBank builds every depth's metadata once. Otherwise one _prepare_batch(packed_prefill=True) per depth. |
Same | Not used |
| PREFILL, SHARED | 259-270: position waves. Each request runs all loops at a position before advancing, batched across requests. Each wave calls _prefill_tokens |
Same | Not used |
| PRELUDE | Host token IDs → tensor (pinned workspace or torch.tensor) |
Gather tokens from AsyncState.tokens. record_stream on the source tensors. Clear input_token_tensor |
— |
| RECURRENT | _gather / stack → _core |
Gather → prepared.kv |
graphs.run(hidden[:n], kv) in any mode. kv comes from prepared or a fresh _prepare_batch |
| CODA | model.coda over size rows. One _sample_row call per real row |
Same, then scatter tokens into AsyncState.tokens |
Huginn: CodaGraphs |
| Lines | What happens | Notes |
|---|---|---|
194-199 _size |
Pads to a power of two when enabled | Also records last_*_size, a hidden side effect |
201-208 _workspace |
Alternates between the two workspaces of a group and acquires one | None without static_buffers |
210-220 _save |
Clones without a slot pool, or with async when the request has no slot. Otherwise copies into its slot, allocating one | Reachable with async_state: async speculative PREFILL skips prepare (#66, F16) |
222-229 _save_batch |
Without routing: _save per row. With routing: one scatter, then request.hidden_state = self.states[slot] per request |
Creates one view per request per step (F9) |
231-238 _gather |
Stack, or zero the workspace rows and copy per request | Per-request copies |
240-256 _core |
Dynamic model.recurrent, or Workspace.prepare + recurrent_prepared |
|
258-282 _prefill |
SHARED waves, or one LAST_EXITED batch. Passes one request per token row. Keeps only each item's last row | Writes hidden_state |
284-303 _populate_coda |
Huginn. Coda without logits on depth-0 metadata for every row but the last prompt position, which runs in CODA. All rows without requests |
Uses private _prepare_batch (F3) |
305-380 _prefill_tokens |
Packed (FA4 only) or generic, by packed_prefill (315). Huginn adds per-row generators (307-314). Generic acquires and releases the workspace around every depth (371-377) |
Each acquire waits for the previous depth's whole forward (F10). An exception skips the bank release (349-350) |
| 410-418 | PREFILL: _prefill, return None. Else pad; a workspace only without routing |
|
| 419-460 PRELUDE | Covered above. Huginn (442-456): boundary prelude over real rows, a generator per request, even greedy | new_zeros padding only without routing; the routing gather zero-pads |
| 462-495 RECURRENT | Gather, run, save, optional lookahead head. Result is sigmoid(logits[:n]) in float32, or None in trace mode |
Padded rows run through the model but write no KV and produce no signal |
| 496-514 CODA | LM head, per-row sampling, token scatter | _sample_row may create the request's generator |
| 515-516 | Any other stage raises | |
| 517-518 | Releases the workspace (records its event) | Skipped on exception. The sync error path does not synchronize (llm_engine.py:352-357) |
521-524 execute |
_execute, then .cpu().tolist() |
Host sync |
4.4 Finalization and release (model_runner.py:592-712)
| Lines | What happens | Notes |
|---|---|---|
| 593-598 | Empty list returns. No async state, or SHARED: call finalize per request |
SHARED finalize_token only validates (kv_cache_manager.py:910-912) |
| 603-610 | Raises unless cache.token_written at the exit depth. Keeps only requests with deeper planes to fill |
Public query since #57 |
| 611-617 | Prepares a control-only finalize bank with depth = exit depth | ensure_slot may queue uploads that a finalize bank never performs (F8) |
| 618-627 | On boundary_stream: wait each request's event, set the descriptor (UVA, or a non-blocking copy) |
|
| 628-643 | One launch over a (requests, max_loops, ceil(layers*channels/256)) grid. A program does nothing when target <= depth |
Reads AsyncState.tables, not the bank's tables |
| 644-647 | Records done, makes it each request's event, cache.mark_finalized marks every deeper plane |
Public since #57. Host written advances at submission time. Section 4.7 |
649-662 finalize |
Waits the request's event on boundary_stream, calls finalize_token, records a new event |
On CPU or sync there is no stream and no event |
664-675 _free_runner_slot |
Pops and synchronizes the event. Async: AsyncState.release. Otherwise returns the static slot |
Host block until the request's last work finishes |
677-683 suspend |
Frees the slot, keeps the RNG | Preemption (preemption.py:99) |
685-692 release |
Frees the slot, then rng.drop: a reused ID starts a fresh generator |
Finish and abort |
694-697 synchronize |
All three streams | |
699-712 _sample_row |
Greedy rows take no generator. Others rng.acquire on the logits device. Delegates to Sampler.sample |
M7 owns the arithmetic. Replaced _sample_tensor (#80) |
4.5 AsyncState and kernels
| Lines | What happens | Notes |
|---|---|---|
async_state.py:17-37 |
Bank buffers. control_only banks have only the pinned descriptor |
|
39-44 acquire |
Waits done, clears uploads and imports |
Keeps pinned upload tables alive until their non-blocking copies finish |
46-87 transfer |
See section 3. Imports go to the hidden or token pool and call record_stream on the source |
descriptor is the pinned buffer itself under UVA. Without UVA (HIP) it is copied non-blocking |
89-91 record_done |
Event on the current stream | |
93-112 gather, scatter |
Grids (size, ceil(H/256)) and (count, ceil(H/256)) |
Gather fills size rows and zeroes those past count. Scatter writes only count rows |
115-142 AsyncState.__init__ |
width = ceil(max_position_embeddings / block_size). planes is total_ut_steps for LAST_EXITED, 1 for SHARED |
tables start at zero. Rows are not cleared on release |
144-170 ensure_slot |
Existing slot: check the owner, re-upload the table if the tuple changed. New slot: pop LIFO, record the owner, queue the upload, import and repoint a hidden state, import an input_token_tensor (#109) |
F1 is fixed. owners stores id() values of objects it does not keep alive, so a recycled id could mask a missed release |
172-212 prepare |
Rotates banks. width from the largest position. Descriptors use depth 0 except for RECURRENT and finalize. RECURRENT also validates rows, rejects duplicate write addresses, and builds a _PreparedKVBatch over bank tensors (its position_ids is a host view, swapped for the device tensor by transfer) |
Uses private M4 state (F3). A finalize bank never reaches transfer (F8) |
214-219 release |
Drops the bookkeeping, returns the slot | The caller must have synchronized the slot's last event (_free_runner_slot does) |
routing.py:7-41 metadata_kernel |
One program per submitted row. Real rows get slot, position, length pos+1, write block and offset, and the table row. Padding rows get length 0, a zero table row, and no block or offset store |
Padding never gets a KV address |
| 44-76 gather and scatter | Tiled row copies by slot | |
79-109 finalize_kernel |
Copies one token's K and V for every layer and channel from the exit plane's block to each deeper plane's block | finalize_token copies only recurrent_layers, a subset only for Huginn (sync only) |
4.6 Buffers, graphs, prefill banks, memory plan
| Lines | What happens | Notes |
|---|---|---|
buffers.py:10-26 |
Per row: workspaces H*e + 4*width + 36 (four int64 and one int32 vector, int32 tables). Routing banks H*e + 4*width + 68 (the same plus an int64 token and a 24-byte fallback descriptor). Async pools H*e + 8 + planes*width*4 per slot. Finalize descriptors 2 * max_num_seqs * 24 |
Omits PrefillMetadataBank and still counts the static states that async replaces (F12) |
buffers.py:49-63 |
acquire waits the last release. tokens zeroes size host rows and copies non-blocking |
|
buffers.py:65-96 prepare |
Validates rows, rejects duplicate addresses, zeroes positions, lengths, and tables up to size, fills real rows, copies non-blocking |
Blocks and offsets are copied for real rows only |
cuda_graph.py:10-20 _capture |
Synchronizes the device, warms up twice on the capture stream, captures | Each first capture of a key drains the device |
23-52 _DeviceCache |
A proxy with only KV write and attend, so capture records no written updates |
|
99-121 RecurrentGraphs.run |
Key = (rows, tables, max_seqlen_q, packed). Trims padding rows. Falls back to eager above cuda_graph_max_batch_size, for empty batches, or when the graph cache is full |
|
| 122-139 | Validates the batch and the written prefix of recurrent_layers on the host before replay. Falls back for non-contiguous same-request rows |
Uses _require_live_batch and _require_prefix |
| 140-142 | Waits the previous replay's event | Graph-private buffers are shared across streams |
| 143-158 | Copies metadata into static entry tensors (_graph_entry, 55-85), captures on first use |
|
| 159-168 | Replays, clones outputs, records last_event, marks written |
The host marks written at replay submission |
171-248 CodaGraphs |
Keyed by row count, ordered by last_event. With a cache it validates before any fallback and marks written for coda_kv_layers |
SpeculativeRunner without a cache. ModelRunner with one, Huginn only (#82) |
prefill_metadata.py:19-88 |
Waits done. Builds all metadata, including every plane's tables, in one host list. Grows buffers to powers of two. One staging kernel, then one expand kernel per plane |
Reads cache._allocations |
| 90-110 | metadata(depth) builds a _PreparedKVBatch positionally. release records done |
|
memory.py:20-139 |
Section 3 | Profiles model.recurrent, the dynamic path, not the static, async, FA4-packed, or graph paths. Huginn adds prelude_prepared and coda_prepared over probe._prepare_batch. Imports worker.buffers from core (F7) |
4.7 Device half of the KV manager
| Lines | What happens | Notes |
|---|---|---|
kv_cache_manager.py:184-188 |
key_cache and value_cache [blocks, layers, page, kv_heads, head_dim] |
Allocated by M4, registered directly by NIXL |
| 509-551 | snapshot and restore copy pages page by page and deep-copy written |
For preemption |
735-737 _stage |
Pinned host tensor, non-blocking copy | The caching host allocator keeps the staging buffer alive |
739-783 _prepare_device_batch |
Wraps device-resident metadata in a checked _DeviceKVBatch |
For AsyncSpeculativeRunner |
824-845 _write_prepared |
Validates, writes real rows with advanced indexing, then marks written on the host. A device batch writes and returns |
|
| 847-854, 870-905 | Attention requires a contiguous written prefix through each row's position. A device batch skips the check |
|
907-933 finalize_token |
Validates, then for LAST_EXITED copies each deeper plane with basic indexing and marks written, both over recurrent_layers |
mark_finalized (478-487) is the marking half, for finalize_many |
935-956 read |
Test and debug helper that materializes a prefix |
written therefore records that a write has been enqueued, not that it has finished. Every consumer is ordered behind the producer by a stream or an event: same-stream order inside one traversal, events[request_id] across stages, ready_event/done across banks, and the event passed to publish_prefix for prefix reuse. This is the contract M4's written and M5's events share. A refactor that moves either side has to keep both. _DeviceKVBatch work never updates written; it relies on one compute stream.
4.8 Callers
| Lines | What happens | Notes |
|---|---|---|
llm_engine.py:319-338 |
Async error path: synchronize all streams, abort every request, discard speculative tickets, clear pending signals, coda, and in-flight tickets | Partial submissions are drained before slots are recycled |
| 339-357 | Sync path: execute, then _update or _update_speculative. On failure, abort only the batch's requests |
|
| 400-413 | PREFILL: publish the prefix with the request's event, so publication waits for the writes | The event is non-None only on CUDA async |
| 429-431 | Sync RECURRENT exit: finalize, then CODA |
|
| 571-579 | Async speculative PREFILL and first CODA: execute on the speculative stream, then the engine writes model_runner.events (F5) |
_save clones (F16) |
617-641 _deliver_coda |
Drops results for unregistered or replaced Request objects. Checks output order against output_indices. Uses the snapshot depths for exit_depths |
Object identity guards ID reuse |
| 655-674 | Drops ready in-flight tickets (their leases return), delivers ready coda. Prefers RECURRENT when a core is still running and the previous step submitted a coda on a separate boundary stream. No batch: drain one coda, or raise unless all are RECEIVING | |
| 675-708 | CODA waits until earlier outputs are delivered, so each request has at most one pending output. Then submit. CODA: one placeholder per request, input_token_tensor = ticket.device_values[i], enqueue PRELUDE. PREFILL and PRELUDE: _update(batch, None) |
The PRELUDE gather reads AsyncState.tokens |
| 710-746 | RECURRENT: submit r, increment loops_done, read r-1's score (collect blocks on that older event only), check that the score's position and depth match, decide exit, keep r's score for the next round, finalize_many the exits |
|
preemption.py:81-109 |
Synchronize the runner, snapshot the KV, copy hidden and token to the CPU, suspend (keeps the RNG slot), free, re-queue as WAITING |
|
preemption.py:111-137 |
Allocate, restore, move hidden and token to the device, synchronize the current stream, re-enqueue the saved stage |
The next async prepare imports both into a new slot |
pd/worker.py:276-327 |
Prefill role: return while a ready handoff chunk waits (#122). submit a PREFILL batch, copy the final hidden state into the PD buffer on core_stream, record per-request events for chunk transfer, drop the ticket |
|
pd/worker.py:354-357, 390 |
Releases the runner slot after the last prefill event and clears hidden_state. On decode activation, points hidden_state at the PD buffer (imported into a slot on the first prepare) |
7. Validation
The walkthrough changes no code. The code was exercised as follows.
CPU at 9d601ce, macOS arm64, Python 3.12.13, torch 2.11.0, no Triton:
PYTHONPATH=. python -m pytest -q tests/test_async_pipeline.py tests/test_async_state.py \
tests/test_cdb_runtime.py tests/test_engine.py tests/test_prefix_growth.py \
tests/test_prepared_kv.py tests/test_kv_cache.py tests/test_cuda_graph.py tests/test_pd.pyResult: 164 passed, 83 skipped. All 83 skips require --run-gpu, so this run covers only the CPU async state machine, not streams, banks, or kernels. The async speculative and Huginn tests (test_async_speculative.py, test_async_speculative_lifecycle.py, test_huginn.py) give 28 passed and 32 skipped on CPU; every skip is GPU-only.
GPU was not rerun at 9d601ce. The last GPU run, at 11b1d34 on one RTX 4090 (SM89) with --run-gpu, gave 198 passed, 18 failed, 6 skipped; all 18 failures were environmental (FA2 block size, FA4 unsupported, PD needing a second GPU).
Existing tests that cover M5 paths, and which of them need a GPU:
| Area | Tests | Device |
|---|---|---|
| Delayed exit, placeholders, ordered delivery | test_async_pipeline.py, test_cdb_runtime.py::test_async_submits_next_core_before_collecting_previous_signal |
CPU and GPU |
| Device token into prelude | test_prelude_consumes_device_sample_before_cpu_delivery |
CPU and GPU |
| Bank and slot reuse, abort, ID reuse | test_async_state.py, test_abort_speculative_core_then_reuse_id |
GPU for real streams |
| Batched finalize | test_batched_exit_copies_only_target_positions_and_depths |
GPU |
| Stream overlap | test_cuda_boundary_and_core_can_overlap |
GPU |
| Async equals sync | test_cuda_async_matches_synchronous_with_padding_and_sampling, test_cuda_dynamic_async_respects_buffers_and_matches_sync |
GPU |
| Graphs | test_cuda_graph.py |
GPU |
| Memory plan | test_explicit_bytes_and_cpu_auto_sizing (CPU), test_cuda_auto_memory_plan_and_abort_pending_work, test_cuda_memory_plan_reclaims_allocator_residue_on_recreation (GPU) |
Both |
| Async preemption | test_async_pressure_preemption_with_resident_state, test_async_preemption_restores_prelude_token_in_a_new_slot (#109) |
GPU. Only the second reaches F1 |
| Async speculative decoding (#66, #115) | test_async_speculative.py, test_async_speculative_lifecycle.py |
Mostly GPU |
| Huginn (#82) | test_huginn.py |
CPU and GPU |
Not verified here: GPU at 9d601ce, the FlashAttention paths, HIP (no UVA), FA4 packed prefill, prefill_uva, PD transfer, and the F9 and F10 performance claims. F1 was confirmed on GPU and is fixed by #109, which includes the reproduction test.
Activity
[-][RFC]: M5 Executor, Worker, and ModelRunner ownership and migration plan[/-][+][RFC]: Executor, Worker, and ModelRunner ownership and migration plan[/+]Levius-Fubuki commentedon Oct 15, 2026
The proposed boundary aligns with M8: M4 provides public KV row and validity descriptions; M5 owns prepared-batch construction and device-buffer/stream lifetimes; M8 defines attention metadata semantics, backend capabilities, and kernel input contracts.
Please preserve the existing GPU-resident data paths without adding CPU readbacks for tokens, positions, or metadata construction. The metadata contract should explicitly define allocation identity, borrowing scope, completion dependencies, and when buffers or KV pages can be reused, including cancellation and request-ID reuse.
wuli666 commentedon Oct 15, 2026
Thanks @Levius-Fubuki, agreed on both. GPU-resident paths stay as they are, and the step 3 contract will cover allocation identity, borrowing scope, completion dependencies, and reuse, including cancellation and request-ID reuse.
ycjcx123 commentedon Oct 18, 2026
@wuli666 Thanks. M7 agrees with the plan/result boundary, the submit/collect/retire split, and the migration order. Three RNG contracts to keep:
RngRegistry(engine-owned since [REFACT] [M7 2/3] Unify the sampling module, move the sampling RNG into the engine, and split suspend from release #80) stays the only owner of generator creation, lookup and lifetime. Runners keep acquiring at the draw sites, including Huginn recurrent-state initialization. Suspend keeps the generator, and resume continues the same stream when the physical slot changes; slot reuse must not touch RNG state; release drops the entry so a reused ID starts fresh. Greedy sampling needs no generator, but a greedy Huginn request still holds an entry for recurrent init, so cleanup cannot assumetemperature == 0means no request RNG.On decision 4: bare-ID cleanup is safe today. At
9d601ce,ModelRunner.release()waits any recorded request event before dropping the RNG entry and is the only production caller ofrng.drop(), andLLMEngine.add_request()drains a retiring request with the same ID. Async speculation already defers when that release runs (_retire_speculative()), while the calls stay synchronous. If release can return before retirement completes and generations overlap, key the registry by(request_id, generation)using the same identity the plans and slot table carry, defined by one authority rather than the registry. Suspend/resume keeps that identity across a slot change, and a late drop of an old generation must not remove the new entry. Generation checks do not replace completion events or leases.Keep submission
retire(reclaim the readback lease after collection or explicit discard, with copy completion established) separate from requestrelease(terminate, drop the RNG entry). Retiring an intermediate submission must not end the request's stream.M7 3/3 stays CUDA validation of the merged sampling/RNG work. Async temperature/top-k/top-p speculative sampling will be a separate PR with CPU and GPU validation, preserving per-request RNG progression from bootstrap through rounds without adding host readbacks to the device dependency path. Since step 2 changes
AsyncSpeculativeRunner's identity and release rules, I'll coordinate that interface first.