Skip to content

[RFC]: Executor, Worker, and ModelRunner ownership and migration plan #126

Open

Description

@wuli666

Progress update — 2026-10-22

Baseline: main 4a350344027960a48384626aa646f1765fda6d4f.

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 Request inside each ScheduledItem and 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 decodes Submission.values by stage.
  • Prepared KV batches are built in several runner-side places from private KVCacheManager state.
  • 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
Loading

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.

  1. 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 makes ModelRunner and SpeculativeRunner return 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_delivery is 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, and device_values, and boundary overlap becomes a runner capability. _inflight splits in two: buffer lifetime moves to the runner, and the scheduling hint (prefer_recurrent: is a RECURRENT submission still running?) uses ready() 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.

  2. 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's RngRegistry ([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 calling rng.acquire where 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.finish stops clearing device tensors; the engine calls the runner's release. Preemption asks the runner to export and import device state instead of copying Request fields (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.

    AsyncSpeculativeRunner follows 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.

  3. 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 standalone write() 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.

  4. Thin Worker and Executor (F7, F12). The Worker initializes and shuts down devices and resources: it owns plan_cache and execution_buffer_bytes (moved out of core/) 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 with submit, collect, suspend, release, and synchronize.

  5. 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 release is synchronous, and add_request first drains a retiring speculative request with the same ID.

Non-goals

Alternatives considered

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 states that 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.

@bjf-frz

Module owners for contract dependencies: @carsontung666 (M2) @YzXiao101 (M3) @BuckMulligan99 (M4) @ycjcx123 (M7) @Levius-Fubuki (M8) @YouHengfei (M9)

Anything else.

Decisions requested:

  1. Do you agree with the plan/result boundary, and with splitting a submission into submit, collect, and retire?
  2. Do you agree that the runner owns the hidden state and device token, while the speculative runners keep their own state?
  3. Do you agree with the migration order and with the interface split with M2, M3, M4, M7, and M9?
  4. Every release now completes before its ID can be reused, so bare-ID release and RngRegistry.drop are safe. If release later becomes asynchronous, should release, slot ownership, and RngRegistry share 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...

Appendix: code walkthrough at 9d601ce.

Worker and ModelRunner walkthrough

Baseline. This walkthrough describes 9d601ce (current main). It was first written at 11b1d34 and moved here from #110. Line numbers refer to 9d601ce. 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:

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:

  1. 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.
  2. Exit decisions are one loop late. The engine submits loop r, then reads loop r-1's gate score to decide whether r+1 is needed. No speculative extra loop runs. The policies that allow this are ouro_delayed, random_lookahead, and trace.
  3. 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:

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.py

Result: 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

  1. changed the title [-][RFC]: M5 Executor, Worker, and ModelRunner ownership and migration plan[/-] [+][RFC]: Executor, Worker, and ModelRunner ownership and migration plan[/+] on Oct 15, 2026
  2. Levius-Fubuki commented on Oct 15, 2026

    @Levius-Fubuki
    Contributor

    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.

  3. wuli666 commented on Oct 15, 2026

    @wuli666
    ContributorAuthor

    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.

  4. ycjcx123 commented on Oct 18, 2026

    @ycjcx123
    Contributor

    @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 assume temperature == 0 means 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 of rng.drop(), and LLMEngine.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 request release (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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions