Repository navigation
[Distributed][Bug fix] Make the members of a gloo subgroup agree on its name, so a TP x SP mesh starts - #705
Conversation
vasilevklart
left a comment
There was a problem hiding this comment.
Checked on CPU gloo with 4 ranks (torch 2.12.1), the 2x2 mesh hangs on main and works with this fix, same with [(0,1),(0,1,2,3)] and [(0,1,2),(0,3),(1,2,3)]
| # iterates it identically; ``new_group`` is collective and | ||
| # tag-ordered (see the field comment). A tuple shared by a TP and | ||
| # an SP group (degenerate meshes) maps to one subgroup. |
There was a problem hiding this comment.
The field comment (L298) and the docstring (L350) still say one new_group per tuple with non-members discarding the handle, now it's two passes with a one-rank stand-in
| # torch names a members-only group after its ranks and the number of | ||
| # groups this process has made so far, and a non-member makes none. |
There was a problem hiding this comment.
This is len(_world.pg_names), the groups currently registered, so it stays equal only inside this loop and only while nothing is destroyed; worth saying here + AGENTS.md invariant 11 still says use_local_synchronization=True is enough
| _NON_MEMBER = object() | ||
|
|
||
|
|
||
| class _FakeDist: |
There was a problem hiding this comment.
We could guard with a real 4-rank gloo run
patch
--- a/test/modular/test_subgroup_creation_order.py
+++ b/test/modular/test_subgroup_creation_order.py
@@ -11,10 +11,14 @@
from __future__ import annotations
+import multiprocessing as mp
+import time
from types import SimpleNamespace
import pytest
import torch
+import torch.distributed as dist
+from test_rendezvous_store import _allow_cpu_device_id, _free_port
from mstar.distributed import communication
from mstar.distributed.communication import GlobalParallelConfig
@@ -146,3 +150,34 @@
assert sp.device_group.ranks == tuple(sp.group_members) == sp.cpu_group.ranks
assert tp.cpu_group.backend == "gloo" and tp.device_group.backend == "nccl"
assert rank in tp.group_members and rank in sp.group_members
+
+
+def _real_rank(rank: int, port: int, out) -> None:
+ _allow_cpu_device_id()
+ view = _mesh(2, 2).per_worker_config[f"worker-{rank}"]
+ view.init_dist(init_method=f"tcp://localhost:{port}", device=torch.device("cpu"))
+ sums = []
+ for group in (view.node_to_tp_group["dit"], view.node_to_sp_group["dit"]):
+ value = torch.ones(1)
+ dist.all_reduce(value, group=group.cpu_group)
+ sums.append(value.item())
+ out.put((rank, sums))
+ dist.destroy_process_group()
+
+
+def test_a_2x2_mesh_starts_on_real_gloo():
+ """The fake above follows torch's naming; this runs torch's own."""
+ ctx = mp.get_context("spawn")
+ out = ctx.Queue()
+ port = _free_port()
+ procs = [ctx.Process(target=_real_rank, args=(rank, port, out)) for rank in range(4)]
+ for p in procs:
+ p.start()
+ deadline = time.monotonic() + 120
+ for p in procs:
+ p.join(timeout=max(0.0, deadline - time.monotonic()))
+ hung = [rank for rank, p in enumerate(procs) if p.is_alive()]
+ for p in procs:
+ p.kill()
+ assert not hung, f"ranks {hung} still in init_dist"
+ assert sorted(out.get(timeout=5) for _ in procs) == [(rank, [2.0, 2.0]) for rank in range(4)]torch names a members-only group after its ranks and how many groups the process has made so far, and a non-member makes none. On a 2x2 TP x SP mesh ranks 0 and 2 named their (0, 2) gloo group differently and each waited for the other under its own store prefix, while ranks 1 and 3 sat in the next ncclCommSplit. A rank outside a tuple now makes a gloo group of just itself, and the gloo groups go before the device ones.
6ec48a1 to
3eca928
Compare
|
/review |
Automated review🟡 No findings on Description
What the review checked
Advisory, not a merge gate. Rules: |
Gaurav-Shah05
left a comment
There was a problem hiding this comment.
The fix is right. In my runs on CPU gloo with 8 ranks, main hangs on both a 4x2 and a 2x4 mesh, and this head starts both with the right all-reduce sums.
A shape that might be simpler: on CUDA, initialize the default group as cpu:gloo,cuda:nccl, as init_dist already does on XPU, and have every rank make one new_group per tuple. torch names those from group_count, which every rank advances, and non-members join the members' ncclCommSplit. So the members-only gloo loop and the one-rank groups go away, every group is named from the counter torch shares across ranks, and cpu_group is the device group on CUDA as it is on XPU. I haven't run it on NCCL. What do you think?
The cap to the smallest tier was for an illegal access seen with the all-gather exchange composed with the TP all-reduce. With the all-to-all, `cosmos3_nano_tp2_sp2` on 4x H100 (with #705 for startup) captures 192x320 and 480x832 and matches the SP2 images at 38 and 41 dB PSNR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gather exchange (#897) * Sequence parallel: one all-to-all, captured; drop the all-gather exchange CommGroup.all_to_all sends one packed buffer with all_to_all_single instead of a list of split pieces that it then concatenated, and only the collective is hidden from torch.compile, so the packing and unpacking stay in the caller's graph. It replays from a CUDA graph, so ulysses_attention loses prefer_all_gather and its all-gather exchange, which moved P times the bytes, and Cosmos3's captured denoise uses the all-to-all like its eager path. ulysses_attention takes the sequence and head dims, for batched layouts. The exchange is from LTX-2.5's SP work (#376), which kept its own copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * CommGroup.all_to_all: accept negative dims; say what the compile boundary costs `unflatten` adds a dim before the `movedim`s, so a negative `scatter_dim` or `gather_dim` pointed at the wrong one and the exchange returned a wrong tensor without an error (main's list form handled them). Both are made positive first, as `all_gather` does; the exchange tests now also run with negative dims, and four of them fail without this. The docstring said the packing stays in the caller's compiled graph. It doesn't: with the collective behind `torch.compiler.disable`, dynamo compiles `all_to_all` as its own frame. Tracing the collective instead (`_functional_collectives.all_to_all_single`) keeps one graph and is bit-exact in isolation, but served Cosmos3 SP2 hit an illegal memory access in the batched capture's warm-up, so the disable stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Cosmos3: capture every tier under TP x SP The cap to the smallest tier was for an illegal access seen with the all-gather exchange composed with the TP all-reduce. With the all-to-all, `cosmos3_nano_tp2_sp2` on 4x H100 (with #705 for startup) captures 192x320 and 480x832 and matches the SP2 images at 38 and 41 dB PSNR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: rendezvous the SP exchange tests through a file store A freed ephemeral port can be taken by another xdist worker before the spawned ranks bind it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * sequence_parallel: say which helpers take a layout Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Ulysses: one all-to-all for q, k and v The exchanges are latency-bound at small sizes: on Cosmos3 SP2 at 320x192 a point-to-point all-to-all call took ~14 us against ~8 us for the all-gather it replaced, ~7200 times a request. q, k and v now go in one exchange: each rank's share of the three tensors' heads is laid side by side, so scattering the heads sends rank r all of its share, and the received tensor splits back into q, k and v. Different query and key/value head counts work, as long as each divides by the group size, which Ulysses already requires. Cosmos3-Nano SP2, 2x H200, single request (median of 8 after 2 warm-ups), alternated with the previous head, three boots each: 320x192 0.633 -> 0.548 s, 832x480 0.927 -> 0.871 s, 1280x720 (eager) 3.44 -> 2.78 s; images byte-identical across all six boots, 600 more requests (single and 8 at a time) without a failure. Compiled and captured alone, bit-exact against one rank attending everything, 100 -> 84 us at 960 tokens. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * CommGroup.all_to_all: several tensors in one exchange, each back contiguous Ulysses packed q, k and v interleaved by head and handed the result to all_to_all, which packed it again: a second full copy, and q, k and v came back as non-contiguous slices that LTX's attention copies once more. On LTX SP2 that was ~2% slower than separate exchanges. all_to_all now takes several tensors that differ only in the scattered extent. Rank r's block of the send buffer holds its piece of each tensor one after another, written with one copy per tensor, and each tensor unpacks on its own, so the copies are those of separate exchanges in one collective. Ulysses passes (q, k, v). 2x H200, two boots per side, alternated: - LTX-2.5 SP2 against separate exchanges: 19.52 / 21.72 / 22.30 videos/min at c=1/2/4 against 19.55 / 21.79 / 22.24, two-stage 5.78 / 6.31 against 5.77 / 6.35; video and audio byte-identical. - Cosmos3-Nano SP2 against the interleaved version, single request: 320x192 0.552 vs 0.544 s, 832x480 0.872 vs 0.866 s, 1280x720 2.760 vs 2.764 s, so it keeps that version's gain over separate exchanges; images byte-identical, 400 more requests without a failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Joseph Tan <jdtan638@gmail.com>
|
Thanks for running both meshes. I like the mixed |
…one set of groups Pins the per-rank-tuple dedupe that keeps #705's group-creation order equal on every rank once several lockstep nodes share an instance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…anks 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>
…-axis-core Main's #705 makes the axis gloo twins members-only with a one-rank stand-in on non-members, the same rule this PR's instance groups follow. init_dist now makes both in one loop over the sorted union of axis tuples and multi-axis instances, then the device groups; the conflict otherwise left a second rank_tuple_to_cpu_pg that would have dropped the instance groups. Tests: #705's creation-order harness builds real WorkerGraphs and adds cfg meshes, so it covers the instance groups; this PR's call-order test for them is dropped as a duplicate. test_tp_read_check no longer renames gloo groups (the shim stood in for #705) and takes main's walk-driver fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… has Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…947) * Several lockstep nodes on one worker, sharing its lockstep instance A worker leads or follows one lockstep instance; the step protocol (one leader, one broadcast seq, one follow FIFO, teardowns stamped on that seq) was already per instance, and only validation refused a second lockstep node. It now accepts any number whose TP and SP groups are the same on the worker, and refuses lockstep nodes in different instances. What was keyed by node and has to hold across interleaved nodes: - The TP read check keys pending reads per node, so one node's step does not spend another node's read of the same signal (BAGEL's combine_cfg feeding several LLMs). - The fused all-reduce+norm workspace on the shared CommGroup is sized for the first width set up; another width takes the NCCL path instead of overrunning it. - A follower counts a followed node's graph-ready rows as its own work only through rank 0's FIFO head, so it no longer passes over the head and parks in wait_for_work while rank 0 waits in the collective. - The resident journal stays per node (#873 reworks it), so two lockstep nodes sharing a KV spec with host offload are refused: an eviction under one would reach a follower only with that node's next step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Lockstep instance refusal: name only the axes a node is parallel on (R1.12) The refusal of lockstep nodes in different instances printed "SP ranks None" for a TP-only node; an axis of one rank is now left out. It also asked for "the same ranks, in the same order, with the same tp_size and sp_size", stricter than the check, which compares the node's TP and SP groups on the worker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Refuse any KV resource shared by two lockstep nodes on a worker (R1.1, R1.2, R1.5) Rank 0 replicates its page moves to the followers per node, with that node's next step, so two lockstep nodes over one pool could admit out of step on a follower. The refusal was keyed on cpu_offload_pages > 0, which goes stale once rank 0 moves pages without a host pool; it now covers every KVSpec shared by two or more lockstep nodes on one worker, and names #873 as the owner of journaling a shared pool. Pools of their own stay accepted. The test built PagedKVConfig without its required geometry and without kv_labels, so it raised TypeError; it now builds a valid KVSpec, checks the refusal with and without offload, and the accepted test gives each node its own pool instead of a shared stub. docs/serving.rst: the vision_encoder example was refused (no model TP-enables an encoder); the example is now Qwen3-Omni's Thinker and Talker, and the rule is stated as the same TP and SP groups on each worker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fused all-reduce+norm: match the workspace's dtype as well as its width (R1.4) The shared group's workspace was guarded by width alone, so an input of the same width in another dtype took the fused kernel on a workspace built for the first dtype. init_allreduce_fusion records (hidden, dtype), the forward takes the fused path only when both match (plain comparisons, since it runs inside compiled forwards), and a refused second width or dtype is logged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Read check docstrings: a step's own node, not "its lockstep node" (R1.10) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Read check: note a local output only under the led nodes it is routed to (R1.7) An output of a node beside rank 0 was noted under every led node, so the entry of a node that never takes the signal stayed until teardown and _step_reads lost its empty fast path. It is now noted under the led nodes the producer's edges reach in the row's walk (get_consumed_edges), looked up once per (producer, walk). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Micro-scheduler: one filter for the rows a follower can take (R1.9) "A node rank 0 leads is work here only through rank 0's FIFO head" was written out in _scan_ready, has_ready_excluding and pending_rows. _rows_here now says it once; each caller keeps its behaviour (_scan_ready and pending_rows take no followed rows, has_ready_excluding takes the head's unless its key is excluded). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Lockstep sim: per-node pools only, above the __main__ guard (R1.11) The two-node section sat after the module's `if __name__ == "__main__"` block; it now precedes it. The shared-pool negative test pinned only the fake (validation refuses that layout), so it and ONE_POOL are gone, and with one pool per node left the sim drops its node-to-pool mapping. The positive test (two nodes interleaved with evictions, a refused admit and an abort) stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Shared-KV lockstep refusal: state the page moves main journals today (R2.3) On main the per-node journal carries host offloads and reloads; the preemption drops come with mstar PR #873. The layout stays refused at any offload setting until that PR's shared-pool journal lands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Serving docs: describe colocated lockstep nodes without the untested Thinker+Talker layout (R2.1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Read-check test: describe the two-consumer case without a model it no longer fits (R2.2) Validation now refuses BAGEL's layout, so the case is the general one: one producer feeding the same signal to two lockstep nodes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Shared-KV lockstep refusal: name the missing mechanism, not a delivered one R3.1: the refusal's comment, message, test expectation and docs implied mstar PR #873 delivers a shared-pool journal; it still journals per node. Keep #873 as the owner, but state the lift condition as rank 0 journaling a shared pool's moves in one stream rather than per node. R3.2: the moves parenthetical now lists #873's reservations beside its preemption drops ("host offloads and reloads today; #873 adds ..."). R3.3: docs/serving.rst drops the hypothetical encoder-beside-LLM example for "each listed in the model's tp_enabled_nodes (or sp_enabled_nodes)", and says "every node's steps" for "both nodes' steps". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Lockstep shared-KV refusal: timeless reason, no per-node move attribution Review round 4 P3s on the shared-KV refusal sentence (placement check, its test expectation and docstring, docs/serving.rst): - R4.1: drop "(host offloads and reloads today; mstar PR #873 adds ...)", which goes stale when #873 merges. - R4.2: stop attributing page moves to the node that made them; #873 routes a shared pool's moves through one journal callback per pool. The text now says moves on a pool ride on one node's next step. - R4.3: give the move kinds as examples ("host offloads and reloads, for example") rather than a list that omits some of #873's kinds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test_subgroup_creation_order: lockstep nodes on the same ranks share one set of groups Pins the per-rank-tuple dedupe that keeps #705's group-creation order equal on every rank once several lockstep nodes share an instance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drop the two-node instance simulator (R1 F5) explore_instance and INSTANCE_SCENARIOS modelled the per-node resident journal with their own alloc/move/admit, so no change to Engine, Worker or MicroScheduler could make them fail. The argument they made (separate pools are safe because each node's moves ride on its own next step) moves into the comment beside the shared-pool refusal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Shared-pool refusal: read a declared spec property, not KVSpec (R1 F2) NodeResourceSpec.journals_resident_moves (False by default, True on KVSpec) says rank 0 moves the resource's state outside a step and journals the move per node. _worker_parallelism refuses two lockstep nodes of a worker on any spec that declares it, so the next resource with rank-0-decided moves (a recurrent snapshot offload, say) opts in by declaration and validation no longer imports one resource's concrete class. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Lockstep placement refusals: accurate, short, once per instance (R1 F4 F5 F9 F10) - F9: a TP-only and an SP-only node on the same ranks are not "in different lockstep instances"; say what is true, that they are not in the same TP and SP groups (the layout stays refused). - F4: the shared-pool refusal is cut to what an operator acts on, and the error and docs/serving.rst no longer cite another PR as where the fix belongs. - F10: the shared-pool refusal is reported once per node set, naming every worker, rather than once per worker of the instance. - F5: the refusal tests check the resource, nodes and workers named, not the whole message. The comment beside the refusal now carries the argument the deleted simulator made. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * init_allreduce_fusion: record the workspace only once built; warn on a larger max_tokens (R1 F3) A second init with the same (hidden, dtype) and a larger max_tokens returned True while inputs between the two sizes silently took NCCL; it now logs the same warning as a mismatched width. hidden and dtype were recorded even when create_workspace failed; they are now set with the workspace, as max_tokens already was. One workspace per group (first built wins) stays; a workspace per (hidden, dtype) is a follow-up to land with the fused-path changes of the TP ViT work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Worker read-check cleanups (R1 F10) - _lockstep_consumers gets a class-level default beside _lockstep_led and _lockstep_unread, so a bare Worker reads it as empty. - _note_local_lockstep_inputs resolves the led routes once per walk of the store call (its rids/walks/rid_walk_idx) instead of a walk_of call and a tuple-keyed lookup per stored tensor. - rid_table.py lists Worker._lockstep_unread as handle-keyed state (purged in _remove_request). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test a follower taking two partitions' heads as a stream lands (R1 F1) The accepted layout with lockstep nodes in different async partitions joined by a streaming edge (Omni Thinker -> Talker on one TP instance) had no CPU case. A chunk landing on a follower readies its Talker row before rank 0 sends the Talker's head; the follower must keep taking the Thinker's heads and then the Talker's, in rank 0's order. Fails on main's micro-scheduler (the follower passes over the second Thinker head for the Talker row). The fake queue now pops only the named node of a rid ready for several, as the real one does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drop the Thinker/Talker follower test: it repeats the head-order case (R2.2) Its partitions and stream did not reach the scheduler (the fake manager ignores partitions, and the chunk was plain graph readiness), so it failed and passed at the same line as test_a_follower_does_not_hold_a_head_back_for_rows_rank_0_has_not_sent. The fake queue's per-node pop it needed goes with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * journals_resident_moves: say what it means and when to set it; the offload-off reason (R2.3) The property covers every decision rank 0 makes for its followers outside a step (offload, reload, prefix leases), and a resource with supports_eviction must set it. The placement comment says why the refusal holds with offload off, and the message says the other ranks replay rank 0's moves, not the reverse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Worker: drop the _lockstep_consumers class default (R2.4) Its only reader writes on a miss, so a read-only default never made a bare Worker work; _setup_tp_read_check sets it beside _lockstep_unread. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * CommGroup: one fused all-reduce workspace per (dtype, hidden) Lockstep nodes of one instance share its TP group. With a single workspace, a second node of another width or dtype fell back to NCCL; now each node's init_allreduce_fusion builds its own, and a larger max_tokens for a key it has replaces it. Calls stay at load, in node order, so every rank builds the same keys in the same order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test: a TP-only and an SP-only node on the same ranks are refused The PR describes this refusal; it had no case. The two nodes cover the same ranks on different axes, so they are two lockstep instances on each worker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Docs: the evictable-resource contract for lockstep nodes, in Step 2a Model and resource authors read the resources section, not spec.py: say there that two lockstep nodes on a worker can't share a resource that journals resident moves, and that a new evictable resource's spec sets it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * CommGroup fusion: a larger max_tokens keeps the smaller workspace alive; a failed grow says so A destroyed workspace could still be held by a compiled or captured forward, and a failed grow returned True. Drop destroy_workspace and return whether the requested size is available. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Resources: build_resource refuses a replayed resource whose spec doesn't journal it Validation refuses lockstep nodes sharing a resource only by its spec's journals_resident_moves, which was set by convention. Check it where the resource is built, and name the hooks (eviction, prefix leases, page trails) rather than "evictable" in the docs and spec docstring: KV is refused with host offload off too. The refusal also names the other remedy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test: _ToyModel takes sp_enabled_nodes; only the other-axis case sets it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Resources: derive journals_resident_moves from the resource class's hooks One fact, one place: a resource implementing supports_eviction, lease_cached_prefix or page_trail is one whose state rank 0 decides for its lockstep followers. Placement asks the class, as validation already does for prefix_skip_safe, so the spec flag and the build-time check go. The refusal test covers a page-trail-only resource beside the KV cache. The fusion docstring says the smaller workspace is left allocated, not why it might be used. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * P3s: say where the journaled hooks must live; one KV case in the shared-resource test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * CommGroup fusion docstring: the measured cost of a workspace Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * CommGroup fusion docstring: scope the measured workspace cost Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * CommGroup fusion docstring: the fallback is the group's all_reduce; the measured shapes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * CommGroup fusion docstring: state the cost where it matters, within what was measured Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * CommGroup fusion docstring: a grow builds only if it can Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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>
* 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>
* Tests: the Qwen3-Omni prefix-cache Thinker fixture's config has an im_end_token_id
#859's ThinkerSubmodule declares its TokenStop in __init__ from
config.im_end_token_id; main's #836 fixture config (merged in 5dada826a)
had no such field, so the three tests built on it errored at setup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* BAGEL parallel CFG: a branch node sitting out a step builds only what its forward reads
merceod #860 row 12. Under image_gen_cfg the step's branch count was
known in prepare_inputs, but a node sitting the step out (cfg_img all of
t2i, cfg_text outside the CFG interval) still seeded step-0 latents,
built the sentinel frame, three per-label position-id tensors and the
shape inputs, only for the forward to return a zero-row velocity.
prepare_inputs now counts the branches first and returns the slim
NodeInputs (the count, requires_cfg) when the node sits out. forward
checks the skip ahead of the single-branch dispatch and sends
sat_out_velocity(config, device), which no longer needs latents: same
(0, latent width) shape and the latents' dtype rule as before, so the
v_<label> edge each runtime routes is unchanged. declare_step still
returns None for the step, and no image_gen_cfg capture exists (the LLM
captures only decode and prefill_text), so no captured input shape
moves. _sits_out / _cfg_label replace three NODE_TO_CFG_LABEL lookups,
the per-node output-name dict and the _NODE_TO_CFG_LABEL alias.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* OmniVoice silence trim: the trailing trim reads the lead trim's prefix sums (merceod #859 row 23)
remove_silence built three _Segment prefix sums per call, the last over a
reversed copy of the lead-trimmed samples. A _Segment now addresses a window
of its samples, read backwards when flipped, so `reversed_tail` hands the
trailing trim the lead trim's prefix sums with reversed bounds: one build
fewer and no reversed copy. Output is unchanged to the sample.
The pydub comparison skips without the omnivoice package (as on CI), so a
new case checks the trim against the reversed-copy path on random clips at
four rates, including rates whose fractional samples per millisecond pad or
drop the edge.
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>
* Wan VAE decoders: one FastDecoder setup in wan_vae, used by Wan2.2, Cosmos3 and LingBot
merceod #860 row 10. The shared Wan VAE decoder was set up four times
(Wan2.2, Cosmos3 whole-clip, Cosmos3 streaming, LingBot): a
to_channels_last before a DecoderStep, and a decode_causal call under
autocast-off, with only Wan2.2 switching layout per request under
inference_mode(False). wan_vae.FastDecoder now owns it: the layout
switch at construction (before the step compiles), the DecoderStep,
decode (a whole clip from an empty cache) and decode_stream (a
caller-owned cache). Both calls put the VAE back in the decode layout
first, outside inference mode, and use_stock_layout switches it to the
loaded layout for a stock decode, so Wan2.2's per-request switch and
everyone else's construction-time switch are one mechanism. A VAE
already in the right layout skips the switch.
wan_vae.decode_dtype(fp32_env) is the cuDNN-gated decode dtype (bf16
from 9.16, the model's *_VAE_DECODE_FP32 override) that Wan2.2 and
Cosmos3 each spelled out.
Numerics per model are unchanged: each caller keeps its cast where it
was (Wan2.2 and Cosmos3 whole-clip at construction, the Cosmos3
streaming node lazily, LingBot bf16 unless vae_decode_fp32), its
deterministic flag (False only on the Cosmos3 streaming step), its
compile switch, latents_per_call and cudnn_benchmark scope. The Cosmos3
streaming node's step compiles only when the warm cache is on, as
before; its stateless re-decode never runs it.
Deleted: the three to_channels_last + DecoderStep copies, Wan2.2's
per-request inference_mode(False) block and both inline decode_causal
calls, the two cuDNN-version dtype bodies (and the TODO asking for
this), Cosmos3's _fast_step / _step and LingBot's _decode_step.
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>
* Decoder layers: one run_layers for the deferred-add residual stream; the fused add+norm is an opaque op (merceod #859 row 19)
`DecoderLayer.forward_residual` runs a layer with its residual adds left to
the norms (`RMSNorm.forward_residual`), and `run_layers` runs a stack of them
and the final norm. By default a layer attends through its engine attention
after its layer cursor moves; `attend(i, self_attn, h)` stands in for a code
predictor's kernels. Qwen3DenseLM, the Qwen3-TTS talker and code predictor
and the Qwen3-Omni code predictor use it, and their layer classes are
DecoderLayers, so the parameter paths are unchanged. The ops and their order
are the same: fp32 CPU outputs, random weights, are torch.equal to the
inlined loops they replace.
The Omni code predictor's unfused layer forward, its inner model's forward
and its own forward had no caller (the inner one could not have run: it
called the layer without the keywords it required); they are deleted.
OmniVoice keeps its loop: its layers are the reference's own modules, whose
norms have no forward_residual and whose MLP is a fused-weight FlashInfer
call, so the loop is all it would share.
FlashInfer's fused_add_rmsnorm registers its custom op with a no-op
(flashinfer/utils.py `register_custom_op` returns the function unchanged in
0.7.2), so Dynamo traced into the wrapper and broke the graph there. Each
norm call site then compiled as frames of its own, per norm and shape, which
is where Qwen3-TTS 1.7B's extra recompiles (293 against main's 103) sat.
`mstar::fused_add_rms_norm` now holds the kernel behind a graph boundary, as
`mstar::rms_norm` already did. A CUDA test compiles a three-norm stream with
fullgraph=True.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* LingBot fast numerics: compile module-level pointwise chains once, not closures per block
merceod #860 row 18. Off reference numerics, _fast_numerics setattr'd
a torch.compile closure over six bound methods (_pre, _post_self,
_norm3, _ffn, _qkv, _q) onto every block and attention module: 180
compiled wrappers on the 30-layer DiT, each tracing its own graph and
counting against Dynamo's per-code cache limit.
The chains are now module-level functions of the modules they run
(_qkv(attn, x), _pre(norm1, ...), _ffn(norm2, ffn, ...), ...), each
compiled once (functools.cache) with the options 98da67e10 set
(dynamic=False, emulate_precision_casts). Blocks and attention modules
take one constructor flag, fast (was fa4: flash-attn-4 and the compiled
chains are both the off-reference path), and call
_pointwise(fast, fn, *args); with fast off it calls fn eagerly, the same
ops the methods ran, so the reference path is unchanged. Deleted:
_fast_numerics and the six swappable methods.
Dynamo guards on the modules passed in by their structure, not their
identity: with a CompileCounter backend, three blocks' worth of calls
trace six frames, one per chain.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Wan2.2 CFG-parallel: gather through the group, drop gather_rows (860 I1.1)
gather_rows was CommGroup.all_gather behind a None-group convention the
rest of sequence_parallel does not use. The dit now holds a trivial group
when none is passed (one GPU), so my_rows takes a CommGroup like the other
helpers and the forward calls sp_group.all_gather directly; a trivial
group's all_gather returns its input, as gather_rows(None, x) did. The
collective and the guidance_scale > 1 condition it runs under are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* BAGEL parallel CFG: the sat-out velocity drops its dtype logic (860 I1.2)
sat_out_velocity read the autocast dtype inside the forward and called
that the latents' dtype rule, but step-0 latents were fp32 (prepare_inputs
runs outside autocast), so the claim did not hold. Nothing reads the dtype:
the edge carries zero elements and combine_cfg checks only shape[0]. Build
the tensor in the default dtype, say so in the docstring, and stop the
6055b60f4 test asserting fp32.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Wan VAE FastDecoder: one layout switch path; Cosmos3 reads the VAE's layout (860 I1.4)
FastDecoder.__init__ called to_channels_last directly, outside
_use_layout's inference_mode(False), though the class says every switch
runs there. It now goes through _use_layout(True), which converts the same
weights in the same way (and skips a VAE a sibling node already converted,
as the idempotent call did).
Cosmos3's decoder nodes kept a _channels_last flag beside their
FastDecoder's. _vae_input now asks wan_vae.is_channels_last(self.vae): the
flag was set exactly when a FastDecoder converted this node's VAE, and
Cosmos3 never switches it back, so z's layout is unchanged in every config.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Qwen3-Omni audio encoder: audio_seqlens is a required input (M-2)
The merge brought back the lenient `inputs.get("audio_seqlens", [None])[0]`
and an Optional default on forward. The graph always sends audio_seqlens with
audio_features (qwen3_omni_model.py's audio encoder node), and preprocess
already reshapes it unconditionally, so the read is strict again, as on main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Qwen3-Omni audio encoder: count a clip's embeddings with the encoder's n_window (M-1)
prepare_inputs counted a clip's embeddings with
_get_feat_extract_output_lengths' default n_window=50, while the prompt layout
(Qwen3OmniModel._audio_frames) and the encoder itself use the checkpoint's
config.audio_encoder.n_window. forward_batched splits a packed call's output
by that count, so an encoder with another window would split at the wrong
rows or raise. It now passes the encoder's n_window.
A CPU test packs two clips through a 52-frame-window encoder and checks each
clip's share against the encoder run on it alone (the default count claims
14 embeddings for the 104-frame clip, the encoder gives 13).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Qwen3-Omni prefix-cache tests: drop the test that pinned a flag; inline the vision test's constants (M-3)
test_the_audio_encoder_stays_eager_while_its_output_is_cached only read back
AudioEncoderSubmodule.disable_torch_compile through _compile_submodules, so
it pinned a constant rather than behaviour. The vision recompile test's
one-line tuple of node, walk and sizes is inlined at its uses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Docs: a cached Qwen3-Omni audio prefix keeps its first batch's embeddings (M-4)
The audio encoder note said a clip's embeddings depend at the last bit on its
batch-mates under packing; with prefix caching, a reused audio prefix keeps
the embeddings it was first computed with, so the note says so too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* mstar::fused_add_rms_norm: a portable default and a CUDA kernel, so add_rms_norm picks no backend (6080-B)
The op had only FlashInfer's kernel as its body, so it could not run off CUDA,
and add_rms_norm chose between it and the unfused pair by device and dtype.
It now mirrors mstar::rms_norm: the op's default is the unfused add and norm,
in place (`residual.add_(x)`, then `x.copy_(norm(residual))`, through
mstar::rms_norm or Gemma's fp32 norm), and register_kernel("cuda") runs
FlashInfer's fused_add_rmsnorm / gemma_fused_add_rmsnorm. FlashInfer's norm
kernels dispatch on fp16 and bf16 only, so the CUDA kernel falls back to the
default for other dtypes; that is the one eligibility check left, and it
lives with the kernel. add_rms_norm is a reshape around the op. The fake
impl is unchanged.
No vendor branch: the op picks its kernel by device type exactly as
mstar::rms_norm does, so ROCm behaves as before.
Gemma's fp32 norm moves from components/norm.py to rms_norm.py
(gemma_rms_norm) so the op's default and RMSNorm share it.
Behaviour change off CUDA-half: add_rms_norm now updates both inputs in
place on every device, as RMSNorm.forward_residual's docstring already
states and the CUDA path already did. Values are unchanged; on XPU and CUDA
fp32 the in-place form adds one copy of the normed rows per norm.
A CPU test checks the default against the unfused add and norm, Gemma
included (before, the op imported flashinfer on any device).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tests: the fused add+norm graph-break test compiles with aot_eager (6080-F)
backend="eager" stops at Dynamo, so the op's mutates_args declaration was
never functionalized. aot_eager runs AOTAutograd's functionalization too,
so a wrong mutation declaration fails the test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Qwen3.5: the layer loop is run_layers, with the TP group passed to the norms (6080-C)
Qwen3_5LanguageModel._forward_fused_residual was a fifth copy of the
deferred-add loop. It differed from run_layers only in two ways: the norms
take the TP comm_group, and each layer moves its mixer's cursor (attend or mix)
to that layer's resource index. run_layers and DecoderLayer.forward_residual
now take comm_group and pass it to both norms and the final norm. Qwen3.5
passes its cursor move through the existing attend hook (_attend). The
hand-written loop is deleted.
The TP ops are the same: RMSNorm.forward_residual already took comm_group,
and it reduces only when residual is given. The first layer's input norm
started the stream without a group (the embeddings are a full tensor), and
that is still what happens. The guard that raised ValueError when a stream
started under TP is removed. It refused exactly this case, a TP stack whose
first input is the full embedding output, and the norm cannot tell a full
tensor from a partial sum. The docstring states the contract. The cursor
move now runs after the layer's input norm rather than before it. That is
host-side state the norm does not read.
CPU fp32 check (throwaway harness, not committed): tiny Qwen3.5 config,
random weights, stub mixers. The new forward is torch.equal to the deleted
loop, and the cursor calls come in the same sequence. TP2 needs a GPU.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Code predictors: one cp_attend for Qwen3-TTS and Qwen3-Omni (6080-D)
The two forward_depth_unrolled attend closures were the same code. The only
difference was that Omni called F.linear(h, attn.qkv_proj.weight) where TTS
called attn.qkv_proj(h). Both are ParallelAttention modules on a trivial
comm group, and Omni's has no qkv bias, so qkv_proj(h) is that same
F.linear. cp_attend in code_predictor_kernels.py is the shared function, and
each predictor binds its cache arguments with functools.partial. cp_attend
is plain Python, so its projections still compile; the launchers it calls
keep their torch.compiler.disable. Omni's leftover locals go, and so does
its comment that FlashInfer's rope needs bf16 (the Triton prologue replaced
that rope). The Qwen3-TTS comment that said Omni used a different attention
primitive and deferred sharing until #199 now points at the shared path.
The TTS CPU fakes patch the kernels module, where cp_attend looks the
launchers up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Qwen3-Omni code predictor: docstrings describe the dense-KV Triton path; dead helpers go (6080-E)
forward_depth_unrolled's docstring and section header called it SDPA and a
replacement for a paged-FlashInfer path. It attends through cp_attend's
Triton kernels, and the eager fallback is gone. consolidate_stacked_weights
said the per-head lm_head modules serve that eager path; they stay only as
the weight loader's targets. get_embedding, get_lm_head, the codec_embedding
alias, the FlashInfer _apply_rope and _num_residual had no callers anywhere
in the repo (grep over mstar/, test/, benchmark/, scripts/) and are deleted.
A plan reference ("Option P") goes from the lm_head_weight comment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* OmniVoice silence test: say the shared-prefix-sum case guards the flipped path on CI (7C-1)
The pydub comparison skips without the omnivoice package, as on CI, so this
case is what checks _Segment.reversed_tail there; the docstring says so.
7c270c703's saving, measured (inv 10): remove_silence on 24 kHz speech-like
clips (mid_sil=500, lead_sil=100, trail_sil=100, OmniVoice's settings),
parent 30fb70b4f and 7c270c703 interleaved in one process on the Eras CPU
box (r7i.4xlarge, OMP_NUM_THREADS=2), 550 timed calls each, two runs:
10 s clip 6.73 -> 5.46 ms median (-1.27 ms, -19%), 30 s clip 13.7-17.6 ->
11.4-15.1 ms (-2.3 to -2.5 ms). A 10 s OmniVoice request takes about 130 ms
end to end at c=1, so the saving is about 1% of it and over 1 ms; the change
stays.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tests: wrap the packed-clip test's long line (M-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tests: the host-sync test's stand-in audio encoder has an n_window (M-1)
AudioEncoderSubmodule.prepare_inputs now reads the encoder's n_window, as
the real Qwen3OmniMoeAudioEncoder carries; the stand-in gets the
checkpoint's 50.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* 859 R2.1, R2.2: fix stale code-predictor docstrings; note the fused add-norm fallback
R2.1: the code predictor runs in the autocast dtype, not float32, and its
scratch KV cache is allocated once and never zeroed; it needs no clearing
because every position a depth step reads is written earlier in that step.
R2.2: say at mstar::fused_add_rms_norm's default kernel that it is the
portable fallback (XPU, CUDA fp32), opaque to Inductor, and that an XPU
model adopting run_layers can register a fused kernel there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* LingBot-World: drop the fast config from this stack; #926 owns it
#926 brings LingBot-World's fast path through the ragged attention
resource, a deterministic block compile and a bf16 channels-last decode,
under the same lingbot_world_1_3b_fast.yaml name. This removes everything
here that existed only for that config, as introduced by 29bf80124 (its
LingBot fast parts), 7b38d578d, fac2fb744, bc110b94d, e8519d791,
35526b3b9, dbc9bb679 and f312336d2:
- configs/lingbot_world_1_3b_fast.yaml and its rows in docs/models.rst
and docs/installation.rst;
- reference_numerics, the flash-attn-4 attention path and its import
check, the compiled block pointwise chains and emulate_precision_casts,
the one-row timestep embedding, the cuDNN-autotuned condition encoder;
- vae_decode_compile / vae_decode_cudnn_benchmark, the bf16 weight cast,
and the named vae_decode_* model kwargs (back to main's **kwargs);
- their tests (the bool-settings test, the kwargs test, the compiled-chain
test).
config.py, lingbot_world_model.py and test_lingbot_world_shell.py are
main's again. Kept, as they serve the default config: 45e67ab32's shared
FastDecoder (eager, contiguous by default, the same decode as main's
DecoderStep path), the per-chunk camera modulation (99a7b64b6), the
single-row e/e0 broadcast and the no-op RoPE concat skip, all bit-identical
on the default config.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* LingBot-World: back to main exactly
The LingBot-World code on this stack now matches main: the per-chunk
camera modulation, the single-row e/e0 broadcast, the RoPE-concat skip and
the decode through wan_vae.FastDecoder.decode_stream go, and the dit
decodes through main's DecoderStep / decode_causal again (run_controls.py
too). wan_vae's FastDecoder and decode_dtype stay for Wan2.2 and Cosmos3.
git diff origin/main over mstar/model/lingbot_world, configs/lingbot* and
the LingBot tests is empty.
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 #859 row 11: the patch grids become host tuples once, in process_prompt
process_prompt turns each image's and video's grid into a (t, h, w) tuple of
Python ints where it builds the lists, sizes the spans from those tuples, and
puts the same tuples in the metadata. The wire codec carries tuples of ints
through model_kwargs and step_metadata exactly, so the schedule's tuple(...)
and the submodules' _host_grids re-conversions were no-ops; both go, along
with the host() helper and media's 2-D grid guard (every grid is (3,)).
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>
* merceod #860 row 15: share ragged_for between DenoiseLoopSubmodule and Wan2.2
Wan22DitSubmodule's dit attention callable (_attention_fn) was a copy of
DenoiseLoopSubmodule.ragged_for. Move ragged_for, its per-binding memo and the
rebind reset into RaggedAttentionMixin beside RaggedAttentionFn in
components/diffusion/attention.py; both classes now inherit it. Wan sets
attn_resource_key = SELF_ATTN and calls ragged_for(VIDEO_SPAN), so it gets the
same partial(resource.run, label="video") from the same resource key, memoized
per binding as before. Wan's copy, its bind_node_resources override, its
_self_attn_fn field and its one-time "self-attention through the ragged
resource" log line are deleted, as are two test lines that stubbed that field.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* 859 R3.1: Higgs valid_frames go up from a list
Pass the host list of chunk frame counts straight to to_device_nonblocking
with dtype=torch.long, so it is built once in pinned memory instead of as a
pageable tensor that the helper then pins again.
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>
* BAGEL parallel CFG: the guided window is its own walk, decided at intake (merceod #860 row 3)
On a CFG-parallel deployment every image step ran image_gen_cfg's three
branch nodes and combine_cfg. Each node counted the step's branches from
its own worker's loop counter, and a branch outside the window sat the
step out: a zero-row velocity, a None declare_step, and a host check in
combine that the counts agreed. The guard on those counter reads failed
every CFG-parallel request ("loop counter reads 0 on a later
iteration"): under the Rust graph runtime the branch nodes are
dispatched once more after the loop's last iteration with the counter
at 0 (MSTAR_RUST_GRAPH=0 passes; that runtime fix is separate). Main
never read a counter on these nodes.
t(k) decreases with the step, so a request's guided steps are one window
[a, b) with one branch count, known at intake. process_prompt resolves it
(cfg_window, an internal kwarg) and the conductor runs the request's
phases (image_gen_phases): image_gen_head for k < a on LLM alone,
image_gen_cfg2/3 for the window on the branch nodes, then image_gen
from b (which decodes), or image_decode when the window reaches the last
step. A request without guidance runs image_gen and never reaches the
branch ranks. One builder makes every denoise walk from (branches,
decode), each with its own loop, which get_loop_caps caps at the phase's
steps; latents and time_index cross phases as persisted edges, and
get_partition_forward_pass_args moves the request on, so every rank
follows the conductor's walk. Each phase carries its branch count in
step_metadata; only single-GPU image_gen with the window inside it
still reads its loop counter (and keeps the seeded-latents guard).
Deleted: sat_out_velocity, cfg_branch_skipped, CFG_BRANCH_LABELS,
_sits_out, the sat-out forward and prepare_inputs branches (row 12's
6055b60f4/28dc0d77d slim inputs), declare_step's None return, combine's
zero-row check and its per-step branch count, and image_gen_cfg. The
CFG-parallel configs name the new walks.
Tests: the sit-out cases become a phase-schedule test against a copy of
the old per-step count (interval, scales, timesteps/shift, both
placements) and one test of the conductor's walk sequence.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* BAGEL: stage host index lists straight into pinned memory; tests name the image_gen_cfg2 walk (860 I2.1, I2.3)
The ViT cu_seqlens and the LLM text_indexes pass their lists to
to_device_nonblocking with dtype=, one pinned allocation each instead of a
pageable tensor that is then pinned. No other caller builds a torch.tensor
from a host list for it.
The image_gen_cfg walk no longer exists; the tests that named it now name
image_gen_cfg2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* BAGEL test: the conductor walk accumulates persist signals and drops what is unpersisted (860 I2.2)
The conductor appends each walk's persist signals and removes the uuids a
walk unpersists; the test replaced them per walk, so it passed even if
_get_unpersist_tensors stopped returning the fed latents and time_index.
It now accumulates like the conductor and asserts every fed uuid is
unpersisted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* BAGEL: name a window's CFG walk from CFG_WALKS; IMAGE_GEN_LOOP is test-local (860 I2.4)
image_gen_phases looked the CFG walk up by formatting its branch count into a
name; it now reads CFG_WALK_BY_BRANCHES, the inverse of CFG_WALKS, so the
walk names live in DENOISE_WALKS alone. IMAGE_GEN_LOOP had no production
reader and moves into the one test that uses it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* BAGEL CFG-parallel: publish only the KV labels the request's CFG walk reads (860 I2.5)
The window is known at intake, so the main LLM's publish labels follow the
request's phases on a CFG-parallel deployment: cfg_text for a 2-branch
window, cfg_text and cfg_img for 3, and nothing for a guided request whose
window has no step. A t2i default request no longer exports cfg_img at
prefill and after a think decode, which no node of its walks reads.
The labels come from the conductor's request config, so every rank of the
LLM instance publishes the same set; the KV layout, the consumers' needed
labels and the single-node and split-LLM deployments are unchanged (a
remote LLM's single-node walks read every branch label).
The conductor-walk test's intake moves into a helper that the new test
shares.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* graph runtime (Rust): drop a finishing loop's loop-back edges to other worker graphs
A loop split across worker graphs (BAGEL's CFG-parallel denoise: combine_cfg
on rank 0, LLM_cfg_text / LLM_cfg_img on peers) loops back through them. On
its last iteration Python drops every loop-back (name, next_node) pair
whatever the dest. Rust compiled only the pairs whose dest was in its own
worker graph and dropped only Dest::Local edges, so it still sent
latents/time_index to the peers (and to LLM, a sibling worker graph on the
same worker). Each branch then ran one more pass with its loop counter back
at 0.
Compile every loop-back pair as (signal, dest node name), one list for the
exit filter and speculation, and drop edges by (name, dest_sym) when the loop
finishes, as Python does. Delete Completed.filtered and register_loop_finish's
return value: neither was read, and both returned only the local pairs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* code predictor: no per-position recompile of cp_attend (GPU recompile count)
A captured forward compiles with dynamic=False, so every compiled frame
that holds a depth step's cache_pos (or passes it to a call at a graph
break, partials included) guards on it and recompiles at each of the 16
depth positions and each batch bucket. Before run_layers this could not
happen: the launcher calls sat in forward_depth_unrolled's own layer loop,
and Dynamo runs a frame with a graph break inside a loop eagerly, so only
the norm and MLP frames it called were compiled. With run_layers the
breaks landed in loop-free frames that hold the ints: cp_attend and its
resume frame, DecoderLayer.forward_residual (through the partial's args)
and forward_depth_unrolled (rebuilding the partial at the break).
Every frame holding them now runs eager:
- cp_attend is torch.compiler.disable'd; its qkv/o projections are cuBLAS
GEMMs at -O1 compiled or not.
- run_layers calls attend in its own loop rather than through
DecoderLayer.forward_residual (deleted; it had no other caller), so an
opaque attend sends the loop to eager as the old depth loop did. The ops
and their order are unchanged for every caller.
- forward_depth_unrolled (Qwen3-TTS, Qwen3-Omni) skips its own frame with
torch.compiler.disable(recursive=False). Its callers already broke at it.
Only the norm and MLP frames compile, none keyed on a position. The test
compiles the talkers' depth-loop shape over 2 and 4 positions for both
code predictors and requires the same frame count; at 3abebd178 it is
11 vs 19 (Qwen3-TTS) and 17 vs 31 (Qwen3-Omni).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* run_layers: one compiled frame per layer besides attention
run_layers runs eager (its loop breaks at the layer cursor), so every
compiled frame it calls is dispatched on each uncaptured forward. Calling
the input norm, the post norm and the MLP separately made that three
frames per layer, where DecoderLayer.forward_residual and its resume
frame had made two.
DecoderLayer.forward_mlp_residual runs the post-attention add+norm, the
MLP and the next norm's add+norm (the next layer's input norm, or the
final norm) as one frame holding no layer index. The ops, their order,
the fused add+norm placement and comm_group are unchanged; the first
layer's input norm runs once before the loop.
The test counts the compiled graphs one forward of a Qwen3 dense stack
runs: each extra layer may add attention's graphs plus one. At d55935f03
it adds 5 (attention's 2 plus input norm, post norm, MLP).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Qwen3.5: _attend runs eager, so no frame guards on the layer index
run_layers calls Qwen3_5LanguageModel._attend from its eager loop, so
_attend was a top-level compiled frame. It reads _cursor_attr[i] and
_resource_idx[i] before the graph break at set_layer_idx, which guards
it on i: one empty-graph compile per layer per compile wrapper, counted
as recompiles and toward MSTAR_RECOMPILE_LIMIT. It is
torch.compiler.disable(recursive=False) now, so the mixer it calls still
compiles as its own frame. No other attend binds i in a compiled frame:
the default path sets the cursor in run_layers' eager loop, and cp_attend
is disabled.
The depth-loop test becomes test_layer_loop_compiles_no_frame_per_step,
parametrized over Qwen3-TTS, Qwen3-Omni (now including its opening
two-token pass at cache_pos 0) and Qwen3.5's layer loop with stub mixers.
It counts the frames Dynamo compiles rather than graphs: _attend's
per-layer compiles have empty graphs, so a graph count is equal at
d55935f03 (5 vs 5) while compiles are 11 vs 9 there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* cp_attend docstring: the cuBLAS claim holds up to -O1
At -O2 max-autotune may pick a Triton GEMM template for the compiled
projections, so eager is not equivalent there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tests: the per-layer frame bound compares the two-layer difference whole (D5)
(calls4 - calls2) // 2 rounded an odd difference down, so one stray frame
per two layers could pass. Compare the difference with twice the bound.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tests: run_layers' compile tests live with the Qwen3 LM, on one counter (D4)
The Qwen3.5 layer-loop case moves from the Qwen3-TTS module into
test_qwen3_lm.py beside the per-layer frame test, and both modules count
through one dynamo_counts fixture in conftest (frames compiled, and graphs
one compiled call runs) instead of a helper each. The code-predictor
depth-loop cases stay with their builders in the TTS module.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* run_layers: an empty layer stack is the final norm again (D3)
Starting the stream at layers[0]'s input norm raised IndexError on an
empty stack, which had returned the final norm before 76ba991e2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* code predictor: cp_attend's projections compile again (D1)
cp_attend was torch.compiler.disable'd whole, so its one-row qkv and o
projections ran on eager cuBLAS. At the default -O1 (coordinate descent)
Inductor rewrites an M==1 mm into a fused Triton reduction, and Qwen3-TTS
measured 25-30% slower at -O0 at c=1 (docs/serving.rst). It is
disable(recursive=False) now: its own frame, which binds cache_pos and
layer_idx, stays eager, and the projections compile as int-free frames of
their own. The launchers stay disabled. The docstrings said the
projections were cuBLAS GEMMs either way; they now say what compiles.
The new test requires both projections' weights in a compiled graph of
the depth loop, for both code predictors; it fails at 9f26410bd. The
per-position compile test still passes.
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>
* Tests: the projection-compile test counts through dynamo_counts (E2)
It had its own backend, reset and torch.compile beside the fixture. The
fixture's DynamoCounts gains traced, the data_ptrs of the tensors its
compiled graphs take, and the test reads that.
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>
* Qwen3.5 / run_layers: capture works again (fail_on_recompile)
Every capture runs its forward under torch.compiler.set_stance(
"fail_on_recompile") (capture_into_graph). torch.compiler.disable(
recursive=False) skips only its wrapper's code; the decorated function's
own frame never gets a cache entry, so Dynamo's callback runs on every
call, and convert_frame lets it run eager only because its caller is the
wrapper. The capture stance's fail_callback has no such check: it raises
on the first warm call, whatever the shapes. So every bucket of a forward
that reaches one fails to capture: Qwen3.5 through _attend (1438ea127),
Qwen3-TTS and Qwen3-Omni through forward_depth_unrolled (d55935f03) and
cp_attend (a9bf02141). The undecorated _attend of 0fe8c812d captured
because it was compiled at warm-up, one entry per layer index.
No frame is recursive=False now. Each per-layer or per-step int is read
only in fully torch.compiler.disable'd cursor methods, called from loops
that already run eager, and the attention runs as its own int-free
module frame:
- run_layers' hook is set_cursor(i, self_attn), which replaces the
default self_attn.attend.set_layer_idx(i); the loop then calls
self_attn(h) on both paths. Qwen3.5's is _set_cursor.
- cp_attend is gone. A code predictor's layers are DepthAttention (a
ParallelAttention: qkv and o projections, compiled) over the predictor's
DepthKV, a cursor holding the frame-local KV cache and the depth step
(bind_step, set_layer_idx, and the two kernel launches). run_layers
moves it per layer through the default path.
- forward_depth_unrolled takes only the input embeddings; the depth loops
in the Talkers bind the step on cp.depth first, so cache_pos never
enters a compiled frame.
dynamo_counts now makes its second, warm call under the capture stance,
as the runner warms a bucket and then captures it. With only that change
on da5514d26, the Qwen3.5 per-layer test and both code-predictor tests
(per-position, projections compile; TTS and Omni) fail with the same
"Detected recompile ... '_attend' / 'forward_depth_unrolled'" errors the
GPU runs logged; they pass here, with the per-layer, per-position and
projection checks unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Code predictor: a DepthKV binding serves one pass over the layers
DepthKV kept the previous step's cache, positions and cache_pos, so a
depth step whose loop skipped bind_step wrote and attended over the
previous step's slots without a sign. The last layer's call now drops
the binding after its launches, inside the eager __call__ (no new graph
break; a capture has already recorded the cache's pointers, and the
caller owns its storage), and a call with none bound raises ValueError.
The leading dim of the cache is the layer count, as both Talkers
allocate it.
The bind_step argument shapes move from Qwen3-Omni's
forward_depth_unrolled docstring into DepthKV.bind_step's; the stale
"Position IDs come in directly" bullet goes, the Omni class docstring
calls depth the cursor it is, and the recursive=False explanation lives
only in run_layers.
Tests: a second forward_depth_unrolled without rebinding raises (fails
at 16d62450f: DID NOT RAISE). The node's bind_node_resources leaves each
DepthAttention on the predictor's DepthKV (fails with the empty
bind_resources override removed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Code predictor: the first layer consumes a DepthKV binding
The guard no longer depends on the cache layout. bind_step marks the
binding fresh and set_layer_idx(0), the start of a pass, raises
ValueError unless it is, then consumes it; the last-layer clear and the
None check in __call__ go. bind_step's docstring says the cache is not
cleared between frames (a depth step reads only what its frame wrote)
and that the submodule allocates it.
The code-predictor tests share _cpu_predictor (config, bs, calls) for
their identity-norm, fake-kernel setup. The bound-once test still fails
at 16d62450f (DID NOT RAISE).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rust loop exit: test the sibling-graph path and the routed edge directly
The split-loop test now also runs with step in a sibling worker graph on
this worker, and asserts the last iteration routes no x to step: not staged
for a peer's read, and not ingested into the sibling graph. Correct the
LoopSpec / LoopArg comments, which said both lists are kept verbatim, and
inline the single-use resolve closure.
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>
* test_row_output_store: a withheld output stays out of the row store
Both Talkers now withhold their EOS frame through the row-store fast path;
pin that it reads the popped per-row outputs, not the row views.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Docs: the Cosmos3 two-GPU numbers name their hardware and predate main's Edge changes
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>
* PiecewisePackedConfig: attn_key is resolved when the region declares, not by rewriting declare_step
attn_key used to overwrite the config's public declare_step field with a
bound method in __post_init__. A dataclasses.replace of such a config then
passed both fields and raised, and a shallow copy kept declaring through the
original's method. The runner now asks the config for the region's step
(region_step), which answers from attn_key or declare_step on the instance
it is called on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Engine: a forward gets a lease_before_step region's runner only on a step that leased it
A region that takes its slot before the outer declaration must replay only
on a step that leased it: unleased, the step's own declare_step covers the
region's resources, and a replay would declare, plan and commit them a second
time. Each forward used to enforce this itself, against
ModelInputsFromEngine.piecewise_leases (Qwen3.5 vision, Qwen3-TTS,
V-JEPA2, Omnilingual, OmniVoice) or against engine_inputs.step being None
(Qwen3-ASR, nemotron duplex). The engine now leaves an unleased region out of
the runners it hands the forward (SubmoduleManagement.runners_for), so a
forward replays whenever its runner is there. The piecewise_leases field on
ModelInputsFromEngine and the per-model checks and their tests go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Device loop-back is its own declaration, DeviceLoopback, not a TokenStop field
TokenStop.loopback_signal bundled two concerns into the stop policy: which
token ends the loop, and how a decode walk reads its token back on the device.
Declaring it also made the walk's step input-free, which silently turned on
pre-plan reuse for Whisper, Qwen3-ASR and Higgs decode, whose measured numbers
predate that. Loop-back is now ARNodeSubmodule.device_loopback, a
DeviceLoopback(walk, signal, sampler, buffer); the loop-back hooks
(uniform_row_inputs, device_loopback_signals, inline_client_signals) follow
from it, and one base read, loopback_inputs, replaces the four copies of the
slot-master read and the routed-row overwrite (Whisper passes its timestamp
rule state as a declared loop-back state). step_is_input_free is again each
node's own answer: Qwen3.5 decode says so, the others keep the default.
A walk is now a required field, so the engine's check for a missing one goes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Stop check: the step closing a reply that ended at prefill withholds the loop-back signal too
When a prefill walk samples a stop id, the loop's first step only closes the
reply, and its outputs are withheld. It withheld the token's buffer
(new_token), but a device loop-back node's client edge carries its loop-back
signal (text_inputs), inline off that buffer: the worker nulls an inline value
only when the signal itself is withheld, so the token sampled after the stop
id reached the client. On the tensor path the routed signal leaked the same
way. The closing step now withholds the node's DeviceLoopback signal beside
the buffer, so prefill_walks and device loop-back compose.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Chatterbox T3, Higgs Audio, Qwen3-ASR: a stop token sampled at prefill ends the reply
These prefills sample the reply's first token, but their TokenStop declared
no prefill_walks. A stop id sampled there was either ignored (Higgs,
Qwen3-ASR: only a decode row could stop) or a stop for a loop not yet
entered, which the runtime drops (T3), so decode ran on to the cap. They now
list their prefill walks: the loop's first step closes the reply and its
outputs are withheld. As TokenStop says, that first token counts toward the
budget, so Higgs and Qwen3-ASR now give the loop max_output_tokens - 1 and a
capped reply is max_output_tokens long, not one more (T3 already did).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Qwen3.5: a captured decode step stages no rotary tables, whatever its rows bring
A captured decode builds its rotary tables inside the graph from the planned
positions, so preprocess skipped them, but only on the loop-back branch. A
step whose rows all brought their token routed (each request's first decode
step, or every step with MSTAR_DEVICE_LOOPBACK=0) still built the 3D positions
and cos/sin, and the capture held two static buffers for them, all unread.
preprocess now returns before them on any captured decode step, and the
capture test reads engine_inputs.captured instead of probing the step's
context for a slot lease.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* mstar::fused_add_rms_norm: the CUDA kernel takes FlashInfer's fused path only when x, residual and weight share a dtype
FlashInfer's fused add+RMSNorm reads the residual and the weight in x's
dtype, but the kernel branched on x's dtype alone. mstar::rms_norm casts its
weight; this op, which run_layers hands every decoder stack, did not, so a
model whose norm weight or residual stream was in another dtype would have its
buffers read as the wrong type. Any mix now takes the unfused add and norm,
which casts the weight.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* One cuDNN benchmark scope, beside the engine's precision scope
The engine's private _cudnn_benchmark, added for capture_cudnn_benchmark, was
a line-for-line copy of wan_vae.cudnn_benchmark. The helper now lives once,
in mstar.engine.fp32_precision next to fp32_precision_scope, the other
process-wide backend flag the engine sets around a node's warm-up and capture.
wan_vae imports it from there, so Cosmos3's VAE decode keeps calling it
through wan_vae.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Qwen3-Omni: the audio encoder is the AuT node Qwen3-ASR runs, packed and captured
Qwen3-Omni's audio encoder ran transformers' Qwen3OmniMoeAudioEncoder
eagerly, about 10 ms a call and mostly launches, with its own step splitter
around it. That is the same AuT architecture, on the same thinker.audio_tower
weights, that Qwen3-ASR already runs natively: every attention window of every
clip is a segment of a ragged attention resource, and the 32-layer stack
replays a CUDA graph by packed token count. The audio node is now that node
(Qwen3ASREncoderSubmodule), built without a spectrogram config so it takes the
API server's log-mel features as they arrive, over an AuTEncoder loaded from
the checkpoint and a RaggedAttentionSpec sized for the longest clip
(MAX_AUDIO_SECONDS). Omni's audio config is AuTEncoderConfig. The HF wrapper,
its frame-budget splitter, its return_dict probe and their tests go; the
host-sync test now checks the node Omni runs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Ragged attention: a one-row-per-step region's ceiling is max_segments_per_step
A region that replays a whole step as one row (one_row_per_step) captures at
batch size 1, so its plan held max_segments_per_request segments for every
request of the step: Qwen3.5's vision tower and Omnilingual set a "per
request" ceiling that meant per step, Omnilingual's to its max batch size.
RaggedAttentionConfig.max_segments_per_step now names that ceiling (and a
deployment can override it like the others); when set, it sizes every
captured plan of the resource. Both adopters declare and read it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Qwen3.5: the vision node boots on the image processor alone
The vision node's pixel normalizer loaded the video processor at worker boot,
image-only deployments included, to check that the two processors normalize
alike. A checkpoint with no video_preprocessor_config.json gets the video
processor class's defaults, so one whose image normalization differs from
those failed to boot even when it would never see a video. The check now runs
where the video processor is first loaded, on the first video: the normalizer
reads the image processor only, and such a checkpoint serves images and
refuses videos.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tests: the refused video processor is read into a name (ruff B018)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Qwen3.5 vision: the compile regions warm at start-up; the docs say when model code still compiles
The vision encoder's compile_regions declared no example calls, so a
deployment without warmup_requests compiled its patch embed and merger on the
first image request. They now warm on the smallest image the processor makes,
in the dtypes a step brings, so the dynamic graphs exist before serving. The
docs said not to call torch.compile in model code, which several models still
do for compile options compile_regions does not take (static shapes, a mode,
fullgraph); they now say to prefer compile_regions and when that does not fit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tests: Qwen3-Omni's audio encoder loads no transformers config to check
The audio encoder is now built from mstar's own reading of config.json
(AuTEncoderConfig), with no AutoConfig call that could run checkpoint code,
so the remote-code test checks the vision encoder's loader alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Kokoro: say that cuDNN benchmarking covers both captured halves
The engine turns cudnn.benchmark on around every capture warm-up of the
node, so cuDNN times the convolutions of the text half's buckets as well
as the frame half's. The comment on capture_cudnn_benchmark and the
models doc named only the frame half.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Kokoro: put the fused BiLSTM in the module tree
MaskedBiLSTM.fuse kept the fused LSTM in a plain list, outside the module
tree, beside the two unidirectional LSTMs it was built from. A later
.to(), .half() or state-dict load then reached only the unused copies,
and the fused weights that run stayed behind on the old device or dtype.
fuse now registers the fused LSTM as a submodule and deletes fwd and bwd,
so one copy of the weights exists and every module-tree operation moves
it. The checkpoint still loads into fwd and bwd, before fusing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Split speech configs: run their two workers under MPS
The Chatterbox, OmniVoice and Qwen3-TTS 0.6B split configs put two
workers on one GPU, and their throughput numbers were measured with the
workers' kernels overlapping under CUDA MPS: without it the Chatterbox
split measured the same as one worker. The YAMLs left starting the daemon
to the user. Set `mps: true`, which has the conductor run a private MPS
daemon for the deployment, as the ASR split configs do, and point the
models doc at it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* One residual code predictor for Qwen3-Omni and Qwen3-TTS
Both Talkers carried their own copy of the code predictor: the layer,
inner-model and predictor classes, a depth-loop driver, and a frame-local
KV scratch allocated in each submodule. The two drivers had drifted.
Qwen3-Omni's opened the depth sequence with one two-token pass, sliced a
constant position table and fused the embedding lookup, sum and code
write into one kernel; Qwen3-TTS still ran a separate opening pass, filled
a position tensor per depth step and summed the embeddings with three
kernels a step.
ResidualCodePredictor, beside the kernels they already shared, now holds
the layers, the stacked heads, the input projection (the identity on
Qwen3-Omni and the 0.6B) and one depth loop that takes a sampling
callable. Both Talkers build it and call its depth loop. Its DepthKV owns
the scratch, sized once from the node's largest batch, and the prologue
kernel takes each token's RoPE position from its cache slot, which is
what both callers passed, so no position tensors remain.
For Qwen3-TTS this removes one pass over the layers and about 45 small
kernels per frame. Its embedding sum is now accumulated in fp32 and
rounded once, as Qwen3-Omni's was, instead of rounded after every add, so
greedy codes can differ at near-ties. Qwen3-Omni's numerics are unchanged:
its sum is rounded to the model dtype inside the step instead of when the
next step reads it.
The wrapper of the embed-accumulate kernel now also asserts contiguous
tokens, since the loop takes any sampling callable, and the loop copies
the first embedding even when it is already fp32.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Qwen3-TTS test: name the row-output test…
Why
cosmos3_nano_tp2_sp2(TP2 x SP2 on 4 GPUs) never finishes starting. Two workers sit inProcessGroupGlooconstruction (connectFullMesh, waiting on the store for a peer) and the other two sit inncclCommSplitfromeagerConnectSingleDevice. TP-only and SP-only configs start fine.The cause is how torch names a members-only group.
new_group(..., use_local_synchronization=True)names itsha1("<ranks>_<len(_world.pg_names)>")(_hash_ranks_to_strintorch/distributed/distributed_c10d.py, same in torch 2.9.1 and 2.12.1).len(_world.pg_names)counts the groups this process has registered so far, and a rank that isn't in a group registers nothing. So two members of the same gloo group can get there with different counts, pick different names and store prefixes, and wait for each other forever.The 2x2 mesh has rank tuples (0, 1), (0, 2), (1, 3) and (2, 3). With the current per-tuple loop, rank 0 reaches the (0, 2) gloo group after making 4 groups and rank 2 after making 2, so they hash
0_2_4and0_2_2. Ranks 1 and 3 have moved on to the (1, 3)ncclCommSplit, which needs ranks 0 and 2 to join as non-members, and they never do. A one-axis mesh puts every rank in one group at the same point, which is why this never showed up before. Nothing here is ROCm-specific, so a 2-D mesh on CUDA should hang the same way (not run on NVIDIA).Making every rank create the groups in one global order isn't enough, even though the torch docstring suggests it is. All device subgroups first and then all gloo groups still hangs (rank 0 hashes
0_2_4, rank 20_2_3).What changed
WorkerParallelGroups.init_dist(mstar/distributed/communication.py) makes the gloo twins first, in the sorted order every rank already uses. A rank outside a tuple makes a gloo group of just itself at that step, so every rank's count moves in step and the members of each twin compute the same name.Verification
On 4 x MI325X with our ROCm branch, which carries these commits:
cosmos3_nano_tp2_sp2startupFor scale, the same 5 prompts on 1 x MI300X give 16.5 / 22.3 / 22.3 / 12.8 / 12.6 dB, and the diffusers reference against itself with math SDPA gives a median of 19.3 dB. This checkpoint's videos drift a lot under any attention change, so the TP x SP numbers matching the 1-GPU ones is the check here.
A CPU-only gloo script with 4 ranks reproduces the hang without any mstar code. It makes the four groups above in the same order on every rank with
use_local_synchronization=True, and ranks 0 and 2 enter (0, 2) withlen(_world.pg_names)2 and 1.Tests
test/modular/test_subgroup_creation_order.py(new, added totest/cpu-core.txt). A fakedistthat names groups the way torch does drivesinit_diston every rank of 2x2, 2x1, 1x2, 4x1, 2x4 and 4x2 meshes. It checks that the members of each gloo twin agree on its name, that every rank makes the same all-rank calls in the same order, that each twin is made by its members in one global order, and that the groups land on the right comm groups. The name check fails on main for the 2x2, 2x4 and 4x2 meshes.test_init_dist_uses_mixed_groups_only_on_xpunow expects the new call order (gloo twins, then device groups) and still checks the XPU path.pytest test/modular/test_parallel_groups.py test/modular/test_subgroup_creation_order.py: 40 passed.python -m pytest @test/cpu-core.txt(ROCm torch 2.12.1 env, GPUs hidden): 2963 passed, 236 skipped, 4 failed. The same 4 fail on main in that env (3 Wan VAE decode tolerance checks and one Rust graph-runtime test that needs a rebuilt extension), so nothing new.ruff check .: clean.Not in this PR