Skip to content

cfg_size: classifier-free-guidance branches get a mesh axis of their own (4/5) - #948

Open
dghosef wants to merge 429 commits into
mainfrom
perf/cfg-axis-core
Open

dghosef wants to merge 429 commits into
mainfrom
perf/cfg-axis-core

Conversation

@dghosef

@dghosef dghosef commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Up to date with main at 9c6f1ad75 by 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: 2 instance whose ranks split guidance rows rather than the sequence. The engine sizes attention heads by EngineResourceInfo.head_shards, the node's whole instance size on the base, so Wan's dit declared RaggedAttentionConfig(shard_over_sp=False) to keep every head on those ranks. So sp_size meant 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 through JointGroups.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's sp_group / cfg_group), and which axes split heads is written out in head_shards. That step changes no behaviour. JointGroups.instance is a CommGroup over the instance's ranks, and every agreement is one gloo collective on it: a barrier, or a vote (JointGroups.agree is one CommGroup.agree on 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 raises ValueError. Placement refuses a lockstep instance whose ranks are listed out of order ([1, 0] with tp_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's cfg_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 as tp_size (the instance size), so the conductor's messages and the Rust shard templates are unchanged. A model declares ShardingConfig.cfg_enabled_nodes with the most branches each node runs, and placement refuses, before any worker starts, cfg_size on an undeclared node or one that doesn't divide that count; a paged KV cache on a cfg_size > 1 node, because the pool's rank math counts every instance rank as a head shard (branches that hold different KV are separate nodes in a graph Parallel on a cfg_size: 1 node group, as BAGEL's LLM_cfg_text and LLM_cfg_img are); and a shard_dim signal into or out of a node whose node group has sp_size or cfg_size above 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 a shard_dim). The engine manager hands the node cfg_group in get_submodule when it is non-trivial. head_shards goes from the instance size to TP ranks × SP ranks, so CFG ranks keep every head; _build_ragged still splits a shard_over_sp=False attention over TP only (LTX-2.5 SP2's audio and cross attentions). Wan2.2's wan22_cfg_parallel_2gpu.yaml sets cfg_size: 2; the dit splits its [cond, uncond] stack with my_rows(cfg_group, CFG_BRANCHES), and the model declares cfg_enabled_nodes={"dit": 2} and no longer sp_enabled_nodes. It drops shard_over_sp=False: its dit isn't SP-enabled at this PR, so placement refuses sp_size > 1 on it and the flag was a no-op, and #949's Ulysses SP needs the default, which splits heads over SP. Wan's own validate_config_yaml is deleted. A Wan2.2 config that splits guidance with sp_size: 2 must become cfg_size: 2: placement refuses the old shape and says so. Two smaller fixes: CommGroup.barrier raises on a group whose cpu_group isn't set up (torch reads group=None as the whole world), as agree already did; and a node handed two different groups on one axis of one rank is a ValueError that placement reports (it was a RuntimeError). 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 at b58ca20cc, where both arms were built from one dependency set). Throughput rounds each on a fresh server.

Check Setup Result
Wan2.2 cfg_size: 2 vs base's sp_size: 2 CFG split, eager dit b58ca20cc vs #860's 5dcbd78e5, one 81-frame 704×1280 video; also before that merge Base, base and PR give byte-identical videos, on both trees.
Same, compiled dit b58ca20cc vs 5dcbd78e5 The PR's video differs from base's by 26.8 dB, but the difference follows the Inductor cache, not the code. On a shared cache, base gave one video (twice) and the PR the other; with a fresh cache per arm the two swapped, byte for byte. Inductor's generated code (TORCH_LOGS=output_code) is identical between the arms on both ranks, and the traced graphs differ only in their generated function names.
Qwen3.5-9B TP2, lockstep agreement on the gloo instance group b58ca20cc vs 5dcbd78e5, three rounds each Startup and capture pass; a probe confirms JointGroups.agree and 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.
Same Before the merge of #860 Startup and capture pass. c=32 4841 / 4814 vs 4756 / 4836 tok/s; c=1 238 vs 241. Greedy 14/16 identical to base, where base against itself with a cold cache gives 15/16. No moved phase.
Multi-axis instance groups, Cosmos3-Nano tp2_sp2 and sp2 Before the merge of #860, with #705 (now on main) tp2_sp2 starts. Heads shard over TP × SP: output within base's own cold-cache spread (t2v 23.4 / 23.8 dB against 23.6 dB, t2i 42.1 / 42.4 dB against 45.1 dB).
ruff; CPU Core vs main 9f84f18cd Eras CPU box, OMP_NUM_THREADS=2, head dc0c20852 ruff clean. CPU Core against main 9f84f18cd: 9091 passed, 1066 skipped, 2 failed, the same 2 as main (the test_eval_compare_multigpu dump_stacks pair); none fail only on this branch. GitHub CI green at dc0c20852.
New CPU tests Linux CPU Placement refuses a paged KV cache on a cfg_size: 2 node, and a shard_dim signal on TP × SP and TP × CFG instances (test_validate_deployment); a barrier on a group with no cpu_group raises (test_parallel_groups); head_shards with 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 with head_shards as the instance size). The three refusal tests fail without their fixes.
test_sequence_parallel, three-rank gloo case Eras CPU box A known intermittent failure: it aborts in some runs, more often on a loaded box. With a private --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, then bb0e0a8d7, c96e02b7d and 9f84f18cd through #860, and 9c6f1ad75) and of #860's heads since 5dcbd78e5, including the change to Wan's attention declaration. The one exception: Qwen3.5-9B, which crashed at load on the previous head because _unchunkable_nodes still called the removed get_sp_config_for_node, boots, captures and serves on H200 with the fix (b3eaf9ddf). To confirm against main: Wan2.2 cfg_size: 2, eager dit, one 81-frame 704×1280 video byte-identical to main's sp_size: 2 CFG split (this confirms that dropping shard_over_sp=False keeps 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 a cpu_group (init_dist sets 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_submodule takes 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 (the ShardingConfig.<axis>_enabled_nodes fields, the JointGroups.<axis>_group fields with cfg_group defaulted last, the engine manager's kwarg tuple, and head_shards = tp × sp); one JointGroups.axes mapping plus each axis's role (splits heads, splits tokens, splits batch rows) beside PARALLEL_AXES would remove them, touching every JointGroups caller and #949's. tp_allreduce sets up its backend on every axis group, CFG groups included, though a CFG group only all-gathers: free with the default nccl, one 512 KiB buffer plus a rendezvous per group otherwise; restricting it to TP needs an audit of which comm_group.all_reduce callers run over SP. my_rows lives in components/distributed/sequence_parallel.py but is the CFG axis's row split too.

AI assistance (Claude) was used for this PR.

🤖 Generated with Claude Code

dghosef and others added 30 commits October 27, 2026 21:40
…nblocking) into #860

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
dghosef and others added 10 commits November 1, 2026 17:14
…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>
dghosef added a commit that referenced this pull request Nov 2, 2026
… 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>
dghosef added a commit that referenced this pull request Nov 2, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef added a commit that referenced this pull request Nov 2, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef and others added 2 commits November 1, 2026 21:26
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>
dghosef added a commit that referenced this pull request Nov 2, 2026
…rf/cfg-axis

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from perf/split-3-generation to main November 2, 2026 04:44
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef added a commit that referenced this pull request Nov 2, 2026
…rf/cfg-axis

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vasilevklart vasilevklart self-assigned this Nov 3, 2026

@vasilevklart vasilevklart left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chunkable models crash at load; merging main breaks tests (inline)

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)

@vasilevklart vasilevklart Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@vasilevklart vasilevklart Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@vasilevklart vasilevklart Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense - whichever lands second, I'll keep it TP x SP (TP alone when positions go over SP) in #1124.

dghosef and others added 5 commits November 4, 2026 05:36
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>
@dghosef
dghosef requested a review from vasilevklart November 4, 2026 07:43

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants