Skip to content

Performance improvements across 18 models - #853

Closed
dghosef wants to merge 64 commits into
mstar-project:mainfrom
dghosef:perf/overnight-code
Closed

dghosef wants to merge 64 commits into
mstar-project:mainfrom
dghosef:perf/overnight-code

Conversation

@dghosef

@dghosef dghosef commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Note

Still needs some review.

Performance work across 18 models: a few shared changes, then per-model changes.

What changed

Shared

  • Conductor: a request goes to the least-loaded data-parallel replica, not a random one.
  • Warmup: each warmup request runs once per data-parallel replica, and a warmup spec's concurrency (an int or a list) warms batched sizes.
  • Engine: a "one row per step" slot lease for piecewise capture, so a vision tower can batch every request's images into one captured step.
  • Ragged attention: the resource owns an mstar::ragged_attention custom op; a resource that declares compile_op keeps attention inside a compiled region's graph instead of breaking there (Wan2.2). Other callers are unchanged.
  • DiT scaffold: schedule staging without host syncs.
  • Request video decode uses more FFmpeg threads.
  • API server: a per-config GIL switch interval (api_switch_interval_s, opt-in).
  • Fused MoE router kernel with tuned tiles (opt-in; Qwen3-Omni uses it).
  • New serving configs: split (a second worker for the vocoder or codec, with MPS), data-parallel 2-GPU, sequence-parallel 2-GPU (Cosmos3) and CFG-parallel 2-GPU (BAGEL, Wan2.2) presets.

Per model, by technique

Technique Models
Remove host syncs: stop the CPU waiting on the GPU Qwen3.5, Qwen3-Omni, OmniVoice, Chatterbox, BAGEL, DiT scaffold
Capture more as CUDA graphs Qwen3.5 vision, Qwen3-TTS, Fish S2-Pro, Higgs Audio, OmniVoice, Omnilingual ASR, FLUX.2-klein, Z-Image-Turbo, V-JEPA2-AC
Batch across requests: a node that took one request per step takes several Qwen3.5 vision, Qwen3-Omni audio encoder, Higgs Audio encoder, Omnilingual ASR, Cosmos3 DROID, Qwen3-TTS and Fish S2-Pro (64-row decode)
Batched stop check and device loop-back: less per-request Python per decode step Qwen3.5, Qwen3-TTS, Orpheus, Higgs Audio
Kernel fusion: fewer, larger GPU kernels Qwen3-TTS, Fish S2-Pro, Qwen3-Omni, Kokoro, Wan2.2, OmniVoice, Omnilingual ASR
Skip work that doesn't change the output BAGEL, Orpheus, LingBot-World, Qwen3.5
Compile choices Omnilingual ASR, Kokoro, Cosmos3, Qwen3.5 vision
Opt-in faster numerics Fish S2-Pro (TF32 codec), Kokoro (bf16), LingBot-World (fast config)
Output encoding and I/O BAGEL, Wan2.2, Cosmos3

Per model

  • Qwen3.5 text: rotary tables built inside the captured decode graph; one host-to-device sync removed. (Turning on TP async scheduling in the TP configs is left for a separate PR after a soak on current main.)
  • Qwen3.5 image/video → text: the vision tower's block loop captured and batched across requests (the shared lease); images stay uint8 and are normalized on the GPU.
  • Qwen3-Omni: host syncs removed; first audio chunk is 8 frames instead of 25; fused MoE router; code-predictor fusions; audio encoder batched.
  • Qwen3-TTS: 64-row decode; codec batch buckets; a batched stop check; fused add + RMSNorm; voice-clone prefill captured.
  • Fish S2-Pro: prefill and decode captured; fused Snake kernel; TF32 codec (config); 64-row decode.
  • Orpheus: an LM head over the audio vocabulary only; a batched stop check.
  • Higgs Audio: audio encoder batched and captured without per-shape recompiles; a Qwen3.5-style decode loop.
  • OmniVoice: backbone captured; host stalls removed; its own layer loop with fused norms; a split config.
  • Chatterbox (+ Turbo, Multilingual): a per-row host-to-device sync removed; split configs.
  • Kokoro: fused BiLSTM directions; scoped cudnn.benchmark; an opt-in bf16 preset.
  • Omnilingual ASR: feature extractor compiled; a packed, captured variable-length encoder; the positional conv as per-group GEMMs.
  • BAGEL: CFG branches that don't affect a step are skipped; no host syncs in prepare_inputs; SDPA for single-image ViT; faster PNG encode; a 2-GPU parallel-CFG config.
  • FLUX.2-klein: the Qwen3 text encoder is captured.
  • Z-Image-Turbo: every batch size 1–8 is captured, not only powers of two.
  • Wan2.2: self-attention through the ragged attention resource (FA3), kept inside the compiled graph; GELU fused into its GEMM; faster mp4 muxing; 2-GPU CFG-parallel and DP2 configs.
  • Cosmos3: whole-clip VAE decode; the DROID batched action step compiled; per-batch-size warmup; SP2 and DP2 configs.
  • LingBot-World: timestep embedding broadcast; channels-last VAE decode; the camera path once per chunk; an opt-in fast config. The default stays deterministic, within ~73 dB of the old decode rather than bit-exact.
  • V-JEPA2 / V-JEPA2-AC: the AC rollout encoder captured; rotary tables shared.

Performance

Throughput of this branch vs main, on H200, across the concurrencies run (the vLLM / SGLang comparison benchmark's mstar-pr and mstar-main arms, 2–3 rounds on fresh servers; main at abdfd5802, so before main's last few commits).

Model Improvement vs main
Qwen3.5-0.8B text +5–19%
Qwen3.5-9B text +0–5%
Qwen3.5-0.8B image/video → text +3–76%
Qwen3.5-9B image/video → text +1–22%
Qwen3-Omni speech out +27–31% (TTFA 2.3–2.4× lower); audio → text +26–79%; text +5–14%
Qwen3-TTS (0.6B / 1.7B) +4–35% / +2–31%
Fish S2-Pro +48% (c=1) to 2.5× (c=64)
Orpheus +5–15%
Higgs Audio (speech recognition) +47% (c=1) to 2.2× (c=8–32)
OmniVoice 4× at c=1, +25–90% at higher concurrency
Chatterbox +3–10%
Kokoro +10–17%
Omnilingual ASR (300M / 1B–7B) +17–30% (−3% at c=64) / 1.7–2.1×
BAGEL text → image +64–65% on 1 GPU, +91–97% on 2 GPUs; edit +0–3%; understanding +7–23% at c≥4 but −17.5% at c=1
FLUX.2-klein (4B / 9B) +2–6% / +1–2%
Z-Image-Turbo +2–13%
Wan2.2 +9–10%
Cosmos3 DROID policy +39–46% (c=4–8); text → video +1–5%
LingBot-World +19%
V-JEPA2 (ViT-L) 2.0–2.5×

🤖 Generated with Claude Code

dghosef and others added 3 commits October 23, 2026 11:18
The code of the overnight multi-agent perf push (#13 at d248477), on the same base
(main abdfd58), as one commit. Byte-identical to #13's tree outside overnight_artifacts/, which
stays on #13 with the benchmark results, profiles and harnesses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolutions:
- worker held-output fix: take main's mstar-project#799 (drop host copies once a step's
  sends are done) and drop this branch's release_host_copies, which fixed the
  same bug.
- qwen3_5: keep main's KV layout and this branch's grid step metadata.
- qwen3_tts: the batched stop check reads max_tokens, as main's mstar-project#808 does.
- bagel, qwen3_omni, vjepa2, base, ragged callable: union of both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's KV layouts (mstar-project#808) gave Higgs three spans for its old
prefill_text/prefill_audio/prefill_text walks; this branch prefills the
text and audio in one prefill_audio step, so a keyed request dropped its
layout and debug asserts failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef and others added 20 commits October 23, 2026 17:29
The flip rested on measurements from before main's TP teardown-order and
TP prefix-caching fixes; it moves to its own change after a GPU soak.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mstar.utils.device.to_device_nonblocking replaces the copies in Qwen3.5,
the diffusion denoise loop and the Higgs audio encoder. The Omnilingual
ASR wav2vec2 module keeps its inline copy: it is kept free of M* imports
so the parity harness can run it in the reference's environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ARNodeSubmodule.loopback_input_ids reads a decode step's tokens off the
sampler and overlays the rows that brought their own, with the row index
uploaded through pinned memory. It replaces the copies in Qwen3.5 and
Higgs Audio; Higgs's copy uploaded the index with a synchronous pageable
copy on every request's first decode step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each parallel-CFG node counts the step's branches from its own worker's
loop counter, and a branch that sat out sent zeros_like(latents). Had a
branch node counted fewer branches than combine_cfg (the 2-GPU config puts
LLM_cfg_* on another worker), the combine would have used those zeros as an
unconditional velocity. A node that sits out now sends a zero-row velocity,
which the combine tells apart on the host: it drops the branches it does
not read and fails the request if one it reads sat out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A shared mstar.utils.device helper replaces the backbone's local copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The image_gen shape inputs become a small LRU keyed by output size, and
the ViT's cu_seqlens, a two-element tensor, is staged through
to_device_nonblocking per image instead of cached per token count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Qwen3.5, Higgs Audio, Orpheus and the Qwen3-TTS Talker each carried a
check_stop and a check_stop_batched that had drifted apart (only Qwen3.5
had the in-order fast path). ARNodeSubmodule now implements both off a
TokenStop declaration (loop, token buffer, stop ids, sampler, optional
walk and generated-count key), and the four copies are gone. The tests
that compare the batched and per-request checks now run the shared path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
encode_image's format check moves into image_output_format, which BAGEL's
process_prompt calls for image output, so a bad value fails the request
before the denoise instead of in postprocess after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_device found a parameter by walking the module tree on every call,
and prepare_inputs / preprocess call it per row per step. Qwen3-Omni
patched that with a mixin for two submodules; the base now caches the
device and _apply (.to(), to_empty()) clears it, so the mixin is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The flag only changes how a lease_before_step packed region captured at
batch size 1 is leased; anywhere else it was silently ignored. The config
now raises ValueError at construction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
configs/wan22_sp2.yaml becomes wan22_cfg_parallel_2gpu.yaml, after
bagel_cfg_parallel_2gpu.yaml: its two ranks each run one guidance branch,
and nothing splits the sequence. The mechanism still rides the node's sp
group; the docs, comments and the shard_heads description now say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tables were cached per (length, device), so every new prompt length
added a device allocation that was never freed. The encoder now builds one
table per device at max_sequence_length and slices it; a longer prompt is
refused with ValueError. Evicting instead would free a table a captured
forward still reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The least-loaded DP replica pick rebuilt a load map over every live
request on each admission. The conductor now keeps per-worker counters,
updated where a request enters self.requests (_register_request) and on
the one way out of it (_drop_request), which both teardown paths take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_normalize passed non-uint8 patches through unnormalized, and the None
default normalizer raised a TypeError on bytes. The normalizer is now a
required argument and any other dtype is refused with ValueError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…graph

The block loop replays a whole step as one row, not one prompt per
replay. In installation.rst, "That backend needs flash-attn-4" followed
the LingBot sentence; the paragraph now reads in order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…edge

process_prompt returns the target length as ProcessPromptOutput metadata
(an internal kwarg), the conductor puts it in step metadata, and the
backbone builds the iteration-0 canvas on the device. The [C, target_len]
initial_canvas edge is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each value runs as its own warmup, in order, so the Cosmos3 DROID configs
warm batch sizes 1-8 with one entry instead of eight copies of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…exact

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…opt-in

The ragged attention resource now owns the mstar::ragged_attention custom
op, and its run() goes through it whenever it is traced by torch.compile.
Every compiled caller keeps the attention in its graph without opting in,
and every eager caller keeps the plain kernel call.

Sketch:
- Goal: a compiled region keeps ragged attention in its graph, so Inductor
  fuses the pointwise work around it, without the model choosing compile
  mechanics. Non-goal: changing eager or capture-only callers.
- Foundation: a resource-owned custom op, as with mstar::rope_apply_qk_inplace,
  mstar::rms_norm and mstar::ring_scatter, routed inside the manager's run()
  by torch.compiler.is_compiling(). Rejected: the opaque=True call-site flag
  (the model picks compile mechanics, against invariant 1); routing in
  RaggedAttentionCallable alone (a layer calling resource.run directly would
  still break, and the callable is a convenience, not the owner); opaque-object
  op arguments (newer than the torch floor in pyproject).
