Repository navigation
graph runtime (Rust): drop a finishing loop's loop-back edges to other worker graphs - #931
Conversation
…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 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>
Automated review🟢 No findings on What the review checked
Advisory, not a merge gate. Rules: |
BAGEL's CFG-parallel walks need it: without it the Rust runtime sends a finishing loop's loop-back edges to peer graphs and the walk hangs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
stop_loops and _stop_loops_for_rid returned the stopped loops' loop-back (name, dest) pairs for a caller to drop from routing, but neither caller read them: the loop-backs are dropped when the loop next completes an iteration, through Loop.complete_iter's filtered_signals. The Rust runtime dropped the same unread return in this PR, so the two now describe one mechanism. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ingest_for_speculation looked the dest node's name up through the graph; finish_loop reads the edge's interned dest name. Both give the same pair for a local edge, so use dest_sym in both places. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… idle (R1.4) The loop is now entered at step with an external cond, as BAGEL's CFG branches are, so the sibling cases run step for all three iterations; the test then checks nothing is ready and the partition is idle, so a worker graph left mid-loop fails it. It still fails on main's Rust in both Rust cases (the last combine sends its loop-back). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…R2.1) BAGEL's CFG branches have no loop-external input; with one, main's stray loop-back is stranded in step's slot instead of re-running step at counter 0, which is the failure the test's docstring names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
….2, R2.3) register_loop_finish_signal already skips loops this worker graph doesn't carry, and test_loop_done_drops_loop_back_and_keeps_terminal_outputs plus the apply_peer_loop_stops tests already pin the finish signal. 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>
Live test: #931 at
|
image_gen_cfg forwards per request |
LLM |
LLM_cfg_text |
LLM_cfg_img |
combine_cfg |
vae_decoder |
|---|---|---|---|---|---|
| main, Rust | 50 ×7 | 50 ×7 | 50 ×5, 49 ×2 | 49 | 1 |
| main, Python | 49 | 49 | 49 | 49 | 1 |
| PR, Rust | 49 | 49 | 49 | 49 | 1 |
| PR, Python | 49 | 49 | 49 | 49 | 1 |
- The c=1 images are bit-identical across all 4 arms (3/3).
- At c=4, 2 of 4 images differ between any two arms, including main-Python vs PR-Rust, neither of which has the extra pass. The differences are small, PSNR 36–39 dB, consistent with batch composition. I read them as run-to-run noise, not the PR.
- The PR's new parity test, run against each arm's build: main's fails
[rust-peer]and[rust-sibling], and passes both[python-*]. The PR's passes all 4.
Other loop layouts (both runtimes; main vs PR on Rust)
| Model, layout | What was checked | Result |
|---|---|---|
| Qwen3.5-0.8B, 1 GPU (AR decode) | LLM forwards per request vs tokens returned; greedy text | ✅ forwards = tokens on all 96 requests in all 4 arms; c=1 text 32/32 identical; c=16 41–52/64, same band as a main-Rust vs main-Python control (41/64) |
Qwen3.5-9B, TP2 (qwen3_5_9b_tp2.yaml) |
same, on both ranks | ✅ forwards = tokens on both ranks, all 96 requests; c=1 text 32/32; no hang |
BAGEL PD-disaggregated (bagel_pd_disaggregated.yaml) |
t2i forward counts and images | ✅ image_gen 49 + vae_decoder 1 per request in every arm; c=1 images bit-identical |
Orpheus PD-disaggregated (orpheus_pd_disaggregated.yaml, LLM → SNAC streaming) |
benchmark.runner TTS, 24 requests at c=4 |
✅ 24/24 completed, 0 failed, in every arm |
Not covered
- The 3-GPU layout of
bagel_cfg_parallel.yamlas shipped (this allocation had 2 GPUs). PR Performance 3/7: image, video and world models #860's walk split, where the extra pass hangsimage_gen. - The TCP and RDMA transports. Throughput: the change only touches loop finish, so I didn't benchmark it.
- The follow-up named in the description (several loop nodes in one worker graph): no shipped config has that layout.
Brings in #931 (graph runtime: a finishing loop drops its loop-back edges to other worker graphs too, in Python and Rust). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…split-2-speech-language Brings #858's deletion of NodeSubmodule.splits_heads in favour of RaggedAttentionConfig.shard_over_sp, and origin/main's #810 (per-node dtype and autocast), #961 and #931 (Rust loop-back fix). The compile-region warm-up now reads the node's resolved dtype by name and names the node in its autocast scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…8b0859) into perf/split-3-generation Conflicts: - components/diffusion/attention.py: keep this branch's RaggedAttentionMixin beside the base's RaggedAttentionFn protocol and return_lse sdpa_attention; resource.run takes return_lse, so the mixin's bound callables satisfy it. - components/wan_vae.py: keep decode_dtype and FastDecoder, drop the cudnn_benchmark helper the base moved to mstar.engine.fp32_precision. - rust/src/graph/state.rs, test/rust/test_runtime_parity.py: main's squashed #931; this branch's copies came only from #931's branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Python/Rust graph-runtime drift at loop exit: the Rust runtime sends a finishing loop's loop-back edges to other worker graphs. A loop split across worker graphs (BAGEL's CFG-parallel denoise:
combine_cfgon rank 0,LLM_cfg_text/LLM_cfg_imgon their own ranks, andLLMin a sibling worker graph on rank 0) loops back through those graphs. On the last iteration, the Python runtime drops every loop-back(name, next_node)pair, wherever the dest is. The Rust runtime dropped only the pairs whose dest was in its own worker graph. So after the final step it still sentlatents/time_indextoLLM,LLM_cfg_textandLLM_cfg_img. Each of them ran one more forward with its loop counter back at 0. Their velocities then parked atcombine_cfg, which never ran again.On main this costs one wasted denoise step per request on every CFG GPU, about 1/49 of the denoise. The image is not affected. A node that reads its loop counter breaks instead: on PR #860's BAGEL walk split, the extra pass hangs
image_genuntil the request times out.How it works
compile.rs:LoopSpec.loop_backkeeps everyLoop._loop_back_inputspair as(signal, dest node name), including pairs whose dest is in another worker graph. Before, it held only the local(signal, NodeId)pairs. Speculation reads the same list, and it is still local-only, as in Python.state.rs,finish_loop: edges are dropped by(name, dest_sym)against that list. This is the check Python makes, soDest::LocalandDest::Externaledges are handled alike. It runs insidecomplete(), before the sibling-graph local resolve and before the TP fanout, so every rank drops the same edges.Completed.filteredand the return value ofregister_loop_finish. Nothing read either one, and both returned only the local pairs.How it was tested
test/rust/test_runtime_parity.py::test_the_last_iteration_sends_no_loop_back_to_another_worker_graph, parametrized over both runtimes and over wherestepruns: on a peer worker (the wire path) or in a sibling worker graph on this worker. It builds the loop with the real_divide_into_worker_graphs, runs it for all 3 iterations, and checks that the last iteration routes no loop-back tostep(not staged for the peer's read, not ingested into the sibling graph) and holds no reference for it.[rust-peer]and[rust-sibling]fail ([True, True, True] != [True, True, False]), and both[python-*]cases pass.image_gen_cfgworker graphs on main's 3-GPUconfigs/bagel_cfg_parallel.yaml, with three runtimes delivering every peer send over the real ZMQ transport for Rust. Per-node loop counters:LLM,LLM_cfg_text,LLM_cfg_imgandcombine_cfgeach run k = 0..48, andvae_decoderruns once.LLM,LLM_cfg_textandLLM_cfg_imgeach run k = 0..48 and then 0.mstar_rustbuilt from the branch:pytest test/rust/on 532c6ed: 539 passed, 5 skipped.cd rust && cargo test --release --no-default-features --features pythonon 532c6ed: 77 passed.test_eval_compare_multigpu.py::test_dump_stacks_retries_with_sudo_only_when_asked, also fail on main (fadeb27). No failure is new. The head, 608b272, only adds the merge of main fadeb27, and CI's CPU Core job passes on it.ruff check .: clean.MSTAR_RUST_GRAPH=0), over the SHM transport:configs/bagel_cfg_parallel.yamlon 3 GPUs;configs/qwen3omni_2gpu.yamlandconfigs/orpheus.yamlon 2. Qwen3-Omni ran in its own venv with torch 2.10.0+cu130, torchaudio 2.10.0 and the flash-attn 2.8.3 wheel fromdocs/installation.rst, because the default venv's torch 2.12.1 and torchaudio 2.11.1 fail to loadlibtorchaudio.LLM,LLM_cfg_textandLLM_cfg_img50 times per request (k = 0..48 and then 0). Main's Python runtime, and this PR on both runtimes, run each of them 49 times.combine_cfgruns 49 times andvae_decoderonce in every arm. The images are bit-identical across all 4 arms. No request failed, and the server was idle after every drain.[rust-peer]and[rust-sibling]and passes both[python-*]cases. This PR passes all four (withtest/modular/test_graph_runtime.py: 213 passed, 1 skipped).image_genuntil the 600 s timeout on the Rust runtime, on both the 2- and 3-GPU CFG-parallel layouts. With the fix cherry-picked, t2i, edit, emptywin and think all pass on both layouts. Forward counts and KV publishes then equal the Python runtime's.Follow-up, not in this PR: the finished-iteration filter covers only the outputs of the node that completes a worker graph's last iteration. If one worker graph owns two or more nodes of a split loop, an earlier node can still send a loop-back to a peer on the final iteration. Both runtimes have this gap (
rust/src/graph/state.rs:563,mstar/model/base.py:773), so parity holds. BAGEL is unaffected, becausecombine_cfgis its graph's only loop node.🤖 Generated with Claude Code