Repository navigation
Conversation
…d 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>
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>
… 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>
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>
… 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>
…cstring) into #859 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ake (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 6055b60/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>
…860 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 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>
…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>
…t-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>
… 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>
…r 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>
The tp and sp axes of a lockstep instance were each built, stored and iterated by their own copy of the code: WorkerGraph's _tp_ranks/_sp_ranks grid, WorkerParallelGroups' node_to_tp_group/node_to_sp_group with add and add_sp, and two near-identical loops in GlobalParallelConfig. The mesh is now row-major over PARALLEL_AXES (outermost first), built by one helper, mesh_groups, and every per-axis structure is keyed by axis name, so an axis is added by naming it. WorkerGraph.tp_size also stops being rewritten to the instance size (with the real degree kept aside in _tp_comm_size): it stays the TP degree, and the one reader that wanted the instance size, placement's sharding-group check, reads WorkerGraph.instance_size. No behaviour change: the same rank groups in the same order, the same process-group schedule, and get_tp_config_for_node / get_sp_config_for_node keep their callers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 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 3abebd1 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 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 d55935f it adds 5 (attention's 2 plus input norm, post norm, MLP). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 d55935f (5 vs 5) while compiles are 11 vs 9 there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
…ole (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>
…er (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>
Starting the stream at layers[0]'s input norm raised IndexError on an empty stack, which had returned the final norm before 76ba991. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 9f26410. The per-position compile test still passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main since 73c6c17: #883 mstar_rust in the Linux wheels, #901 output spans (BatchedModelOutput.output_spans, NodeSubmodule.spanned_outputs, the worker's crop), #904 DiT seed noise drawn at ingest (on_request_added, NoiseStager deleted, to_device_nonblocking as one memcpy into pinned memory), #903 API-server torch pool from the CPU quota and pooled image encodes, #924 robotics harness, #819 Inductor cache under ~/.cache, #881 CommGroup.agree / JointGroups.agree (agree_across_ranks and flagged_on_any_rank deleted), #724 CUDA IPC KV in one 2D memcpy a page, #387 GLM-5.2 (MLA, fp8 MoE with NoAuxTcRouter, the DSA resource), #921 vjepa2 AC crop, #922 Cosmos3 contiguous positions, #923 dense attention falls back when fa3-fwd has no kernel for the GPU. Conflicts: - mstar/utils/h2d.py, to_device_nonblocking: the stack's signature (data: Tensor | Sequence, device, dtype=None) and DeviceCapability pin_memory gate, with main's tensor path (#904): a CPU tensor is written into a fresh pinned buffer with one memcpy rather than pin_memory()'s copy_ over the intra-op pool. Every CPU tensor now goes through a fresh buffer, so the stack's "an already-pinned source is read in place" caveat (R3.3) no longer applies and is dropped from the docstring. test_accelerator_graph_backend's pinning test checks the pinned torch.empty and its non-blocking copy instead of pin_memory(). - mstar/model/components/diffusion/denoise_loop.py: imports, both (main's copy, the stack's functools). Main code on a superseded path, ported here: - GLM-5.2 (glm52/submodules.py): its own check_stop / check_stop_batched (four arguments, step_walk, its own row-map loop) / _stops -> token_stop = TokenStop(decode_loop, new_token, eos_token_ids, sampler) with a _token_stops override for its two differences (a stop token at prefill ends the reply at the first decode step; max_tokens counts the prefill's token), and a check_stop override for that closing step, whose token postprocess drops. test_glm52_model's helper and test_model_kv_layouts' GLM cap test build the real submodule (over a stub language model) instead of object.__new__, which would leave token_stop unset. - PinnedStager.to_device (#387), a second "host values -> new device tensor" helper beside to_device_nonblocking: deleted with its test (test_pinned_stager_to_device.py); its callers, GLM-5.2's prefill seq_lens and the DSA resource's page tables, call to_device_nonblocking(list, device, dtype=torch.long). test_dsa_resource spies on that instead. The other files both sides touched (engine.py, accelerator_graph_runner.py, kv/manager.py, attn/base.py, ragged/base.py, communication.py, model/base.py, submodule_base.py, entrypoint.py, conductor.py, moe.py, image_io.py, vjepa2_model.py, docs, tests) auto-merged into disjoint hunks: per file the merged delta equals main's own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
…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>
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>
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>
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>
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>
…uns its branch over the batch Since the dit moved onto the scaffold's denoise loop, DenoiseLoopSubmodule is RaggedAttentionMixin's only user, so the mixin folds back into it as on main. The declare_step test keeps only what the per-rank segment test does not cover. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… of each number Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop wan22_cfg_parallel_2gpu.yaml, my_rows and the dit's sp-group plumbing, which used the SP slot to mean a CFG split. The cfg_size axis carries it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a commented opt-in Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…der capture Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Wan2.2's dit moved onto the scaffold's denoise loop there; the CFG-parallel removal is reapplied to that version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…to perf/cfg-axis-core # Conflicts: # benchmark/command_a_plus/validate_checkpoint.py # docs/models.rst # docs/serving.rst # mstar/model/base.py # mstar/model/command_a_plus/tests/test_reference.py # mstar/model/wan22/submodules.py # mstar/validation/placement.py # mstar/worker/worker.py # test/modular/test_recurrent_prefix_snapshots.py # test/modular/test_subgroup_creation_order.py # test/modular/test_tp_read_failure_acceptance.py # test/modular/test_wan22_model.py
#860 dropped Wan2.2's CFG-parallel over the SP slot; this PR keeps it on the cfg_size axis, so its side of those files stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # configs/wan22_cfg_parallel_2gpu.yaml # docs/models.rst # mstar/model/components/distributed/sequence_parallel.py # mstar/model/wan22/submodules.py # mstar/model/wan22/wan22_model.py # test/modular/test_wan22_model.py
main's KV routing and data worker read a group's tp_size (or tp * sp) as its instance size, and its lockstep placement message named TP and SP only; with the cfg axis, the instance is cfg * sp * tp. Ascending instance ranks already rule out the other-leader case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… into perf/cfg-axis main's #1078 computes the DiT's modulation per frame; under Ulysses a rank's token slice can cut a frame, so the dit expands the timestep per token there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…to perf/cfg-axis-core Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rf/cfg-axis Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rf/cfg-axis Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| node_sp_group = parallel_groups.get_sp_config_for_node(name) | ||
| if node_sp_group.world_size > 1: | ||
| extra["sp_group"] = node_sp_group | ||
| joint = parallel_groups.get_joint_group_for_node(name) |
There was a problem hiding this comment.
_unchunkable_nodes (:45) calls the removed get_sp_config_for_node, so Qwen3.5, Qwen3-Omni, Orpheus and BAGEL fail to load. You can use get_joint_group_for_node(node).sp_group (also #949; test_chunked_prefill.py:757's stub hid it). On H200, Qwen3.5-9B raises AttributeError at load; with the fix it boots, captures and serves
There was a problem hiding this comment.
Fixed in b3eaf9ddf - it reads get_joint_group_for_node(node).sp_group now, and the test builds real WorkerParallelGroups so a stub can't hide this again. Ty!
|
|
||
| tp_group: CommGroup | ||
| sp_group: CommGroup | ||
| instance: CommGroup |
There was a problem hiding this comment.
Merging main breaks 5 CPU Core tests: main's test_lora_pool.py:498, test_lora_tp_lockstep.py:419 (and GPU-only test_qwen3_tts_packed_replay.py:85) build JointGroups without instance
There was a problem hiding this comment.
Fixed in the main merge (8020ecc00): those tests pass instance now. Main's test_validate_deployment.py LoRA case also expected the old "TP x SP shape" message - fixed in 5ca1a571f.
| ``RaggedAttentionConfig.shard_over_sp=False`` splits over TP only.""" | ||
| group = self.joint_comm_group | ||
| return 1 if group is None else group.world_size | ||
| return 1 if group is None else group.tp_group.world_size * group.sp_group.world_size |
There was a problem hiding this comment.
Heads-up: #1124 turns this into group.world_size // sp, which counts CFG ranks once this lands, and still calls the removed getters. Whichever merges second, keep it TP x SP proabaly, or TP alone when positions go over SP
There was a problem hiding this comment.
Makes sense - whichever lands second, I'll keep it TP x SP (TP alone when positions go over SP) in #1124.
Main's LoRA pool, LoRA TP lockstep and Qwen3-TTS packed replay tests now pass the instance group JointGroups requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_unchunkable_nodes still called the removed get_sp_config_for_node, so every model that chunks prefill (Qwen3.5, Qwen3-Omni, Orpheus, BAGEL) failed to load. Its test now builds real WorkerParallelGroups, so the stub can't hide a drifted getter again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sage Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
R2.1: the side_stream refusal fires for any lockstep instance, CFG-only nodes included, so its message now says "it runs in a lockstep instance (TP, SP or CFG)" instead of "tensor or sequence parallel". The test's match string follows. R2.2: MSTAR_TP_ASYNC_SCHED applies to every node in worker.parallel_nodes, which counts CFG nodes, so AGENTS.md and the async-worker skill now say "(TP/SP/CFG)". The same fix goes to the other places that describe a lockstep instance as TP/SP only: the env var doc, the side-lane refusal list (submodule_base, adding_models, serving), the shared-resource placement refusal, the metrics leader-reports note, and two comments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Up to date with main at
9c6f1ad75by a merge (#860 has landed).CFG parallelism (classifier-free guidance, with the conditional and unconditional branches run on different GPUs) had no mesh axis of its own. Wan2.2 borrowed the SP axis for it: an
sp_size: 2instance whose ranks split guidance rows rather than the sequence. The engine sizes attention heads byEngineResourceInfo.head_shards, the node's whole instance size on the base, so Wan's dit declaredRaggedAttentionConfig(shard_over_sp=False)to keep every head on those ranks. Sosp_sizemeant two different things, and a CFG-parallel node couldn't also use Ulysses sequence parallelism. Separately, everything the ranks of a lockstep instance must agree on (capture barriers, and the votes throughJointGroups.agree: capture decisions, the KV manager's symmetric free-pages check, the read-check vote, the TP-async flag check) ran as one collective per mesh axis, with the axes listed by hand. This PR gives CFG its own outermost axis,cfg_size, moves Wan2.2 onto it, and makes every lockstep agreement one gloo collective on one group per instance.The mesh is row-major over
PARALLEL_AXES, outermost first, and one helper (mesh_groups) builds it; comm groups, rank lists and shapes are keyed by axis name. The axes' own declarations still have one field or keyword each (ShardingConfig.<axis>_enabled_nodes,JointGroups.<axis>_group,get_submodule'ssp_group/cfg_group), and which axes split heads is written out inhead_shards. That step changes no behaviour.JointGroups.instanceis aCommGroupover the instance's ranks, and every agreement is one gloo collective on it: a barrier, or a vote (JointGroups.agreeis oneCommGroup.agreeon it rather than one per axis). It has no device group and no NCCL communicator, so the capture barriers are now gloo barriers. TP-only and SP-only deployments reuse their axis's gloo group and create no new process group. A multi-axis instance's gloo group is made first, on every rank: a rank outside the instance makes a group of just itself, so all members agree on the group's name. On XPU it is made like the XPU axis groups, and a device collective on a group with no device group raisesValueError. Placement refuses a lockstep instance whose ranks are listed out of order ([1, 0]withtp_size: 2): torch orders a process group by global rank, so such a group would gather in a different order from the one its ranks split work by and scramble TP shards or swap CFG branches without any error. Instances themselves may come in any order ([2, 3, 0, 1]is two valid replicas), and no shipped config is affected. CFG is set by a node group'scfg_size, shares the TP and SP code paths for instances, groups and lockstep agreement, and routes as one sharding group. On the wire it is still sent astp_size(the instance size), so the conductor's messages and the Rust shard templates are unchanged. A model declaresShardingConfig.cfg_enabled_nodeswith the most branches each node runs, and placement refuses, before any worker starts,cfg_sizeon an undeclared node or one that doesn't divide that count; a paged KV cache on acfg_size > 1node, because the pool's rank math counts every instance rank as a head shard (branches that hold different KV are separate nodes in a graphParallelon acfg_size: 1node group, as BAGEL'sLLM_cfg_textandLLM_cfg_imgare); and ashard_dimsignal into or out of a node whose node group hassp_sizeorcfg_sizeabove 1, since the fanout splits such a signal over the whole instance while only the TP ranks split the weights (under TP2 × CFG2 or TP2 × SP2 each rank would get a quarter of the tensor where its weights expect half; that held for SP on the base too, and no shipped model or config declares ashard_dim). The engine manager hands the nodecfg_groupinget_submodulewhen it is non-trivial.head_shardsgoes from the instance size to TP ranks × SP ranks, so CFG ranks keep every head;_build_raggedstill splits ashard_over_sp=Falseattention over TP only (LTX-2.5 SP2's audio and cross attentions). Wan2.2'swan22_cfg_parallel_2gpu.yamlsetscfg_size: 2; the dit splits its [cond, uncond] stack withmy_rows(cfg_group, CFG_BRANCHES), and the model declarescfg_enabled_nodes={"dit": 2}and no longersp_enabled_nodes. It dropsshard_over_sp=False: its dit isn't SP-enabled at this PR, so placement refusessp_size > 1on it and the flag was a no-op, and #949's Ulysses SP needs the default, which splits heads over SP. Wan's ownvalidate_config_yamlis deleted. A Wan2.2 config that splits guidance withsp_size: 2must becomecfg_size: 2: placement refuses the old shape and says so. Two smaller fixes:CommGroup.barrierraises on a group whosecpu_groupisn't set up (torch readsgroup=Noneas the whole world), asagreealready did; and a node handed two different groups on one axis of one rank is aValueErrorthat placement reports (it was aRuntimeError). That check also covers the instance group for multi-rank instances: two multi-rank instances of a node that share a rank differ there on some axis, size-1 groups included. It misses a node placed one-rank for one walk and multi-rank for another, since a worker graph with one-rank instances registers no groups; placement's resource-spec shape check still refuses that placement whenever the node belongs to a resource spec, so only a node with no resource spec gets through. That gap predates this PR.GPU rows: Eras H200s (4× at
b58ca20cc, 2× and 4× before the merge of #860), torch 2.12.1+cu132, Rust graph runtime (and control mesh atb58ca20cc, where both arms were built from one dependency set). Throughput rounds each on a fresh server.cfg_size: 2vs base'ssp_size: 2CFG split, eager ditb58ca20ccvs #860's5dcbd78e5, one 81-frame 704×1280 video; also before that mergeb58ca20ccvs5dcbd78e5TORCH_LOGS=output_code) is identical between the arms on both ranks, and the traced graphs differ only in their generated function names.b58ca20ccvs5dcbd78e5, three rounds eachJointGroups.agreeand the barriers run on one gloo group over ranks (0, 1). c=32 4797 / 4792 / 4830 vs 4727 / 4788 / 4763 tok/s; c=1 239 / 240 / 251 vs 243 / 246 / 242. Greedy: the PR matches the two base runs on 11/16 and 13/16 prompts, where base matches itself on 13/16. No phase moved by more than run-to-run variance in the profile pass. On TP2 the instance group is the TP gloo group, so the read-check vote is the same single collective as on base; its 2.10 → 2.49 ms at c=32 (0.70 → 0.37 ms at c=1) is a wait phase moving between runs.ruff; CPU Core vs main9f84f18cdOMP_NUM_THREADS=2, headdc0c20852ruffclean. CPU Core against main9f84f18cd: 9091 passed, 1066 skipped, 2 failed, the same 2 as main (thetest_eval_compare_multigpudump_stacks pair); none fail only on this branch. GitHub CI green atdc0c20852.cfg_size: 2node, and ashard_dimsignal on TP × SP and TP × CFG instances (test_validate_deployment); a barrier on a group with nocpu_groupraises (test_parallel_groups);head_shardswith a CFG axis, including Wan's shipped cfg-only shape (test_ragged_attention_cpu::test_cfg_ranks_keep_every_head, which fails 4 of 5 cases withhead_shardsas the instance size). The three refusal tests fail without their fixes.test_sequence_parallel, three-rank gloo case--basetemp, the module passed 4 of 4 runs on both this PR and #860. The cause is under investigation.Not yet run on GPU at this head: every GPU result above predates the merges of main (
8a2095f81, thenbb0e0a8d7,c96e02b7dand9f84f18cdthrough #860, and9c6f1ad75) and of #860's heads since5dcbd78e5, including the change to Wan's attention declaration. The one exception: Qwen3.5-9B, which crashed at load on the previous head because_unchunkable_nodesstill called the removedget_sp_config_for_node, boots, captures and serves on H200 with the fix (b3eaf9ddf). To confirm against main: Wan2.2cfg_size: 2, eager dit, one 81-frame 704×1280 video byte-identical to main'ssp_size: 2CFG split (this confirms that droppingshard_over_sp=Falsekeeps every head); Qwen3.5-9B TP2 startup, capture, c=32 and c=1 throughput over three fresh-server rounds with the phase-profile pass (expected neutral); Cosmos3-Nano tp2_sp2 and sp2 t2v / t2i within base's cold-cache spread. The placement checks, the error type and the barrier refusal change no GPU path, since production instance groups always have acpu_group(init_distsets it or raises); that is traced in code, not run. XPU: no hardware; the XPU instance-group path is covered only by a mocked test.Follow-ups:
get_submoduletakes one keyword argument per axis (sp_group,cfg_group), and changing that public signature across all models is a separate decision. Per-axis knowledge is still written out in a few places (theShardingConfig.<axis>_enabled_nodesfields, theJointGroups.<axis>_groupfields withcfg_groupdefaulted last, the engine manager's kwarg tuple, andhead_shards = tp × sp); oneJointGroups.axesmapping plus each axis's role (splits heads, splits tokens, splits batch rows) besidePARALLEL_AXESwould remove them, touching everyJointGroupscaller and #949's.tp_allreducesets up its backend on every axis group, CFG groups included, though a CFG group only all-gathers: free with the defaultnccl, one 512 KiB buffer plus a rendezvous per group otherwise; restricting it to TP needs an audit of whichcomm_group.all_reducecallers run over SP.my_rowslives incomponents/distributed/sequence_parallel.pybut is the CFG axis's row split too.AI assistance (Claude) was used for this PR.
🤖 Generated with Claude Code