- New concepts: none. The op moves here from convenience.py. Its index-keyed
  list becomes _MANAGERS, a dict keyed by resource key (unique within the
  worker's engine), filled when a manager is built. A rebuild replaces its
  entry, so the table cannot grow and the key baked into a compiled graph
  does not change.
- Op contract, stated with the op: output shaped like q in the manager's dtype
  (the fake reads the dtype from the manager), freshly allocated, and no
  argument mutated.
- Per-model cost: none. Wan2.2 drops opaque=True.
- Deleted: RaggedAttentionCallable's opaque parameter and _run,
  _RAGGED_CALLABLES, _register_ragged, the op in convenience.py, and the
  flashinfer.py docstring saying ragged attention deliberately is not behind
  a custom op.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef and others added 23 commits October 23, 2026 18:39
…ngths

Fails on the per-length table cache, which allocated a device table for
every new prompt length; also checks a slice matches a table built at that
length and that a prompt past the table is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lashinfer

The backbone wraps Hugging Face modules whose norms aren't M*'s RMSNorm, so
it called flashinfer's rmsnorm and fused_add_rmsnorm directly. RMSNorm's
forward and forward_residual now have function forms, rms_norm and
add_rms_norm, that take a weight and eps; the backbone uses add_rms_norm
for its residual norms and the mstar::rms_norm op for the rest, the same
FlashInfer kernels on CUDA in half precision and the portable path
elsewhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mstar-project#377's per-device JSON tile tables replace this branch's in-code table: the
Qwen3-Omni Thinker and Talker tiles (swept on an H200) become
configs/E=128,N=1536,K=2048 and E=128,N=768,K=1024 for NVIDIA_H200, with
entries past the swept sizes carrying the default heuristic's two regimes so
larger batches keep the default tiles. The table, its Hopper gate and its test
are deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the ragged rebuild test, and one_row_per_step with no capture sizes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keeps the review fix it carried for Higgs: its loop-back row index goes up
through to_device_nonblocking instead of a blocking pageable copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ared norm, not flashinfer""

This reverts commit c30a4c5.
…heir stop as a TokenStop""

This reverts commit ab3db58.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… on for Wan2.2

On H200, routing every compiled ragged caller through the op made Z-Image
(attention_backend: flashinfer) trace its transformer as one graph per capture
bucket: +2.6 GiB of capture memory and 1.7-2.7x slower startup, with no step-time
gain since the step is captured. Wan2.2's DiT, compiled and uncaptured, is where
keeping attention in the graph pays, so it declares compile_op; everyone else
breaks at the attention as on main.

Also initialize the Qwen3 encoder test's parameters: FusedColumnLinear
allocates with torch.empty, so the test read whatever memory it reused and
failed after some other modules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef added a commit that referenced this pull request Oct 24, 2026
… perf push

The parts of the overnight perf push (draft #853) outside the per-model
folders, so the speech/language and generation model changes stack on top:

- Engine: ragged attention `shard_heads` (CFG-parallel nodes keep every head)
  and opt-in `compile_op` (`run` traces as the `mstar::ragged_attention` custom
  op); managers carry their resource key. `RaggedAttentionCallable` is deleted:
  callers bind the label with `functools.partial(resource.run, label=...)`.
  Piecewise `one_row_per_step` leases a packed region at batch size 1.
- Submodule base: `TokenStop` declaration on `ARNodeSubmodule` (gives
  `check_stop` / `check_stop_batched`), `loopback_input_ids`, cached
  `get_device`.
- Conductor: least-loaded data-parallel replica pick (`_worker_load`).
- API server: warmup `concurrency` (int or list) and one copy per DP replica;
  `api_switch_interval_s` config.
- Components: `rms_norm` / `add_rms_norm` helpers, `TopKRouter`
  `fused_softmax_topk` through the fused-MoE `router_topk` (now with a softmax
  mode), Qwen3 text encoder caches one rotary table and takes `padded`,
  `image_output_format`, `to_device_nonblocking`, threaded video decode,
  `DenoiseLoopSubmodule.ragged_for` via `resource.run`.
- fused_moe H200 tile configs for E=128.
- LTX-2.5: the one call site of the deleted `RaggedAttentionCallable`.
- Docs (serving, adding_models), diffusion-scaffold skill, waypoint benchmark
  log parsing, and the tests for all of the above.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef added a commit that referenced this pull request Oct 24, 2026
The per-model changes of the overnight perf push (draft #853) for the speech
and language models, on top of the shared engine and component changes:

- Qwen3.5: captured decode builds its rotary tables in the graph (the
  MSTAR_INGRAPH_DECODE_ROPE knob and its docs row go, the behaviour is the
  default), TokenStop, shared loop-back input ids, vision encoder block-loop
  capture, configs (api_switch_interval_s, warmups).
- Qwen3-Omni: code-predictor kernels (cp_kernels), fused softmax-top-k MoE
  router, batched audio encoder, decode-path host syncs removed, Thinker
  TokenStop, PD 2-GPU config. The Code2Wav first-chunk ramp is main's
  (codec_chunk_schedule, default (12, 13)); this commit keeps it as is.
- Qwen3-TTS, Fish S2-Pro, Orpheus, Higgs Audio, Omnilingual ASR, Kokoro,
  Chatterbox, OmniVoice, Whisper: their per-model perf changes, TokenStop
  adoptions, and split / bf16 configs.
- Their tests, and their sections of docs/models.rst (plus the Qwen3.5
  TokenStop example in docs/adding_models.rst).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef added a commit that referenced this pull request Oct 24, 2026
The per-model changes of the overnight perf push (draft #853) for the image,
video and world models, on top of the speech/language changes:

- BAGEL: CFG pruning (only the branches a step reads), output_format checked
  at intake, bounded shape caches, CFG-parallel 2-GPU config.
- Wan2.2: DiT self-attention on the ragged resource with compile_op, the
  FFN GELU in the GEMM epilogue, CFG-parallel (shard_heads=False) and DP
  2-GPU configs. The DiT still compiles only for compile_dit_sizes; other
  shapes run the same transformer (ragged attention included) eager.
- Cosmos3: fast whole-clip VAE decode, compiled batched DROID action step
  and its warmups, DP / SP 2-GPU configs (Edge's follow the chained 61/32
  windowed defaults and warmup).
- FLUX.2 klein, Z-Image, LingBot-World, V-JEPA 2: their per-model perf
  changes and configs (LingBot-World's fast config).
- Their tests (BAGEL, FLUX.2, Wan2.2, Wan VAE, Z-Image, Cosmos3, the waypoint
  rollout log parser), their sections of docs/models.rst, the BAGEL note in
  docs/clients.rst and the flash-attn-4 note in docs/installation.rst.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dghosef

dghosef commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Split into a stack: #858 (shared) → #859 (speech and language models) → #860 (image, video and world models).

@dghosef dghosef closed this Oct 24, 2026
dghosef added a commit that referenced this pull request Nov 1, 2026
* Engine, conductor, API server and shared components for the overnight perf push

The parts of the overnight perf push (draft #853) outside the per-model
folders, so the speech/language and generation model changes stack on top:

- Engine: ragged attention `shard_heads` (CFG-parallel nodes keep every head)
  and opt-in `compile_op` (`run` traces as the `mstar::ragged_attention` custom
  op); managers carry their resource key. `RaggedAttentionCallable` is deleted:
  callers bind the label with `functools.partial(resource.run, label=...)`.
  Piecewise `one_row_per_step` leases a packed region at batch size 1.
- Submodule base: `TokenStop` declaration on `ARNodeSubmodule` (gives
  `check_stop` / `check_stop_batched`), `loopback_input_ids`, cached
  `get_device`.
- Conductor: least-loaded data-parallel replica pick (`_worker_load`).
- API server: warmup `concurrency` (int or list) and one copy per DP replica;
  `api_switch_interval_s` config.
- Components: `rms_norm` / `add_rms_norm` helpers, `TopKRouter`
  `fused_softmax_topk` through the fused-MoE `router_topk` (now with a softmax
  mode), Qwen3 text encoder caches one rotary table and takes `padded`,
  `image_output_format`, `to_device_nonblocking`, threaded video decode,
  `DenoiseLoopSubmodule.ragged_for` via `resource.run`.
- fused_moe H200 tile configs for E=128.
- LTX-2.5: the one call site of the deleted `RaggedAttentionCallable`.
- Docs (serving, adding_models), diffusion-scaffold skill, waypoint benchmark
  log parsing, and the tests for all of the above.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Conductor: count DP replica load per (node group, replica) (R1.4)

The replica pick summed live requests per worker across every node group, so
a group sharing a worker with another (a DP2 group on ranks 0 and 1 beside a
single-replica group on rank 0) saw the other group's requests as its own load
and kept sending new requests to one replica. Each request now records its
replica per group (RequestData.dp_replicas), and the load is counted on those
keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Piecewise runner: apply one_row_per_step in its own bucket lookup (R1.5)

The flag was applied at the engine's early lease only, while the runner's
run and can_run derived the batch size from the request ids or segment
lengths they were passed, so a caller had to repeat real_bs=1 and the first
request id by hand or replay against a bucket the lease never promised. The
runner now resolves a one_row_per_step region as one row in _resolve (lease,
can_run, run) and trims run's request ids to the first, so the lease and the
replay agree by construction and the engine passes the step's batch size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* TokenStop: read the generated-token count without creating request state (P3-3)

With generated_key set, the stop check read the count through request_state,
which creates a state for a request it doesn't have one for; a check that
runs after the request's state was dropped left a fresh one behind for a
finished request. It reads request_states directly now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* TokenStepNode: declare token_stop in __init__ like every AR node (P3-2)

The mixin derived token_stop from stop_token_ids through a cached_property,
while the docs and every other AR node set token_stop in __init__. Canary and
Canary-Qwen now set it in __init__, the mixin drops stop_token_ids and the
property, and the module docstring no longer claims the stop checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Ragged attention: hold the custom op's managers weakly (P3-1)

The module-level key -> manager map the mstar::ragged_attention op reads was
never purged, so it kept every ragged manager (and its plan wrappers and
workspaces) alive past the engine that built it. A WeakValueDictionary drops
an entry with the engine's last reference; there is no resource teardown
hook to deregister from, and the op can only name a manager by key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: one_row_per_step, shard_heads and compile_op; warmup and switch-interval rows (R1.2, R1.3, P3-4)

adding_models.rst documents one_row_per_step (and that the whole step's
segments then count against max_segments_per_request), shard_heads and
compile_op, says the latter two are the model's declaration rather than YAML
overrides, and names RaggedAttentionConfig where it said "the config".
serving.rst says warmup sends each entry once per DP replica and that
concurrency copies can, not will, share a step, and no longer claims the
Qwen3.5 configs set api_switch_interval_s (they do in #859, not here). The
ragged config comments stop naming stacked-PR adopters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sampler: loopback_input_ids moves from ARNodeSubmodule onto SamplerResource (R1.7, P3-6)

The helper only reads the sampler's slot masters and the step's routed ids,
so it belongs to the resource that owns those masters rather than to every
AR submodule. Its comment no longer claims the row-index copy never blocks:
off CUDA, to_device_nonblocking is a plain copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Nits: router tie docstring, router test docstring, stray EOF blank line (P3-5, P3-6)

TopKRouter's fused_softmax_topk docstring says bf16 logits tie often, so an
opting-in model should count how often its outputs diverge; the router test's
docstring describes the tests it holds; mstar/utils/device.py matches main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Video decode: one thread policy from the preprocess pool and CPU affinity (R1.1)

VIDEO_DECODE_THREADS was min(8, os.cpu_count()) fixed at import: it counted
the host's CPUs rather than the ones a container's cpuset gives the process,
and ignored preprocess_torch_threads, the API server's preprocessing budget.
media_limits.video_decode_threads() now bounds a decode by torch's intra-op
pool (which that knob sets), by len(os.sched_getaffinity(0)) where the
platform has it, and by 8. V-JEPA2's own loader asks it too. Qwen3.5 decodes
through transformers' load_video, which takes no thread count; that stays a
follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Whisper: declare token_stop in __init__ (P3-2)

The Whisper decoder is the third EncoderDecoderNode, missed by the previous
commit: with the mixin no longer deriving token_stop from stop_token_ids, it
had no stop declaration and its stop checks fell back to the base, which
never stops (test_whisper_decoder_node caught it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Qwen3.5: decode loop-back goes through SamplerResource.loopback_input_ids (R2.a)

The decode branch of preprocess kept its own copy of the routed-row
index_copy_, whose row index was a blocking torch.tensor(..., device=).
The sampler's helper does the same copy with the index staged through
pinned memory. The test's fake sampler binds the real helper, so the
routed-row case now exercises it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Canary, Canary-Qwen, Whisper: TokenStop reads the class loop and sampler names (R2.b)

Canary-Qwen built its TokenStop from the module constants while Canary and
Whisper used self.DECODE_LOOP; all three now take both the loop and the
sampler from the class attributes, so a subclass that renames them stops on
the right loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Media limits: the video decode thread cap follows CPU affinity, not a CPU quota (R2.c)

video_decode_threads reads sched_getaffinity, so it honours a cpuset but
not a CFS quota (a container --cpus limit). The docstring and the
adding_models note now say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: the one-row piecewise runner captures batch size 1 only (R2.d)

A one_row_per_step config refuses capture sizes above 1, so the test runner
now captures only (1, 256). can_run(3, ...) then passes only because the
runner resolves the step as one row; with the old per-call batch size it
finds no bucket. Also drops a stray third blank line in
test_conductor_replica_assignment.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* TokenStop: check_stop reads a row's first element, as check_stop_batched does

merceod, #858 mstar/model/submodule_base.py:1036: check_stop took .item()
of the row where check_stop_batched reads [:, 0], so the two disagreed (the
per-request one raising) on a stop buffer wider than one element per row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Waypoint harness: serve_rollout's parsers strip the internal id's #<n> suffix too

merceod, #858 benchmark/waypoint/benchmark_streaming.py:565: the benchmark's
_dit_step_timestamps stripped the suffix, but serve_rollout._dit_schedule,
which runs before it, still matched ids exactly, so rows_per_step_histogram
came out empty. #860's parser hunk (schedule, overshoot veto, cleanup) moves
here beside the benchmark half.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Warmup: repeat and concurrency share one positive-integer check

merceod, #858 mstar/api_server/entrypoint.py:1377: the two checks spelled
the same predicate twice; one _positive_int keeps them in step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Warmup: one id form, warmup-<spec>.<level>.<copy>, and the first failure re-raised as is

merceod, #858 mstar/api_server/entrypoint.py:1468: three separators
(warmup-0, warmup-0-1, warmup-0.1, warmup-0.1-1) depending on level and
copy, and an error list built only to raise its first entry. The gather
still waits for every copy (return_exceptions) so none is cancelled
mid-request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* API server: api_switch_interval_s must be a positive number, checked before the model is built

merceod, #858 mstar/api_server/entrypoint.py:1993: float(interval) took
yaml's true as 1 s (logged "True s"), and 0 or "1ms" raised only after
the API-server model was built. Validated first now; bool, non-numbers and
values <= 0 are a ValueError. Whether the knob should exist (#849) stays
the user's call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs/serving.rst: the warmup copy count is the largest node group's replica count

merceod, #858 mstar/api_server/entrypoint.py:1425: the docs didn't say that
warmup multiplies every entry by the most replicas any node group serves, or
that a group with fewer replicas then sees the copies together and may leave
its one-request shape cold. A per-replica warmup from the conductor stays the
recorded follow-up (P3-4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Ragged attention: the mstar::ragged_attention op body calls the manager's _run

merceod, #858 mstar/engine/resources/attn/ragged/flashinfer.py:46: the op
repeated _run's wrapper lookup instead of calling it, so wrapper selection
lived in two places. (compiler.disable on _run is inert inside an op body.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Resources: every builder shards heads by EngineResourceInfo.head_shards, set by the engine

merceod, #858 mstar/engine/resources/attn/ragged/base.py:31: shard_heads
reached only the ragged family; the other six builders (paged, ring and
bounded KV, attention, cross-attention, recurrent state, linear attention)
divided heads by joint_comm_group.world_size, which can't tell CFG from SP.
A model that puts guidance branches on the sp slot and also binds one of
those would get its heads halved and fail at append/plan.

The engine now sets head_shards once per resource: the lockstep instance's
size, or 1 for a node that declares splits_heads = False (a NodeSubmodule
attribute, next to the other per-node declarations). RaggedAttentionConfig
.shard_heads is deleted. KVManager shards in build, like the others, rather
than in __init__; bounded KV refuses head_shards > 1 rather than any
multi-rank group. Callers that build EngineResourceInfo by hand over a real
TP group (Command A+ reference test, its checkpoint validator, the GPU
ragged test) pass head_shards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* wav2vec2: the frame lengths go up through to_device_nonblocking

merceod, #858 mstar/utils/h2d.py:21: wav2vec2's padded-batch mask kept its
own pin-then-copy instead of the shared helper. (The sampler's copy stays:
it pins straight from a Python list, XPU-aware, in one host allocation.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* MoE: the blocks take a fused router through router=, not a flag of their own

merceod, #858 mstar/model/components/moe.py:238: fused_softmax_topk was a
parameter of four block constructors only to reach the default TopKRouter,
while every block already takes router=. A model opts in by passing
router=TopKRouter(..., fused_softmax_topk=True).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: the get_device compile check joins test_submodule_device_cache.py

RA.M1: test_submodule_alloc_dtype.py's get_device test repeated main's
test_submodule_device_cache.py except for its no-recompile check, which
moves there as a case; the copy goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Resources: EngineResourceInfo without head_shards shards over its joint group

RA.F2: head_shards defaulted to 1 whatever joint_comm_group said, so a
resource built by hand over a TP group (Command A+'s reference test and
checkpoint validator, the GPU ragged test) kept every head unless the
caller remembered to pass it. None now resolves to the group's world
size (1 without a group); the engine still passes the node's own count.
The three call-site edits 41d22c2 needed are reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Engine: TP ranks always split a resource's heads; nodes sharing it must agree

RA.F1: with tp > 1, a node declaring splits_heads = False got head_shards
= 1 against its TP-split layers, and nodes sharing one spec that
disagreed were sized by whichever came first in set order, which can
differ across ranks (invariant 11). head_shards is now tp x (sp if
splits_heads else 1), the same for every node the resource serves, or
load raises ValueError naming them. splits_heads is about SP ranks only,
as its docstring and adding_models.rst now say.

Test: test_ragged_attention_cpu.py, the tp=2 cases of
test_a_node_whose_ranks_split_other_work_keeps_every_head and
test_nodes_sharing_a_resource_must_agree_on_splitting_heads fail
without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: test_startup_warmup.py says it covers the API server's process settings

RA.F3: the module docstring now names api_switch_interval_s beside
warmup_requests, and pytest is imported at the top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* HostRows.first_values: one row map, no in-order fast path beside it

#858 triage row 9 (merceod, submodule_base.py:1055): the in-order prefix
path and the row-map path had to agree. One dict of rid -> value off the
rows, then one lookup per requested rid; padded rows fall off the zip.
Cost (python -P microbench, M-series laptop, in-order batch): 4.0 -> 5.9
us at 64 rows, 1.1 -> 1.2 us at 8; an out-of-order batch gets cheaper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* TokenStop: one per-row verdict holds the walk test; no check_walk flag, no boxing

#858 triage row 10 (merceod, submodule_base.py:1069): the walk was tested
in check_stop, in check_stop_batched and, behind a check_walk flag, in
_token_stops, and check_stop boxed its one row into a 1-tuple and a
1-dict. _token_stops(rid, token, info) -> bool is now the one verdict
both checks call per row, and the only walk test (last, so a row that
goes on never reads its walk). Whisper overrides it per row (record the
token, stop only decode rows, mark stopped); its check_stop_batched,
which only pre-filtered a prompt step to word-timestamp requests, goes.

Cost (python -P microbench, 64 decode rows on the engine's info
wrapper, with row 9): 19.6 -> ~25 us a step, from the per-row call;
the pairs loop with check_stop boxing its row kept ~20 us.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Qwen3.5: the decode stop is a TokenStop declaration, not a copy of its check

Coordinator decision on #858 triage row 9: Qwen3.5's check_stop and
check_stop_batched restated ARNodeSubmodule's TokenStop path (decode-only
stop on its stop ids unless ignore_eos, or at max_tokens). The LLM node
now declares token_stop, as #859 already does, and both methods go. Its
stop tests build the real submodule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs/serving.rst: api_switch_interval_s is re-measured once #849 lands

B6 (coordinator decision): the knob and its validation stay; its row
says it goes if neutral after #849.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: the Qwen3.5 loop-cap test checks the real LLM submodule's stop

Follow-up to a6a1de8 (#858 triage row 9): test_model_kv_layouts'
Qwen3.5 cap test called LLMSubmodule.check_stop on a stand-in holding
only the stop ids; the stop is now the TokenStop the submodule declares,
so the test builds the submodule, as #859's version of it does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Delete check_stop_batched's unread step_walk parameter (858 RB.1)

No check_stop_batched override reads step_walk any more: each row's walk is
on its info. Drop it from the base signatures, the engine's call, and every
test caller and fake; the two engine-path tests now pin only that the
batched path is taken, and the fakes stop carrying the step_context the
engine read only for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: two blank lines before the new ragged-attention tests (858 RB.2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: drop the last step_context the engine's stop check no longer reads (858 RB.1)

The partway-raise Whisper test's batch fake still carried the step_context
that check_stop_for_batch read only for step_walk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Warmup: count data-parallel replicas from the worker graphs (merceod #858 row 3)

The warmup's replica count re-parsed node_groups from the raw YAML, a mirror
of WorkerGraph's ranks // (tp*sp) that floored where WorkerGraph asserts and
counted node groups no walk places. It now takes the model's worker graphs,
counted as the conductor's _pick_replicas counts them, and the YAML path is
gone.

get_worker_graphs is safe to call in the API server: only BAGEL's override
sets state (three placement flags derived from the same config, idempotent),
and nothing the API server process runs after the warmup reads them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 858 I1.1: one WorkerGraph.replica_ranks for a worker graph's DP replicas

The rule "the lockstep instances, or one rank apiece without TP/SP" was
written out in the warmup copy count, the conductor's replica pick and
assignment, GlobalParallelConfig's instance groups and the placement
check's single-instance test. All now read WorkerGraph.replica_ranks.
No behaviour change. The tests that faked a WorkerGraph with a
SimpleNamespace build a real one, so they follow the property.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 858 I1.2: serving docs count warmup copies from the worker graphs

Since the warmup took its replica count from the model's worker graphs, a
node group that no walk places adds no copies; the docs still described
the node-group count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Warmup: a replicated entry runs one request alone first

Warmup sent each entry `copies` times at once, `copies` being the most
replicas any worker graph has. Where only a cheap node is replicated (a VAE
and text encoder x2 beside one DiT SP2 instance), every copy reached the one
DiT together, it batched them, and its bs=1 shape stayed cold for the first
client. Past one copy, each level now runs one request (`warmup-{i}.{l}.solo`)
to its end before the `copies * concurrency` batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* merceod #858 row 15: one pinned H2D helper, capability-gated and list-aware

to_device_nonblocking decides pinning with DeviceCapability.of(device).pin_memory
instead of a cuda string check, so XPU takes the non-blocking pinned path too,
and takes a Python sequence plus a dtype, built straight into pinned memory
(one host allocation, one async copy). The hand-rolled copies switch to it:
the sampler's keep_mask, nemotron_duplex's device_counts (deleted) and chunk
copy, and the callers that built a pageable tensor only to hand it to the
helper (parakeet, wav2vec2, fastconformer, audio_features). audio_features
pads into pinned memory on the same capability check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 858 R3.1: warmup runs its solo once per entry, only when replicas are uneven

The one-request-alone warmup ran once per concurrency level and also in
fully replicated deployments, where every worker graph already sees one
copy. It now runs once per entry, before the levels, and only when some
placed worker graph has fewer replicas than the copies sent.
_data_parallel_replicas returns the most and fewest replicas from the same
worker graphs, and _run_warmup_requests takes that pair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 858 R3.2: the warmup solo logs its own outcome

The solo used to share the batch's try, so a failed solo skipped the batch
and the warning blamed the concurrency level, and the success line's time
included the solo. The solo now has its own try (from R3.1) and its own
done line, and the batch's time covers the batch alone. The startup line
says when each warmup runs once alone before its copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 858 R3.3: to_device_nonblocking says an already-pinned source is read in place

The docstring said the caller need not keep the source, but an
already-pinned CPU tensor is copied from in place: writing to it before
the copy lands changes what arrives. Say so, and point a reused buffer at
PinnedStager.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M1: copy an already-pinned host tensor to the device in place

The merge of main took #904's pageable path for every CPU tensor, so a
source that is already pinned (NemoLogMel.batch pads into pinned memory)
paid a second pinned allocation and a full-batch memcpy per call. Read
a pinned source in place again, and restore the docstring caveat that
the caller must not write to it until the copy lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M2: size video decode threads from usable_cpus, apart from the torch pool

video_decode_threads re-read CPU affinity beside main's usable_cpus(),
which also reads the cgroup quota, and was capped by torch's intra-op
pool, which main #903 shrank to a quarter of the CPUs (at most 4). That
cut V-JEPA2 and Cosmos3 decodes from 8 FFmpeg threads to 4, or 1 on a
small host. FFmpeg's frame threads are not torch intra-op threads, so
decode is now min(8, usable_cpus()). The docs paragraph that said both
that the quota is read and that it is not now says one thing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: test GLM-5.2's stop id at prefill

The merge ported GLM-5.2's prefill stop rules onto TokenStop without a
test: a stop id sampled on the prefill walk ends the reply at the first
decode step, whose token postprocess drops, and both stop checks agree;
with ignore_eos the prefill sets nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M4: declare a prefill-sampled first token on TokenStop

GLM-5.2 and Command A+ each hand-built the same rule beside TokenStop:
a stop id sampled by a prefill walk has no loop to stop, so the reply
ends at the loop's first step, whose token postprocess drops, and
max_tokens counts the prefill's token. TokenStop.prefill_walks now
declares those walks; the base keeps the flag in request_state and
applies both rules in check_stop and check_stop_batched, and
ended_at_prefill() tells postprocess to drop the closing token.

GLM-5.2's _token_stops / check_stop overrides and Command A+'s
_ended_at_prefill set, check_stop and cleanup_request are deleted.
Command A+ now sets the flag for max_tokens 1 too (no decode step
follows, and removal frees it) and keeps it until removal instead of
clearing it on the stopping step, as GLM-5.2 did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M1: model the mocked host tensor in the pin test as pageable

test_to_device_nonblocking_pins_where_the_device_capability_says builds
its host from Mock(spec=torch.Tensor), whose is_pinned() is truthy, so
it now took the already-pinned path. It is about the pageable source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* N1: copy a pinned source in place only when it is contiguous

.to() of a pinned non-contiguous tensor packs it into a pageable temp
first, so the copy blocks the caller. Such a source goes through the
pinned buffer like a pageable one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* N2: read usable_cpus once per process for video decode threads

video_decode_threads re-read the affinity and the cgroup files on every
decode. Cache it, as image_io.PNG_WORKERS does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* N3: say a generated_key counter must count the prefill token itself

prefill_walks adds the prefill's token to the loop count only when
TokenStop counts loop iterations; a submodule's own generated_key
counter is read as is. No model declares both today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* N4: the engine drops the closing token of a reply that ended at prefill

Declaring TokenStop.prefill_walks still made each model's postprocess
delete token_stop.buffer when ended_at_prefill(). Engine._postprocess_batch
now does it after postprocess, the one place every forward path's rows
are postprocessed; a submodule without prefill_walks pays one getattr
per batch. GLM-5.2's and Command A+'s own deletes go.

Tests that drove postprocess by hand (GLM-5.2's engine-cycle driver and
both models' prefill-stop cases) now call Engine._postprocess_batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* N4: say prefill_walks needs the loop to emit the token as buffer

The engine drops token_stop.buffer on the closing step, which removes
the token only where the loop's client edge carries that output (GLM-5.2,
Command A+). Qwen3.5's decode emits text_inputs, which this would miss.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LingBot-World: call the ragged resource through resource.run, as the scaffold does

Main's #926 added LingBot with RaggedAttentionCallable, which this PR deletes
in favour of functools.partial(resource.run, label=...); the module failed to
import (test_lingbot_world_shell collection, lingbot startup validation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test_subgroup_creation_order: the stub worker graph carries replica_ranks

Main's #705 test stubs a WorkerGraph; this PR's GlobalParallelConfig reads
WorkerGraph.replica_ranks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test_withheld_outputs: the fake check_stop_batched drops step_walk, as the base did

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* TokenStop: leave the token budget to the loop cap

TokenStop's stop check re-checked max_tokens, which every adopter's
get_loop_caps already enforces: the runtime caps the decode loop at the
same iteration (Python and Rust both stop the loop and refuse to speculate
past max_iters), and the conductor derives the cap and request_info.max_tokens
from one max_output_tokens, also when it cuts that to fit the context. So the
budget is checked once, by the cap. generated_key existed only to count that
budget and goes with it.

Tests that pinned the stop check ending a row at its budget now expect the
row to go on; the cap-vs-stop-check comparisons for the TokenStop adopters
are deleted (there is no second count to compare), and the two hand-driven
loops apply the request's cap as the worker does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* TokenStop: withhold a prefill-ended reply's closing token

A reply whose prefill sampled a stop id closes its decode loop on the loop's
first step, whose token comes after the reply's last. The engine dropped that
token after postprocess by probing the submodule for `token_stop`: a model
concept in engine code, and three routes missed it (a rows-only step skips
the per-request postprocess, the inline client emit reads the host rows, and
a row left empty by the pop was skipped by the stop check, stalling it).

The closing step now says so through the existing primitive: check_stop
returns StopCheck({loop}, withheld=(buffer,)) and check_stop_batched a
BatchedStopCheck. The engine's pop and its `getattr` are gone, and the flag is
read on the main thread after prefill's check_stop. The worker's inline emit
honours withheld outputs per row: such a row gets None, which both graph
runtimes (Python and Rust) route as that row's tensors, of which it has none.
That also closes main's gap where a withheld output still rode inline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Warmup docs: the solo run happens only when replicas are uneven

docs/serving.rst said every run of a warmup entry past one copy starts with one request alone; _run_warmup_requests sends that solo request only when some worker graph has fewer replicas than the most, once per entry. Say that, and that otherwise bs=1 warms only when concurrency includes 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* video_decode_threads: the cap of 8 is per decode

The docstring said the bound keeps concurrent decodes from oversubscribing; each decode starts its own threads, so it doesn't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Ragged attention: name each manager uniquely in the op registry

The mstar::ragged_attention op found its manager by resource key in a process-wide registry, so two live engines with the same key (tests, harnesses) handed one engine's compiled graph the other's plans. The op key is now the resource key plus a number unique in the process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Warmup: a replica count that fails leaves warmup at one copy

_data_parallel_replicas ran outside the warmup's try, so a config the worker graphs can't be built from (or no worker graphs: max([])) stopped startup, against the promise that a failing warmup is logged and skipped. It now logs and returns DataParallelReplicas(1, 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* one_row_per_step docs: what declare_step receives

Under the flag the region's declare_step gets the first request alone with every request's seq_lens, so max_segments_per_request is a per-step ceiling there. Say so where the flag is documented.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: MSTAR_PY_SWITCH_INTERVAL_SEC gets its worker-scheduling row

The worker reads MSTAR_PY_SWITCH_INTERVAL_SEC at the top of Worker.run, but
docs/environment_variables.rst had no row for it. It gets one under Worker
scheduling, naming Worker.run as its reader. The variable applies to workers
only: the API server's interval stays the serving config's
api_switch_interval_s, whose measurements covered the API server alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Comments: the decode budget is the loop cap, not check_stop

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: a prefill_walks token counts toward the budget, so the loop cap is one fewer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: say which MSTAR_PY_SWITCH_INTERVAL_SEC values the worker ignores

The worker passes the value to sys.setswitchinterval, which refuses only
values of 0 or below, so `nan` and `inf` are applied rather than ignored
with a warning, as the row implied. The row now names what is ignored: a
value float() cannot parse, or one of 0 or below.

_env_number's docstring cited this variable as raising a too-small value
to a minimum; it ignores one instead, so only MSTAR_RECOMPILE_LIMIT is
left as the precedent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: point api_switch_interval_s to the workers' switch-interval variable

The key sets the API server's GIL switch interval only. The worker
processes read theirs from MSTAR_PY_SWITCH_INTERVAL_SEC, whose row
already points back to this key; the serving config row now says so too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Ragged attention: update a test docstring for unique op keys

test_a_compiled_region_attends_through_a_rebuilt_resource still said the
op names its manager by resource key, with a rebuilt resource taking over
that key. Each manager now registers under its own op key, the resource
key plus a number unique in the process. The docstring now says what the
test checks: a region compiled against one manager, then called with a
manager rebuilt under the same resource key, attends through the rebuilt
one's plan while the old one is still registered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Comments and docs: a shard_over_sp=False ragged resource splits its heads over TP only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Head sharding: one per-resource switch, shard_over_sp; NodeSubmodule.splits_heads goes

Every resource now shards its heads over all TP x SP ranks of the node's
lockstep group (EngineResourceInfo.head_shards, a property), and a ragged
resource that each SP rank runs whole says so with
RaggedAttentionConfig(shard_over_sp=False). The node-level splits_heads flag,
the engine's per-node fold and its agreement check, and their docs and tests
are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Name the TP x SP head-sharding test for the kinds it covers; reflow a docs paragraph

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Say the api_switch_interval_s numbers predate #849's output-path rewrite

The row still said the knob would be re-measured once #849 lands. It has
landed and changed the message-thread and data-worker path the knob
tunes, so the numbers are from before that change and the re-measure is
still pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the head-sharding paragraph from the submodule attributes section

It was there to document the removed splits_heads attribute; what is
left repeats the RaggedAttentionConfig row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the TP x SP head-sharding test that main's sizing already passes

Head sizing now equals main's, so the six cases pinned nothing this
branch changes. The shard_over_sp=False test stays; the helper loses its
recurrent branch, which only the deleted test used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the shard_over_sp sizing test, which main's sizing already passes

Main already sizes a shard_over_sp=False ragged attention over the TP
group, so the test pinned nothing this branch changes. The file's
coverage goes back to main's plus the manager registry test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Conductor: build its instance table from WorkerGraph.replica_ranks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pin video decode threads to the CPUs the plan leaves under pinning

A case with a pinned CPU fails if video_decode_threads goes back to
usable_cpus(); the MSTAR_STEP_THREAD_CORE row names FFmpeg's per-decode
threads among what the pins shrink.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* API server: drop the api_switch_interval_s knob

Its gain was measured before #849 and #939 changed the message-thread and
data-worker path, and the re-measure it was waiting on never ran. The
serving-config key, its validation, docs rows and test go; workers keep
main's MSTAR_PY_SWITCH_INTERVAL_SEC unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: the TP2 publish test's KV managers hold one rank's head

KVManager shards its config in build, so the test's direct constructor
shards it the same way and each rank moves its own shard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* API server: drop warmup_requests' concurrency option

No config sets it, so its parsing, docs and tests covered a dead path. Each
entry still goes out once per data-parallel replica, now as
warmup-<spec>.<copy>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: Canary's decode cap leaves the prefill's token in the budget

#794's cap-versus-stop-check agreement tests for Canary and Canary-Qwen assumed
a stop check that counts max_tokens; TokenStop leaves the budget to the loop's
cap, so they pin the cap instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: last_prefill_flag for a prompt prefilled over several passes; GLM-5.2 test comment names the loop cap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
dghosef added a commit that referenced this pull request Nov 1, 2026
* Engine, conductor, API server and shared components for the overnight perf push

The parts of the overnight perf push (draft #853) outside the per-model
folders, so the speech/language and generation model changes stack on top:

- Engine: ragged attention `shard_heads` (CFG-parallel nodes keep every head)
  and opt-in `compile_op` (`run` traces as the `mstar::ragged_attention` custom
  op); managers carry their resource key. `RaggedAttentionCallable` is deleted:
  callers bind the label with `functools.partial(resource.run, label=...)`.
  Piecewise `one_row_per_step` leases a packed region at batch size 1.
- Submodule base: `TokenStop` declaration on `ARNodeSubmodule` (gives
  `check_stop` / `check_stop_batched`), `loopback_input_ids`, cached
  `get_device`.
- Conductor: least-loaded data-parallel replica pick (`_worker_load`).
- API server: warmup `concurrency` (int or list) and one copy per DP replica;
  `api_switch_interval_s` config.
- Components: `rms_norm` / `add_rms_norm` helpers, `TopKRouter`
  `fused_softmax_topk` through the fused-MoE `router_topk` (now with a softmax
  mode), Qwen3 text encoder caches one rotary table and takes `padded`,
  `image_output_format`, `to_device_nonblocking`, threaded video decode,
  `DenoiseLoopSubmodule.ragged_for` via `resource.run`.
- fused_moe H200 tile configs for E=128.
- LTX-2.5: the one call site of the deleted `RaggedAttentionCallable`.
- Docs (serving, adding_models), diffusion-scaffold skill, waypoint benchmark
  log parsing, and the tests for all of the above.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Conductor: count DP replica load per (node group, replica) (R1.4)

The replica pick summed live requests per worker across every node group, so
a group sharing a worker with another (a DP2 group on ranks 0 and 1 beside a
single-replica group on rank 0) saw the other group's requests as its own load
and kept sending new requests to one replica. Each request now records its
replica per group (RequestData.dp_replicas), and the load is counted on those
keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Piecewise runner: apply one_row_per_step in its own bucket lookup (R1.5)

The flag was applied at the engine's early lease only, while the runner's
run and can_run derived the batch size from the request ids or segment
lengths they were passed, so a caller had to repeat real_bs=1 and the first
request id by hand or replay against a bucket the lease never promised. The
runner now resolves a one_row_per_step region as one row in _resolve (lease,
can_run, run) and trims run's request ids to the first, so the lease and the
replay agree by construction and the engine passes the step's batch size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* TokenStop: read the generated-token count without creating request state (P3-3)

With generated_key set, the stop check read the count through request_state,
which creates a state for a request it doesn't have one for; a check that
runs after the request's state was dropped left a fresh one behind for a
finished request. It reads request_states directly now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* TokenStepNode: declare token_stop in __init__ like every AR node (P3-2)

The mixin derived token_stop from stop_token_ids through a cached_property,
while the docs and every other AR node set token_stop in __init__. Canary and
Canary-Qwen now set it in __init__, the mixin drops stop_token_ids and the
property, and the module docstring no longer claims the stop checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Ragged attention: hold the custom op's managers weakly (P3-1)

The module-level key -> manager map the mstar::ragged_attention op reads was
never purged, so it kept every ragged manager (and its plan wrappers and
workspaces) alive past the engine that built it. A WeakValueDictionary drops
an entry with the engine's last reference; there is no resource teardown
hook to deregister from, and the op can only name a manager by key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: one_row_per_step, shard_heads and compile_op; warmup and switch-interval rows (R1.2, R1.3, P3-4)

adding_models.rst documents one_row_per_step (and that the whole step's
segments then count against max_segments_per_request), shard_heads and
compile_op, says the latter two are the model's declaration rather than YAML
overrides, and names RaggedAttentionConfig where it said "the config".
serving.rst says warmup sends each entry once per DP replica and that
concurrency copies can, not will, share a step, and no longer claims the
Qwen3.5 configs set api_switch_interval_s (they do in #859, not here). The
ragged config comments stop naming stacked-PR adopters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sampler: loopback_input_ids moves from ARNodeSubmodule onto SamplerResource (R1.7, P3-6)

The helper only reads the sampler's slot masters and the step's routed ids,
so it belongs to the resource that owns those masters rather than to every
AR submodule. Its comment no longer claims the row-index copy never blocks:
off CUDA, to_device_nonblocking is a plain copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Nits: router tie docstring, router test docstring, stray EOF blank line (P3-5, P3-6)

TopKRouter's fused_softmax_topk docstring says bf16 logits tie often, so an
opting-in model should count how often its outputs diverge; the router test's
docstring describes the tests it holds; mstar/utils/device.py matches main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Video decode: one thread policy from the preprocess pool and CPU affinity (R1.1)

VIDEO_DECODE_THREADS was min(8, os.cpu_count()) fixed at import: it counted
the host's CPUs rather than the ones a container's cpuset gives the process,
and ignored preprocess_torch_threads, the API server's preprocessing budget.
media_limits.video_decode_threads() now bounds a decode by torch's intra-op
pool (which that knob sets), by len(os.sched_getaffinity(0)) where the
platform has it, and by 8. V-JEPA2's own loader asks it too. Qwen3.5 decodes
through transformers' load_video, which takes no thread count; that stays a
follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Whisper: declare token_stop in __init__ (P3-2)

The Whisper decoder is the third EncoderDecoderNode, missed by the previous
commit: with the mixin no longer deriving token_stop from stop_token_ids, it
had no stop declaration and its stop checks fell back to the base, which
never stops (test_whisper_decoder_node caught it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Qwen3.5: decode loop-back goes through SamplerResource.loopback_input_ids (R2.a)

The decode branch of preprocess kept its own copy of the routed-row
index_copy_, whose row index was a blocking torch.tensor(..., device=).
The sampler's helper does the same copy with the index staged through
pinned memory. The test's fake sampler binds the real helper, so the
routed-row case now exercises it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Canary, Canary-Qwen, Whisper: TokenStop reads the class loop and sampler names (R2.b)

Canary-Qwen built its TokenStop from the module constants while Canary and
Whisper used self.DECODE_LOOP; all three now take both the loop and the
sampler from the class attributes, so a subclass that renames them stops on
the right loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Media limits: the video decode thread cap follows CPU affinity, not a CPU quota (R2.c)

video_decode_threads reads sched_getaffinity, so it honours a cpuset but
not a CFS quota (a container --cpus limit). The docstring and the
adding_models note now say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: the one-row piecewise runner captures batch size 1 only (R2.d)

A one_row_per_step config refuses capture sizes above 1, so the test runner
now captures only (1, 256). can_run(3, ...) then passes only because the
runner resolves the step as one row; with the old per-call batch size it
finds no bucket. Also drops a stray third blank line in
test_conductor_replica_assignment.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* TokenStop: check_stop reads a row's first element, as check_stop_batched does

merceod, #858 mstar/model/submodule_base.py:1036: check_stop took .item()
of the row where check_stop_batched reads [:, 0], so the two disagreed (the
per-request one raising) on a stop buffer wider than one element per row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Waypoint harness: serve_rollout's parsers strip the internal id's #<n> suffix too

merceod, #858 benchmark/waypoint/benchmark_streaming.py:565: the benchmark's
_dit_step_timestamps stripped the suffix, but serve_rollout._dit_schedule,
which runs before it, still matched ids exactly, so rows_per_step_histogram
came out empty. #860's parser hunk (schedule, overshoot veto, cleanup) moves
here beside the benchmark half.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Warmup: repeat and concurrency share one positive-integer check

merceod, #858 mstar/api_server/entrypoint.py:1377: the two checks spelled
the same predicate twice; one _positive_int keeps them in step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Warmup: one id form, warmup-<spec>.<level>.<copy>, and the first failure re-raised as is

merceod, #858 mstar/api_server/entrypoint.py:1468: three separators
(warmup-0, warmup-0-1, warmup-0.1, warmup-0.1-1) depending on level and
copy, and an error list built only to raise its first entry. The gather
still waits for every copy (return_exceptions) so none is cancelled
mid-request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* API server: api_switch_interval_s must be a positive number, checked before the model is built

merceod, #858 mstar/api_server/entrypoint.py:1993: float(interval) took
yaml's true as 1 s (logged "True s"), and 0 or "1ms" raised only after
the API-server model was built. Validated first now; bool, non-numbers and
values <= 0 are a ValueError. Whether the knob should exist (#849) stays
the user's call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs/serving.rst: the warmup copy count is the largest node group's replica count

merceod, #858 mstar/api_server/entrypoint.py:1425: the docs didn't say that
warmup multiplies every entry by the most replicas any node group serves, or
that a group with fewer replicas then sees the copies together and may leave
its one-request shape cold. A per-replica warmup from the conductor stays the
recorded follow-up (P3-4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Ragged attention: the mstar::ragged_attention op body calls the manager's _run

merceod, #858 mstar/engine/resources/attn/ragged/flashinfer.py:46: the op
repeated _run's wrapper lookup instead of calling it, so wrapper selection
lived in two places. (compiler.disable on _run is inert inside an op body.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Resources: every builder shards heads by EngineResourceInfo.head_shards, set by the engine

merceod, #858 mstar/engine/resources/attn/ragged/base.py:31: shard_heads
reached only the ragged family; the other six builders (paged, ring and
bounded KV, attention, cross-attention, recurrent state, linear attention)
divided heads by joint_comm_group.world_size, which can't tell CFG from SP.
A model that puts guidance branches on the sp slot and also binds one of
those would get its heads halved and fail at append/plan.

The engine now sets head_shards once per resource: the lockstep instance's
size, or 1 for a node that declares splits_heads = False (a NodeSubmodule
attribute, next to the other per-node declarations). RaggedAttentionConfig
.shard_heads is deleted. KVManager shards in build, like the others, rather
than in __init__; bounded KV refuses head_shards > 1 rather than any
multi-rank group. Callers that build EngineResourceInfo by hand over a real
TP group (Command A+ reference test, its checkpoint validator, the GPU
ragged test) pass head_shards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* wav2vec2: the frame lengths go up through to_device_nonblocking

merceod, #858 mstar/utils/h2d.py:21: wav2vec2's padded-batch mask kept its
own pin-then-copy instead of the shared helper. (The sampler's copy stays:
it pins straight from a Python list, XPU-aware, in one host allocation.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* MoE: the blocks take a fused router through router=, not a flag of their own

merceod, #858 mstar/model/components/moe.py:238: fused_softmax_topk was a
parameter of four block constructors only to reach the default TopKRouter,
while every block already takes router=. A model opts in by passing
router=TopKRouter(..., fused_softmax_topk=True).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: the get_device compile check joins test_submodule_device_cache.py

RA.M1: test_submodule_alloc_dtype.py's get_device test repeated main's
test_submodule_device_cache.py except for its no-recompile check, which
moves there as a case; the copy goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Resources: EngineResourceInfo without head_shards shards over its joint group

RA.F2: head_shards defaulted to 1 whatever joint_comm_group said, so a
resource built by hand over a TP group (Command A+'s reference test and
checkpoint validator, the GPU ragged test) kept every head unless the
caller remembered to pass it. None now resolves to the group's world
size (1 without a group); the engine still passes the node's own count.
The three call-site edits 41d22c233 needed are reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Engine: TP ranks always split a resource's heads; nodes sharing it must agree

RA.F1: with tp > 1, a node declaring splits_heads = False got head_shards
= 1 against its TP-split layers, and nodes sharing one spec that
disagreed were sized by whichever came first in set order, which can
differ across ranks (invariant 11). head_shards is now tp x (sp if
splits_heads else 1), the same for every node the resource serves, or
load raises ValueError naming them. splits_heads is about SP ranks only,
as its docstring and adding_models.rst now say.

Test: test_ragged_attention_cpu.py, the tp=2 cases of
test_a_node_whose_ranks_split_other_work_keeps_every_head and
test_nodes_sharing_a_resource_must_agree_on_splitting_heads fail
without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: test_startup_warmup.py says it covers the API server's process settings

RA.F3: the module docstring now names api_switch_interval_s beside
warmup_requests, and pytest is imported at the top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* HostRows.first_values: one row map, no in-order fast path beside it

#858 triage row 9 (merceod, submodule_base.py:1055): the in-order prefix
path and the row-map path had to agree. One dict of rid -> value off the
rows, then one lookup per requested rid; padded rows fall off the zip.
Cost (python -P microbench, M-series laptop, in-order batch): 4.0 -> 5.9
us at 64 rows, 1.1 -> 1.2 us at 8; an out-of-order batch gets cheaper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* TokenStop: one per-row verdict holds the walk test; no check_walk flag, no boxing

#858 triage row 10 (merceod, submodule_base.py:1069): the walk was tested
in check_stop, in check_stop_batched and, behind a check_walk flag, in
_token_stops, and check_stop boxed its one row into a 1-tuple and a
1-dict. _token_stops(rid, token, info) -> bool is now the one verdict
both checks call per row, and the only walk test (last, so a row that
goes on never reads its walk). Whisper overrides it per row (record the
token, stop only decode rows, mark stopped); its check_stop_batched,
which only pre-filtered a prompt step to word-timestamp requests, goes.

Cost (python -P microbench, 64 decode rows on the engine's info
wrapper, with row 9): 19.6 -> ~25 us a step, from the per-row call;
the pairs loop with check_stop boxing its row kept ~20 us.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Qwen3.5: the decode stop is a TokenStop declaration, not a copy of its check

Coordinator decision on #858 triage row 9: Qwen3.5's check_stop and
check_stop_batched restated ARNodeSubmodule's TokenStop path (decode-only
stop on its stop ids unless ignore_eos, or at max_tokens). The LLM node
now declares token_stop, as #859 already does, and both methods go. Its
stop tests build the real submodule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs/serving.rst: api_switch_interval_s is re-measured once #849 lands

B6 (coordinator decision): the knob and its validation stay; its row
says it goes if neutral after #849.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: the Qwen3.5 loop-cap test checks the real LLM submodule's stop

Follow-up to a6a1de8bf (#858 triage row 9): test_model_kv_layouts'
Qwen3.5 cap test called LLMSubmodule.check_stop on a stand-in holding
only the stop ids; the stop is now the TokenStop the submodule declares,
so the test builds the submodule, as #859's version of it does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Delete check_stop_batched's unread step_walk parameter (858 RB.1)

No check_stop_batched override reads step_walk any more: each row's walk is
on its info. Drop it from the base signatures, the engine's call, and every
test caller and fake; the two engine-path tests now pin only that the
batched path is taken, and the fakes stop carrying the step_context the
engine read only for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: two blank lines before the new ragged-attention tests (858 RB.2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: drop the last step_context the engine's stop check no longer reads (858 RB.1)

The partway-raise Whisper test's batch fake still carried the step_context
that check_stop_for_batch read only for step_walk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* KV: one rule for whether a prefix cache opens

PagedKVConfig.prefix_cache_opens(world_size, checkpoint_named) states, from
facts fixed at startup, whether a pool opens its prefix cache: prefix_cache on,
one rank or prefix_cache_tp, and a checkpoint or a salt to root it in. The
engine's checkpoint gate and the pool's world-size gate now read it, so the
conductor can ask the same question without a worker report. The engine
applies the checkpoint half and the pool its world-size half, since each owns
one input. A process that must not download the checkpoint reads the
checkpoint half as names_checkpoint(model): whether the model overrides
Model.checkpoint_path, read without calling it (BAGEL's downloads).

Two readers that took the raw prefix_cache flag read the rule too.

Behaviour changes:
- The data worker keys a prompt only for a pool whose cache can open at its
  node groups' smallest instance size and with names_checkpoint, so it no
  longer keys prompts under DP x TP, nor for a model that names no
  checkpoint and sets no salt (no model that declares PrefixKeys today).
- It reads a request's prefix_cache as KVReqConfig does (unset or None on,
  otherwise its truth), so prefix_cache: 0 no longer keys a prompt the
  worker serves uncached.
- An engine that does not hold a cache takes it as open only where the
  rule's checkpoint half holds, so for a model that names no checkpoint and
  sets no salt it no longer marks a keyed walk's other nodes (an image
  encoder) as item encoders, which changes what that engine compiles there.
None changes what a worker caches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Conductor: pick DP replicas by cost over routing mirrors

#858 sends a request to the replica with the fewest live requests, which
knows nothing of what a replica holds: a request whose prefix one replica
has cached still lands on whichever is idler. The pick now sums, per replica,
weighted costs from conductor-side mirrors, all in request-equivalents:

- the forward mirror, the live requests on the replica, counted per set of
  node groups that pick together (as #858 counted per group), and only for a
  request with a partition on them that is not done at ingest. It replaces
  _replica_load and RequestData.dp_replicas.
- a mirror per resource whose spec offers one through the new
  NodeResourceSpec.routing_mirror(RoutingContext) hook (protocol
  RoutingMirror: score, add_request, remove_request, each given the
  request's ResourceReqConfig for the resource). KVSpec offers one for a
  paged cache whose prefix cache opens (PagedKVConfig.prefix_cache_opens): a
  per-replica LRU of page keys, scoring minus the share of the request's
  prompt positions predicted cached. The worker never matches a prompt's
  last page, so a hit weighs less than one live request at the default
  weights, and a mirror only scores a request that runs one of the groups
  holding the resource's nodes.

The conductor now builds and validates a request's resource configs before
the pick, the pick records nothing, and the router records the placement
just before dispatch, so a refused request leaves no trace. Groups whose
replicas sit on the same ranks pick one index together, and the
stream-driven-walk check accepts worker graphs on such groups. The weights
are the serving config's routing_weights (load and resource keys, finite and
>= 0, checked in validate_deployment, 1.0 by default). The request's handle
lives on RequestData and is released in _drop_request. One counter,
mstar_routing_predicted_saving_total, with a Grafana panel, reports what the
mirrors predicted. Workers send nothing new; the prediction is approximate
and uncorrected. With no mirror and no two groups on the same ranks, the
pick is #858's, except that a request no longer counts on a group none of
its live partitions runs.

A replica is `WorkerGraph.replica_instances` (each lockstep instance, or one
rank without TP/SP), which the router, the placement check, the conductor's
worker assignment and GlobalParallelConfig now all read.

The router lives in mstar/conductor/routing.py and the KV mirror next to the
KV resource; neither imports the manager or its kernels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Routing: mirrors report named facts, one function prices them

A routing mirror now reports facts instead of a score.
RoutingMirror.facts(config) returns one RoutingFacts row per replica, with
named counts whose identity is 0: today `work` (the prompt positions of
every label the request opens) and `kv_hit` (of those, the positions
predicted cached). The router adds the rows of every mirror on a replica
field by field and prices the sum once with one plain function,
routing_cost(facts, weights) in mstar/conductor/routing.py:
-prefix * kv_hit / work, and 0 with no KV facts. Pricing the sum keeps the
prefix credit below one live request at the default weights however many
keyed caches share the replicas. A replica's cost is load * its live
requests, counted once per set of groups that pick together, plus that
price. A field that does not add (a recurrent snapshot list, say) would
need its own merge in the router.

routing_weights now has one key per term, `load` and `prefix`, both 1.0 by
default, as the RoutingWeights NamedTuple. validate_deployment rejects any
other key, including v1's per-resource `kv`, a value that is not a
mapping, and load 0 beside another weight above 0; the check is a
placement check now. `prefix: 0` gives the load-only pick. The
predicted-saving counter is labelled by `term` (`prefix`), not `resource`,
and the Grafana panel and its description follow. Warmup copies of a
data-parallel deployment run with prefix_cache=False unless the spec sets
it, so a prefix weight above load's cannot gather them on one replica; the
cost is that the prefill from cached pages stays unwarmed.

A spec is asked for a mirror once per set of groups with replicas that
holds one of its nodes. RoutingContext gives the replica count, the ranks
per replica, the walks of the worker graphs there holding the spec's
nodes, the resource's keyed labels with the walks that key each, and
whether the model names a checkpoint. The router reads those from the
model once (keyed_labels, names_checkpoint) so the resource layer imports
nothing from the model layer. KVSpec offers a mirror only where one of
those walks keys one of its labels and the cache opens
(prefix_cache_opens), for those labels. So BAGEL with CFG-parallel nodes
gets one mirror, on LLM's replicas, also with the CFG nodes in LLM's
group, which v1's one-node rule dropped, and a KV cache no walk keys gets
none. The conductor no longer passes keyed walks or a checkpoint callable
to the router.

The KV mirror runs the worker's own PrefixIndex per replica, over a
counting arena (owner counts and a free list; PrefixIndex now sizes itself
from the arena's owner counts), and a request takes KVManager's steps at
dispatch: the lease of its matched pages (never the last key's), its own
pages, evicting on a shortfall, and index entries for its whole keyed
pages only, capped by retention. v1's hand copy indexed a prompt's partial
last page, which the worker never does, and held a fully matched prompt's
last page, which the worker doesn't lease, so on the keys the data worker
sends its eviction diverged. remove_request(handle, ran) releases the
pages, and when no pass came back for a partition of the request with a
walk on the resource's node groups (aborted while queued, refused at
admission) also drops the entries it made that nothing else holds, which
the worker never indexed. The module docstring lists what the mirror does
not model. add_request no longer takes the picked row, which no mirror
read.

One CPU test runs a real KVManager beside the mirror over an interleaved
sequence, with keys built as the data worker builds them, and checks that
every predicted hit equals the hit the cache served.

Removed: RoutingMirror.score, the score loop, _Mirror.weight and
_Mirror.node, Placement.savings and the per-node facts rows, _ReplicaPages,
ReplicaRouter's walks and checkpoint_named parameters, the per-resource
routing_weights keys, and the two-label release-order test, which pinned an
order the worker doesn't use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Warmup: count data-parallel replicas from the worker graphs (merceod #858 row 3)

The warmup's replica count re-parsed node_groups from the raw YAML, a mirror
of WorkerGraph's ranks // (tp*sp) that floored where WorkerGraph asserts and
counted node groups no walk places. It now takes the model's worker graphs,
counted as the conductor's _pick_replicas counts them, and the YAML path is
gone.

get_worker_graphs is safe to call in the API server: only BAGEL's override
sets state (three placement flags derived from the same config, idempotent),
and nothing the API server process runs after the warmup reads them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 858 I1.1: one WorkerGraph.replica_ranks for a worker graph's DP replicas

The rule "the lockstep instances, or one rank apiece without TP/SP" was
written out in the warmup copy count, the conductor's replica pick and
assignment, GlobalParallelConfig's instance groups and the placement
check's single-instance test. All now read WorkerGraph.replica_ranks.
No behaviour change. The tests that faked a WorkerGraph with a
SimpleNamespace build a real one, so they follow the property.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 858 I1.2: serving docs count warmup copies from the worker graphs

Since the warmup took its replica count from the model's worker graphs, a
node group that no walk places adds no copies; the docs still described
the node-group count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Routing: a predicted prefix hit is worth up to 8 live requests by default

routing_weights.prefix defaults to 8 instead of 1, so a request follows its
prefix to a busier replica instead of only breaking ties. The term stays a
share of the request's work rather than a token count: the tokens a hit must
save to be worth one live request (decode cost per request times reply length
over prefill cost per token) is 3,073 on Qwen3.5 0.8B, 626 on 9B and 464 on
BAGEL, while one share weight fits all three. In simulation (this router and
mirror, replicas timed by H200 measurements) 8 was the best weight on 0.8B and
9B, ahead of the best token price, and above 16 a hot prefix piled onto one
replica; on BAGEL DP2 it cut follow-on TTFT p99 by about a third.

Warmup copies still spread, since they run with the prefix cache off; the test
now says so. The test of a hot prefix pins how many requests it holds before
spreading, and fails at the old default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Warmup: a replicated entry runs one request alone first

Warmup sent each entry `copies` times at once, `copies` being the most
replicas any worker graph has. Where only a cheap node is replicated (a VAE
and text encoder x2 beside one DiT SP2 instance), every copy reached the one
DiT together, it batched them, and its bs=1 shape stayed cold for the first
client. Past one copy, each level now runs one request (`warmup-{i}.{l}.solo`)
to its end before the `copies * concurrency` batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* merceod #858 row 15: one pinned H2D helper, capability-gated and list-aware

to_device_nonblocking decides pinning with DeviceCapability.of(device).pin_memory
instead of a cuda string check, so XPU takes the non-blocking pinned path too,
and takes a Python sequence plus a dtype, built straight into pinned memory
(one host allocation, one async copy). The hand-rolled copies switch to it:
the sampler's keep_mask, nemotron_duplex's device_counts (deleted) and chunk
copy, and the callers that built a pageable tensor only to hand it to the
helper (parakeet, wav2vec2, fastconformer, audio_features). audio_features
pads into pinned memory on the same capability check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 858 R3.1: warmup runs its solo once per entry, only when replicas are uneven

The one-request-alone warmup ran once per concurrency level and also in
fully replicated deployments, where every worker graph already sees one
copy. It now runs once per entry, before the levels, and only when some
placed worker graph has fewer replicas than the copies sent.
_data_parallel_replicas returns the most and fewest replicas from the same
worker graphs, and _run_warmup_requests takes that pair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 858 R3.2: the warmup solo logs its own outcome

The solo used to share the batch's try, so a failed solo skipped the batch
and the warning blamed the concurrency level, and the success line's time
included the solo. The solo now has its own try (from R3.1) and its own
done line, and the batch's time covers the batch alone. The startup line
says when each warmup runs once alone before its copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 858 R3.3: to_device_nonblocking says an already-pinned source is read in place

The docstring said the caller need not keep the source, but an
already-pinned CPU tensor is copied from in place: writing to it before
the copy lands changes what arrives. Say so, and point a reused buffer at
PinnedStager.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M1: copy an already-pinned host tensor to the device in place

The merge of main took #904's pageable path for every CPU tensor, so a
source that is already pinned (NemoLogMel.batch pads into pinned memory)
paid a second pinned allocation and a full-batch memcpy per call. Read
a pinned source in place again, and restore the docstring caveat that
the caller must not write to it until the copy lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M2: size video decode threads from usable_cpus, apart from the torch pool

video_decode_threads re-read CPU affinity beside main's usable_cpus(),
which also reads the cgroup quota, and was capped by torch's intra-op
pool, which main #903 shrank to a quarter of the CPUs (at most 4). That
cut V-JEPA2 and Cosmos3 decodes from 8 FFmpeg threads to 4, or 1 on a
small host. FFmpeg's frame threads are not torch intra-op threads, so
decode is now min(8, usable_cpus()). The docs paragraph that said both
that the quota is read and that it is not now says one thing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: test GLM-5.2's stop id at prefill

The merge ported GLM-5.2's prefill stop rules onto TokenStop without a
test: a stop id sampled on the prefill walk ends the reply at the first
decode step, whose token postprocess drops, and both stop checks agree;
with ignore_eos the prefill sets nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M4: declare a prefill-sampled first token on TokenStop

GLM-5.2 and Command A+ each hand-built the same rule beside TokenStop:
a stop id sampled by a prefill walk has no loop to stop, so the reply
ends at the loop's first step, whose token postprocess drops, and
max_tokens counts the prefill's token. TokenStop.prefill_walks now
declares those walks; the base keeps the flag in request_state and
applies both rules in check_stop and check_stop_batched, and
ended_at_prefill() tells postprocess to drop the closing token.

GLM-5.2's _token_stops / check_stop overrides and Command A+'s
_ended_at_prefill set, check_stop and cleanup_request are deleted.
Command A+ now sets the flag for max_tokens 1 too (no decode step
follows, and removal frees it) and keeps it until removal instead of
clearing it on the stopping step, as GLM-5.2 did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M1: model the mocked host tensor in the pin test as pageable

test_to_device_nonblocking_pins_where_the_device_capability_says builds
its host from Mock(spec=torch.Tensor), whose is_pinned() is truthy, so
it now took the already-pinned path. It is about the pageable source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* N1: copy a pinned source in place only when it is contiguous

.to() of a pinned non-contiguous tensor packs it into a pageable temp
first, so the copy blocks the caller. Such a source goes through the
pinned buffer like a pageable one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* N2: read usable_cpus once per process for video decode threads

video_decode_threads re-read the affinity and the cgroup files on every
decode. Cache it, as image_io.PNG_WORKERS does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* N3: say a generated_key counter must count the prefill token itself

prefill_walks adds the prefill's token to the loop count only when
TokenStop counts loop iterations; a submodule's own generated_key
counter is read as is. No model declares both today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* N4: the engine drops the closing token of a reply that ended at prefill

Declaring TokenStop.prefill_walks still made each model's postprocess
delete token_stop.buffer when ended_at_prefill(). Engine._postprocess_batch
now does it after postprocess, the one place every forward path's rows
are postprocessed; a submodule without prefill_walks pays one getattr
per batch. GLM-5.2's and Command A+'s own deletes go.

Tests that drove postprocess by hand (GLM-5.2's engine-cycle driver and
both models' prefill-stop cases) now call Engine._postprocess_batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* N4: say prefill_walks needs the loop to emit the token as buffer

The engine drops token_stop.buffer on the closing step, which removes
the token only where the loop's client edge carries that output (GLM-5.2,
Command A+). Qwen3.5's decode emits text_inputs, which this would miss.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* KV: KVReqConfig.prefix_cache reads only its truth

KVReqConfig.prefix_cache defaults to False, so a config with no value has no
prefix cache, and apply_conductor_config stores bool(prefix_cache). Before,
every reader applied "unset or None is on, otherwise its truth".

Behavior is unchanged. The data worker applies the client rule once
(_prefix_cache: unset or None is on) to decide whether to key the request,
and always sends the conductor an explicit bool. The kwargs it stores for
later stream chunks, and those process_prompt gets, are what the client
sent. KVManager.ingest_request with no config, as for a capture padding
row, builds KVReqConfig(prefix_cache=True), as before.

Tests that build a KVReqConfig with prefix_keys directly now pass
prefix_cache=True. New tests: a request that sends nothing or None is still
keyed and sent prefix_cache=True; a config handed no prefix_cache is not
cached; a truthy value opens a config; a config-less ingest keeps the cache
on.

* LingBot-World: call the ragged resource through resource.run, as the scaffold does

Main's #926 added LingBot with RaggedAttentionCallable, which this PR deletes
in favour of functools.partial(resource.run, label=...); the module failed to
import (test_lingbot_world_shell collection, lingbot startup validation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test_subgroup_creation_order: the stub worker graph carries replica_ranks

Main's #705 test stubs a WorkerGraph; this PR's GlobalParallelConfig reads
WorkerGraph.replica_ranks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test_withheld_outputs: the fake check_stop_batched drops step_walk, as the base did

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test_kv_prefix_request_keys: the talker case asks for the prefix cache, as the data worker does

KVReqConfig.prefix_cache now defaults off, so a request that sent no
prefix_cache was uncached whether or not its talker streamed, and the case
no longer pinned the conductor's streamed-partition rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Prefix caches: one startup rule for which KV caches open, recurrent pools included

#877 made a keyed node close its KV prefix cache when the node's recurrent
pool keeps no snapshots (no prefix_slots, above one rank, or over a cache
that doesn't open), but only on the built resources. The conductor's routing
mirror, the data worker's prompt hashing and the engine's view of caches on
other engines still read PagedKVConfig.prefix_cache_opens alone, so a
Qwen3.5 DP deployment without prefix_slots routed same-prefix requests onto
one replica for hits its workers never serve, and hashed prompts for a
closed cache.

opening_prefix_caches(specs, instance, checkpoint_named) is that rule over
the specs: prefix_cache_opens plus the pool closure. The router, the data
worker, the engine's remote-cache set and RecurrentStatePool.build (which
read the raw prefix_cache flag) all read it; RoutingContext carries its
answer instead of instance_size and checkpoint_named.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Routing docs: the KV mirror over-predicts beside a recurrent pool; name the mechanism, not BAGEL

The mirror counts a KV hit whole where a recurrent pool cuts it to its longest
snapshot, so it is listed with the mirror's other approximations, and the
prefix weight's comment says it was simulated before Qwen3.5's hits were cut.
serving.rst names the walks a label keys rather than one model, and lists a
keyed node whose pool keeps no snapshots among the cases with no cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test_conductor_replica_assignment: the refused-request stub model has what ingest reads for streamed partitions

Main's ingest now asks which partitions a keyed node streams into, reading the
model's kv_labels and walk graphs. The stub lacked them, so ingest raised an
AttributeError, refused the request with a 500, and the case failed before
reaching the mirrors it checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Prefix caches: the engine opens exactly what opening_prefix_caches opens

The engine decided which caches stay open a second way, over the built
resources (_close_unservable_nodes, the checkpoint branch, and KVManager
re-deriving prefix_cache_opens at its world size). The two agreed only on
the shapes validation lets through; a recurrent pool whose kv_cache sits on
another engine was closed by the engine and opened by the rule, so the router
mirrored and the data worker hashed a cache nobody served.

Now the engine calls the rule once with its nodes' lockstep instances and
opens exactly that set, closing every other resource. The rule returns the
pools that restore beside their caches too, and a pool keeps snapshots only
over a cache on every one of its nodes; a resource over pools alone shuts
its nodes' caches when they don't all keep snapshots. A spec is sized by its
nodes the caller places, so an engine sizes a cache by its own ranks. Deleted:
_close_unservable_nodes, the restoring/restorers/serving sets, the separate
checkpoint branch (its log line kept), KVManager's own open check (its log
line moved to the engine), the pool's enable_prefix_cache, and
enable_prefix_cache's return value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ReplicaRouter: size the opened caches per replica set

The router sized each node by its smallest lockstep instance over every node
group, so a node keyed at one rank in one group and two in another got a
mirror on its two-rank replicas for a cache their workers keep shut. Each
unit now asks opening_prefix_caches with its own nodes at its instance size,
as its engines see them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: when a recurrent pool allocates snapshot slots; the router derives prefix_cache_opens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* TokenStop: leave the token budget to the loop cap

TokenStop's stop check re-checked max_tokens, which every adopter's
get_loop_caps already enforces: the runtime caps the decode loop at the
same iteration (Python and Rust both stop the loop and refuse to speculate
past max_iters), and the conductor derives the cap and request_info.max_tokens
from one max_output_tokens, also when it cuts that to fit the context. So the
budget is checked once, by the cap. generated_key existed only to count that
budget and goes with it.

Tests that pinned the stop check ending a row at its budget now expect the
row to go on; the cap-vs-stop-check comparisons for the TokenStop adopters
are deleted (there is no second count to compare), and the two hand-driven
loops apply the request's cap as the worker does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* TokenStop: withhold a prefill-ended reply's closing token

A reply whose prefill sampled a stop id closes its decode loop on the loop's
first step, whose token comes after the reply's last. The engine dropped that
token after postprocess by probing the submodule for `token_stop`: a model
concept in engine code, and three routes missed it (a rows-only step skips
the per-request postprocess, the inline client emit reads the host rows, and
a row left empty by the pop was skipped by the stop check, stalling it).

The closing step now says so through the existing primitive: check_stop
returns StopCheck({loop}, withheld=(buffer,)) and check_stop_batched a
BatchedStopCheck. The engine's pop and its `getattr` are gone, and the flag is
read on the main thread after prefill's check_stop. The worker's inline emit
honours withheld outputs per row: such a row gets None, which both graph
runtimes (Python and Rust) route as that row's tensors, of which it has none.
That also closes main's gap where a withheld output still rode inline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Warmup docs: the solo run happens only when replicas are uneven

docs/serving.rst said every run of a warmup entry past one copy starts with one request alone; _run_warmup_requests sends that solo request only when some worker graph has fewer replicas than the most, once per entry. Say that, and that otherwise bs=1 warms only when concurrency includes 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* video_decode_threads: the cap of 8 is per decode

The docstring said the bound keeps concurrent decodes from oversubscribing; each decode starts its own threads, so it doesn't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Ragged attention: name each manager uniquely in the op registry

The mstar::ragged_attention op found its manager by resource key in a process-wide registry, so two live engines with the same key (tests, harnesses) handed one engine's compiled graph the other's plans. The op key is now the resource key plus a number unique in the process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Warmup: a replica count that fails leaves warmup at one copy

_data_parallel_replicas ran outside the warmup's try, so a config the worker graphs can't be built from (or no worker graphs: max([])) stopped startup, against the promise that a failing warmup is logged and skipped. It now logs and returns DataParallelReplicas(1, 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* one_row_per_step docs: what declare_step receives

Under the flag the region's declare_step gets the first request alone with every request's seq_lens, so max_segments_per_request is a per-step ceiling there. Say so where the flag is documented.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: MSTAR_PY_SWITCH_INTERVAL_SEC gets its worker-scheduling row

The worker reads MSTAR_PY_SWITCH_INTERVAL_SEC at the top of Worker.run, but
docs/environment_variables.rst had no row for it. It gets one under Worker
scheduling, naming Worker.run as its reader. The variable applies to workers
only: the API server's interval stays the serving config's
api_switch_interval_s, whose measurements covered the API server alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Comments: the decode budget is the loop cap, not check_stop

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: a prefill_walks token counts toward the budget, so the loop cap is one fewer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: say which MSTAR_PY_SWITCH_INTERVAL_SEC values the worker ignores

The worker passes the value to sys.setswitchinterval, which refuses only
values of 0 or below, so `nan` and `inf` are applied rather than ignored
with a warning, as the row implied. The row now names what is ignored: a
value float() cannot parse, or one of 0 or below.

_env_number's docstring cited this variable as raising a too-small value
to a minimum; it ignores one instead, so only MSTAR_RECOMPILE_LIMIT is
left as the precedent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: point api_switch_interval_s to the workers' switch-interval variable

The key sets the API server's GIL switch interval only. The worker
processes read theirs from MSTAR_PY_SWITCH_INTERVAL_SEC, whose row
already points back to this key; the serving config row now says so too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* KV routing mirror: run the worker's own PageArena, which the index reports to (R1.1)

Since #869, PrefixIndex.insert reads `arena.index`, which the mirror's
hand-copied `_Arena` never had: every keyed request on a data-parallel
deployment failed ingest with a 500 and leaked the pages the mirror had
already taken. PageArena moves to the kernel-free kv/arena.py, and each
replica's mirror runs it with its index attached, as KVManager does at one
rank, the only size a mirror is built for. Its allocator is a list rather than
PageAllocator's queue, which costs about a microsecond a page on the
conductor's one thread (345 us per 128-page request measured, against 1 us).

The mirror reads all of a request's config before it takes a page, so a bad
config leaves nothing held. PrefixIndex goes back to sizing itself by its
arena's allocator, and loses the docstring sentence #869 made false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* KVReqConfig.prefix_cache defaults on again; the replica tests' stub conductor has #869's spec caches (R1.2, R1.12)

The False default made every direct construction opt in, and #869's admission
tests, written against main's True default, silently stopped caching: nine
failed and its randomized case keyed nothing. In production nothing reads the
default, since the data worker sends the client's value and ingest applies it,
so the default goes back to main's, with the config-less special case in
KVManager.ingest_request, the data worker's always-sent bool, and the test
edits that only opted in. The opt-out test starts from a cached config again,
so it can fail, and the tests that pinned the False default go.

The refused-at-ingest test's conductor now caches the model's specs and each
worker's in the shapes #869 reads (`_node_resources`, `_resource_specs`), so
it gets its 400 instead of a 500 from a stub model with no
get_node_resources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* routing_weights: each weight is at most 1e6, so no replica's cost overflows (R1.7)

Finite weights near 1e308 passed validation, and then `load * n` overflowed
to inf and `-prefix * share` to -inf, so a replica's cost was NaN, no cost
equalled the minimum, and every data-parallel pick raised. A weight is now a
number from 0 to 1e6, far above any useful one, which also covers NaN and
inf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* KV routing mirror: a hit runs on to an item cached up to its end inside a page (R1.6)

The mirror's docstring said the conductor never sees an image that ends inside
a page, but every keyed span row carries its `end_key`. The mirror now takes
the worker's two steps for it: `_lease` extends a hit to the end of an item
cached inside the next page (`_image_end`), and a commit files the last such
item's partial page under the page before it, from a free page only
(`_cache_image_end`). BAGEL's predicted hits were a partial page short of
what its workers serve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Prefix caches: each spec answers whether it opens one and restores a hit; the rule runs to a fixed point (R1.4, R1.9)

opening_prefix_caches picked paged KV caches and recurrent pools by isinstance,
so a resource of another kind that kept a cache was never asked to open it.
Specs now answer for themselves: NodeResourceSpec.prefix_cache_opens (KV caches
by their config, pools by prefix_slots at one rank over a named cache) and
restores_prefix (the pool, and the linear-attention resource whose state it
holds). A cache over other caches keeps only where they open on all of its
nodes, and the engine's log of a shut cache asks the same hooks.

The rule decided pools in one pass, so a pool whose KV cache closed on another
node was dropped without its nodes being marked, and a second cache there
opened beside a pool the engine closed. It now repeats until nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Prefix caches: every caller evaluates the open rule as each rank's engine does (R1.5)

The rule took four different inputs. The data worker sized each node by its
smallest instance over all node groups, so it could stop keying a cache a
rank's engine opens (lost hits); the recurrent pool's build saw only itself
and its dependencies, so it kept snapshot slots on a node the engine then
closed (wasted memory); the router sized only its unit's nodes.

prefix_caches_by_rank evaluates the rule per rank, over the nodes on the rank
at their lockstep instance size, as Engine.load_model does. The data worker
keys a cache some rank opens, and the router mirrors one every replica's
engine opens. The engine decides once before it builds any resource and hands
the decision to the builds (EngineResourceInfo.prefix_caches), so the pool
sizes its snapshot slots by it rather than by a second evaluation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Routing metric: a request adds one share of its prompt, however many replica sets it spans (R1.13)

mstar_routing_predicted_saving_total added a share per mirrored set of
replicas, so a request whose cache's nodes sit on two sets could add up to 2,
where docs/metrics.rst says each request adds the share of its prompt. Its
facts on the replicas it went to are now added up and priced once, as a set's
mirrors are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* checkpoint_path overrides are typed to return a path, as names_checkpoint reads them (R1.11)

The data worker and the router read whether a model overrides checkpoint_path
(names_checkpoint) rather than call it, so an override that returned None
would have them key and steer for a cache the engine keeps shut. Every
override returns a path; the four typed `str | None` now say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Routing: the default prefix weight is provisional while the mirror credits Qwen3.5's hits whole (R1.3)

The comment told how the weight was found. It now says what a reader needs:
the simulations behind 8 credit Qwen3.5's hits whole, as the KV mirror does,
while its workers serve them only up to a recurrent snapshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Ragged attention: update a test docstring for unique op keys

test_a_compiled_region_attends_through_a_rebuilt_resource still said the
op names its manager by resource key, with a rebuilt resource taking over
that key. Each manager now registers under its own op key, the resource
key plus a number unique in the process. The docstring now says what the
test checks: a region compiled against one manager, then called with a
manager rebuilt under the same resource key, attends through the rebuilt
one's plan while the old one is still registered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Data-worker tests deploy the model's nodes on a rank, as every deployment does (R1.5)

The data worker now keys the caches some rank's engine opens, which it reads
from node_groups; a deployment must name them (node_groups_problems), but
these tests' deployments did not, so nothing was keyed. They now place the
model's nodes on rank 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Prefix caches: a rank's set holds only the caches with a node on it (R2.1)

prefix_caches_by_rank took each rank's whole opening_prefix_caches result,
which counts a cache with no node on the rank as open at one rank. The data
worker's union then keyed prompts, and hashed their media, for a cache that
every engine holding it keeps shut: a TP-2 Thinker or BAGEL LLM beside a rank
that holds only the Talker or the encoders (qwen3omni_thinker_tp2,
qwen3omni_thinker_ep2, qwen3omni_full_tp2, bagel_xpu_tp2, bagel_xpu_cfg_tp2).

Each rank's set now keeps only the caches it holds. The router's intersection
is unchanged, since a unit's mirrored specs have a node on every rank of the
unit; opening_prefix_caches itself still counts other engines' caches at one
rank, which the engine's item encoders read.

The _Split test pinned a deployment validate_deployment refuses (one spec over
a one-rank and a two-rank node); it is replaced by qwen3omni_thinker_tp2's
placement, where nothing is keyed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* KV routing mirror: a request that never ran takes back only the entries it filed (R2.2)

A request's item-end page is the index's alone from the moment it is filed, so
a later request can evict it, take the freed page from the free list's end and
file its own key there. The first request's unrun end then dropped that page by
number, taking the other request's entry with it.

The mirror now records the (key, page) entries a request filed, and
PrefixIndex.drop removes one only while its key still names its page. A
request's own pages are held until its end, so for them nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Engine: evaluate the prefix-cache rule and read the checkpoint once (R2.3)

load_model evaluated opening_prefix_caches for the resource builds, and
_open_prefix_caches evaluated it again with a second checkpoint_path() call,
which is a hub snapshot_download for BAGEL and Orpheus. load_model now reads
the checkpoint once and passes it and the rule's caches to
_open_prefix_caches, so the engine's open decision and the recurrent pool's
snapshot slots come from one evaluation, as the docs say.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Resource specs: the hook that says a resource keeps a cache is keeps_prefix_cache (R2.6)

NodeResourceSpec.prefix_cache_opens(world_size, checkpoint_named) asked
whether a resource keeps a prefix cache of its own, before the open rule
runs, while RoutingContext.prefix_cache_opens means every replica's engine
opens the cache, after it; KVSpec.routing_mirror read both side by side. The
hook, and PagedKVConfig's method it delegates to, are renamed after their
docstring, so "opens" names only the rule's result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Prefix caches: the shut-cache log names the resources the rule blames (R2.4)

_log_shut_caches re-derived the rule: it copied its size helper and named
every restoring resource not in the open set. A GDN resource over a pool that
keeps snapshots is never in that set, since it keeps no cache of its own, so a
cache shut by another node's pool could blame it. The rule's size helper and
its blame predicate are now module functions, instance_size and
unrestorable, which the rule and the log both call; the rule still returns
the frozenset its callers read.

Main's validation (_unskippable_resources) still finds pools by isinstance;
that is main's code and stays a follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* KV routing mirror: docs and comments say what it runs now (R2.5)

The docs said the mirror runs over a count of each replica's pages and files
only its whole pages; it runs the worker's PageArena and files an item's end
page too, which the module docstring also left out. The prefix index's
docstring now names the conductor's thread beside the manager's lock, the free
list's comment gives the measured cost of PageAllocator's queue (about 3 µs a
page, not one), and add_request loses a comment claiming a bad config leaves
nothing held, which the inserts after the pool moves don't guarantee.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* KV routing mirror: zip a request's filed keys and pages strictly (R2.2)

ruff's B905 on the R2.2 line; insert_run already zips the same two lists
strictly, so they are always the same length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Prefix caches: two docstrings say exactly what their code does (R2.3, R2.4)

_open_prefix_caches opens only the caches built on this engine, while its
``opened`` also names other engines' caches counted at one rank; and
unrestorable's docstring now reads as a sentence. Docstrings only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Comments and docs: a shard_over_sp=False ragged resource splits its heads over TP only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Head sharding: one per-resource switch, shard_over_sp; NodeSubmodule.splits_heads goes

Every resource now shards its heads over all TP x SP ranks of the node's
lockstep group (EngineResourceInfo.head_shards, a property), and a ragged
resource that each SP rank runs whole says so with
RaggedAttentionConfig(shard_over_sp=False). The node-level splits_heads flag,
the engine's per-node fold and its agreement check, and their docs and tests
are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Name the TP x SP head-sharding test for the kinds it covers; reflow a docs paragraph

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Say the api_switch_interval_s numbers predate #849's output-path rewrite

The row still said the knob would be re-measured once #849 lands. It has
landed and changed the message-thread and data-worker path the knob
tunes, so the numbers are from before that change and the re-measure is
still pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the head-sharding paragraph from the submodule attributes section

It was there to document the removed splits_heads attribute; what is
left repeats the RaggedAttentionConfig row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the TP x SP head-sharding test that main's sizing already passes

Head sizing now equals main's, so the six cases pinned nothing this
branch changes. The shard_over_sp=False test stays; the helper loses its
recurrent branch, which only the deleted test used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the shard_over_sp sizing test, which main's sizing already passes

Main already sizes a shard_over_sp=False ragged attention over the TP
group, so the test pinned nothing this branch changes. The file's
coverage goes back to main's plus the manager registry test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Conductor: build its instance table from WorkerGraph.replica_ranks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pin video decode threads to the CPUs the plan leaves under pinning

A case with a pinned CPU fails if video_decode_threads goes back to
usable_cpus(); the MSTAR_STEP_THREAD_CORE row names FFmpeg's per-decode
threads among what the pins shrink.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* API server: drop the api_switch_interval_s knob

Its gain was measured before #849 and #939 changed the message-thread and
data-worker path, and the re-measure it was waiting on never ran. The
serving-config key, its validation, docs rows and test go; workers keep
main's MSTAR_PY_SWITCH_INTERVAL_SEC unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* KV-aware routing for data-parallel replicas

The conductor picks each request's DP replica by cost over per-replica
routing mirrors (ReplicaRouter, RoutingMirror, KVRoutingMirror): load plus
routing_weights.prefix times the share of the prompt predicted cached there.
One startup rule (opening_prefix_caches) decides which prefix caches open, for
the engines, the router and the data worker's prompt hashing alike.

Rebuilt on main from routing/kv-aware's own diff against #858
(perf/split-1-shared). Carries the minimal pieces of #858 it stands on:
WorkerGraph.replica_ranks, _assign_worker_graphs_to_workers taking the picked
replicas, and _drop_request as the one way a request leaves the conductor. The
warmup change (prefix_cache=False on per-replica copies) is left out, since
main's warmup runs one copy per spec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: the TP2 publish test's KV managers hold one rank's head

KVManager shards its config in build, so the test's direct constructor
shards it the same way and each rank moves its own shard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* API server: drop warmup_requests' concurrency option

No config sets it, so its parsing, docs and tests covered a dead path. Each
entry still goes out once per data-parallel replica, now as
warmup-<spec>.<copy>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Restore #920 to its approved state on #858 (f8d377cbe's tree)

The branch is merged after #858, so it goes back to stacking on it.

* Tests: Canary's decode cap leaves the prefill's token in the budget

#794's cap-versus-stop-check agreement tests for Canary and Canary-Qwen assumed
a stop check that counts max_tokens; TokenStop leaves the budget to the loop's
cap, so they pin the cap instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: last_prefill_flag for a prompt prefilled over several passes; GLM-5.2 test comment names the loop cap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Conductor: _assign_worker_graphs_to_workers indexes the replica inline, as on main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Stephen Duan <s129652@cs.washington.edu>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant