Repository navigation
perf: rotate Model Runner V2 output copy streams - #263
Conversation
yeahdongcn
left a comment
There was a problem hiding this comment.
Merge verdict: 1 blocking finding — not ready to merge.
Round status
- Earlier rounds:
gh pr view 263 --json reviewsand--json commentsboth return empty arrays, so this is the first review — there is no earlier P0/P1 to re-check. - Checks:
gh pr checks 263→no checks reported on the 'feat/copy_stream' branch. Per round policy that does not block, but note there is also no CI signal for this patch. - Head reviewed:
181297374ed253d587b0369f1e321e34c39f2184— identical to the dispatch snapshot, so nothing was reviewed out of date. - The change is a two-file patch-series PR:
vllm_musa/patches/series/0172-MUSA-rotate-V2-runner-output-copy-streams.patch(new, 48 lines) and theREADME.mdpatch-count/summary update. Line references below are into the patch file at the head commit; where I cite the patched file I give upstream line numbers from the pinVLLM_COMMIT=2cf0a6915ce544dc493a0990f2ea38d81601128a(v0.28.0, fromthird_party/PINS) and say so.
Verified mechanically: the series is now 172 .patch files + README.md (173 dir entries), matching the README's Currently **172 patches**; no quilt series manifest exists, so build_apply.py::series_files falls back to the sorted *.patch glob and 0172 is applied last; git apply --recount -p 1 (the build applier) ignores hunk line counts, and all three hunk contexts match the pinned source with a consistent offset (+192 / +1818 / +1894 in patch headers vs the pinned output_copy_stream block at 194, the AsyncOutput construction at 1782–1790, and return async_output + def take_draft_token_ids at 1861–1863).
P1 — the rotation is hardcoded to two streams, but a V2 runner can have pp_size + 1 steps in flight
vllm_musa/patches/series/0172-MUSA-rotate-V2-runner-output-copy-streams.patch:18-24
+ # MUSA: a copy-stream wait queued for step N+1 delays step N's copy event
+ # until step N+1 finishes, so each in-flight step gets its own stream.
+ self._output_copy_streams = [
+ self.output_copy_stream,
+ torch.cuda.Stream(self.device),
+ ]
+ self._output_copy_stream_idx = 0Trigger. The number of concurrently live AsyncOutput objects is bounded by vllm_config.max_concurrent_batches:
vllm/v1/engine/core.py:209—self.batch_queue_size = vllm_config.max_concurrent_batches, andstep_with_batch_queue(pinnedcore.py:624+) keeps that many unconsumed futures, each created eagerly in the worker (theAsyncOutput.__init__that queues the copy and records the event runs atsample_tokenscall time;get_output()runs only when the queue is popped).vllm/config/vllm.py:549-560— for the V2 model runner with async scheduling this ispp_size + 1.
So with the V2 runner + pipeline_parallel_size >= 2 (async scheduling is on by default for non-pooling models), batch_queue_size >= 3. The round-robin then hands step 1 → streams[1], step 2 → streams[0], step 3 → streams[1], while step 1's AsyncOutput is still unconsumed. Step 3's wait_stream(main) is queued on a stream that still holds step 1's copy_event, which is exactly the condition the patch's own comment and the PR body ("one per in-flight step") say delays step 1's event — so on the PP path the workaround silently loses its effect and tokens are again published behind a later step.
Suggested change. Size the list from the value the same __init__ already reads ~11 lines later for its UVA pools (pinned vllm/v1/worker/gpu/model_runner.py:203, set_default_max_concurrency(vllm_config.max_concurrent_batches)):
self._output_copy_streams = [self.output_copy_stream]
self._output_copy_streams.extend(
torch.cuda.Stream(self.device)
for _ in range(max(1, vllm_config.max_concurrent_batches - 1))
)This is not a correctness regression — at pp_size == 1, max_concurrent_batches is 2 and the current code is exactly right — but the patch's stated invariant does not hold for PP, and the correct bound is already in scope.
P2 — the pooling output path keeps the single shared stream
.../0172-MUSA-rotate-V2-runner-output-copy-streams.patch:32-33 changes only the generation call site:
- copy_stream=self.output_copy_stream,
+ copy_stream=self._next_output_copy_stream(),The other consumer of that attribute is untouched: pinned vllm/v1/worker/gpu/model_runner.py:1899-1905 still passes copy_stream=self.output_copy_stream, (line 1904) to AsyncPoolingOutput, whose __init__ repeats the identical pattern (vllm/v1/worker/gpu/async_utils.py:224,241: copy_stream.wait_stream(main_stream) … self.copy_event.record(copy_stream)). Trigger: a pooling engine with pp_size >= 2 (async scheduling is disabled by default for pooling, so max_concurrent_batches = pp_size >= 2), where the same MUSA event behavior applies and the single stream is still reused across in-flight steps. Routing that site through the same helper would close the gap; no test or model-smoke in the PR covers pooling either way.
P2 — the new patch file carries a git format-patch header but no index blob lines
.../0172-MUSA-rotate-V2-runner-output-copy-streams.patch:1-13 has the full header (From 0000000000000000000000000000000000000000 Mon Sep 17 …, From:, Date:, Subject:) and a normal diff --git/--- a//+++ b/ body, but grep -c '^index ' on it returns 0. README.md states the series is generated with git format-patch --no-signature --no-numbered --zero-commit, "keeping index blob lines so git am -3 3-way works across version bumps", and Makefile.sync:14 replays the series with git am -3. Without the index line that patch gets no 3-way fallback at the next VLLM_COMMIT bump and will need manual resolution. The build itself is unaffected (build_apply.py uses git apply --recount -p 1). In a 20-file sample of the series, 0172 is the only file that has a format-patch header and no index line (0145 also lacks one but is a bare --- a/… fragment), so this may just need make -f Makefile.sync format-patches to regenerate the series from its commit rather than a hand-edited file.
P0: no P0 finding.
What I could not verify (assumptions stated)
- The MUSA root cause. I could not confirm on hardware that "once step N+1's
wait_streamis queued, step N'scopy_eventdoes not complete until step N+1 finishes".AsyncOutput.__init__only enqueuescopy_stream.wait_stream(main_stream)after recording nothing — the record happens last (async_utils.py:138,165) — so on standard stream semantics the already-recorded event should complete independently of later work on that stream; the reported behavior therefore looks like a MUSA/torch_musa stream-wait or event-completion semantic rather than a vLLM bug. I reviewed the patch as reported, not as reproduced, and the patch does do what its description says. A reference to the runtime issue in the PR description (not in shipped comments, per the workspace comment rule) would help a future reader decide whether the workaround can be dropped. - The performance claim. The 262→187.56 ms TTFT / 40.9→31.05 ms TPOT table and the trace deltas are reported evidence only; this session has no MUSA hardware and I ran no benchmark. The exact launch configuration behind the table is not stated — in particular whether
--async-schedulingwas on, which is what makesmax_concurrent_batches = 2here. Stating the flags would let a reader reproduce it. - CI. Quoted in full above:
no checks reported on the 'feat/copy_stream' branch— so no build/apply check of the series and no model smoke was read for this PR.
auto-review round 1/3 · verdict: blockers
yeahdongcn
left a comment
There was a problem hiding this comment.
Merge verdict: no blocking findings — ready to merge.
No P0 or P1 finding remains at this head, so this ends auto-review for this pull request (round 2 of 3; nothing further will be reviewed here, including later pushes).
Round status
- Head reviewed:
fcf25c3bdb10dfc0c40ee70aee63d076d70937b8("perf: size V2 output copy streams by max_concurrent_batches"), parent181297374ed2— i.e. the branch moved after round 1, and this is the fix push, not the commit round 1 saw. - Earlier rounds: one review exists (
yeahdongcn, round 1, at head181297374ed253d587b0369f1e321e34c39f2184): one P1 and two P2. All three are addressed at this head, verified in the diff, not taken on trust:- P1, rotation hardcoded to two streams while a V2 runner can have
pp_size + 1in flight → the pool is now sized bymax_concurrent_batches(vllm_musa/patches/series/0172-MUSA-rotate-V2-runner-output-copy-streams.patch:27-32). - P2, pooling output still on the single shared stream → the
AsyncPoolingOutputsite now callsself._next_output_copy_stream()(same patch, line 62). - P2, new patch carried a format-patch header but no
indexblob line → the regenerated file now hasindex 79953a7..02ae53b 100644(line 17).
- P1, rotation hardcoded to two streams while a V2 runner can have
- Checks:
gh pr checks 263→no checks reported on the 'feat/copy_stream' branch(exit 1, stderr as quoted). Per round policy that does not block, but there is still no CI signal for this patch. - This round: no P0, no P1; two P2 nits below.
P2 — the README still says "two" copy streams, but the head sizes the ring by max_concurrent_batches
vllm_musa/patches/series/README.md:49-51
per-layer launch count off the eager prefill path. Model Runner V2 rotates two
output copy streams so a step's sampled tokens are not held back by the next
step's copy-stream wait. The fused TileLang `hc_head` is"two" was accurate for the round-1 revision. At this head the count is max(1, vllm_config.max_concurrent_batches - 1) + 1, which is 2 only when pipeline_parallel_size == 1 (pinned vllm/config/vllm.py:550-560 returns pp_size + 1 for the V2 runner with async scheduling), so on a PP deployment the sentence understates the change; it is also inconsistent with the patch's own commit message, which correctly says "sized by max_concurrent_batches". Suggest "rotates one output copy stream per in-flight step".
P2 — the rotation starts at index 1, so self.output_copy_stream is skipped for the first step
.../0172-MUSA-rotate-V2-runner-output-copy-streams.patch:49-52
+ def _next_output_copy_stream(self) -> torch.cuda.Stream:
+ streams = self._output_copy_streams
+ self._output_copy_stream_idx = (self._output_copy_stream_idx + 1) % len(streams)
+ return streams[self._output_copy_stream_idx]The counter is incremented before it is used, so the first output copy of a process runs on _output_copy_streams[1] and the pre-existing output_copy_stream at index 0 is not touched until the second step. This is harmless — the cyclic alignment is constant, so consecutive steps still get consecutive streams and the window argument below is unaffected — but it does not match the natural reading of "streams[0] first". Suggested form: stream = streams[self._output_copy_stream_idx % len(streams)], then increment, or start the counter at -1.
What I verified (so the above is not an assumption)
- The stream count is the right bound, not just a bigger number. At the moment step j builds its
AsyncOutput, an older object is unconsumed only if its step is still in flight, and the engine allows at mostmax_concurrent_batchesof those:vllm/v1/engine/core.py:209setsbatch_queue_size = vllm_config.max_concurrent_batches,core.py:647assertslen(batch_queue) < self.batch_queue_sizebefore submitting a new step, and consumption isAsyncModelRunnerOutput.get_output()(the worker'senqueue_output,vllm/v1/executor/multiproc_executor.py:983-986). So at mostN-1older outputs are live, the only older step sharing the new step's stream is j−N, and that one is already consumed. That closes the round-1 P1 concern on the merits forpp >= 2as well aspp == 1. - Both call sites are rotated, and the pooling path keeps a correct ring size.
AsyncPoolingOutputuses the identicalcopy_stream.wait_stream(main_stream)→ copies →copy_event.record(copy_stream)pattern (vllm/v1/worker/gpu/async_utils.py:223-241). Async scheduling is off by default for pooling, somax_concurrent_batches == pp_sizethere and the ring degenerates to the previous single stream atpp == 1— no behaviour change on the common case. - Nothing else in the tree consumes
runner.output_copy_stream. A full-tree search at this head returns only this patch plus pre-existing V1-runner references (0091-...patch:178comment context, anddraft_token_ids_copy_streamin0150-...patch:43), so no other code is left holding the un-rotated stream. - The patch anchors to the pinned base. Each hunk's pre-image appears verbatim in upstream
vllm/v1/worker/gpu/model_runner.pyatVLLM_COMMIT=2cf0a6915ce544dc493a0990f2ea38d81601128a(third_party/PINS) at one consistent offset: +0 for the__init__hunk (upstream 192–197,output_copy_streamat 194) and +34 for the three later hunks (upstream 1784→1818, 1860→1894, 1901→1935). The +34 matches the earlier series patches that touch this file (0091 at 416, 0095 at 1345, 0097 at 1236–1422, 0119 at 1241), so the series should still apply. - Series bookkeeping needs no extra edit. 172
*.patchfiles, unique sorted 4-digit prefixes, the new file starts with the zero-commitFromheader andFrom: musa <musa@local>, andREADME.md:20says 172 — the three repo tests that police this (tests/test_musa_sync.py::test_series_readme_count_matches_directory,tests/test_manifest_completeness.py::test_series_readme_count_matches_disk,test_series_uses_documented_prefixes_and_canonical_metadata) all pass by construction. In particular no manifest row is needed:manifest.py::_series_entries()derives one entry perseries/*.patchfrom its firstdiff --gittarget, so 0172 is auto-classified as category 1 / pre-install andtest_series_bijection_with_manifeststill holds. I checked this instead of assuming it. - Comment style. The new block is a 3-line
# MUSA:rationale with no ticket, patch-number, or history narration, and both the patch subject and the comment describe the current behaviour only.
What I could not verify (stated as assumptions)
- The MUSA root cause. I have no MUSA hardware and ran nothing.
AsyncOutput.__init__recordscopy_eventlast, after the copies (async_utils.py:137-165), so under ordinary stream semantics an already-recorded event should complete independently of later work queued on the same stream; the reported behaviour therefore looks like a MUSA/torch_musa event-completion or stream-wait semantic difference, not a vLLM bug. I reviewed the patch as a workaround that does what its description says, and I cannot confirm the underlying runtime behaviour. A reference to the runtime issue in the PR description (not in the shipped comment, per the comment rule) would let a future reader judge when the workaround can be dropped. - The performance claim.
262 → 187.56 msTTFT and40.9 → 31.05 msTPOT, and the trace deltas, are author-reported; I ran no benchmark and no trace was attached. The launch configuration behind the table is not stated — in particular whether--async-schedulingwas on (that is what makesmax_concurrent_batches == 2and therefore two streams on the measuredpp=1config), plus hardware, driver, and batch shape. Stating them would make the gain reproducible. - CI. Quoted above in full:
no checks reported on the 'feat/copy_stream' branch— no build/apply check of the series and no model smoke was read for this PR. - One unrelated observation, not caused by this PR:
tests/test_musa_sync.py::test_reporthardcodes its census ("174 divergences",'1': 112), which matches neither the pre-PR tree (209 total, category 1 = 147) nor this head (210 total, category 1 = 148). That staleness predates the PR; the only census delta this PR introduces is the one new category-1 patch, which is what the derived manifest expects.
auto-review round 2/3 · verdict: clean
…gate) At `v0.28.0-dev` the documented regeneration path was dead. `musa_sync.py rebase` (== `make -f Makefile.sync apply-patches`) stops at `0014-...` with `corrupt patch at line 78`, and after repairing that entry it stops at `0140` with `Patch format detection failed`. The build and `verify` stayed green because `build_apply.py:51-60` applies entries with `git apply --recount -p1`, which infers the hunk counts from the patch body and accepts bare diffs, so only the `git am` path noticed. * three entries carried hunks whose header disagrees with their body: `0014`'s `@@ -6,58 @@` hunk carries 60 lines (and the later offsets are off by two as a result), plus one hunk each in `0133` and `0138`. `0014` is restored from the commit it was generated from; every hunk in the series now matches its header. * twelve entries were bare diffs rather than mailboxes (`0140`-`0150`, `0166`), which `git am` refuses. Every entry is a mailbox again, renumbered contiguously. The rewritten series is a fixed point: at the pre-rebase 171-entry head it applied to the pinned upstream (`2cf0a6915ce544dc493a0990f2ea38d81601128a`) to `f09964462be438ffdb982ebf018b1692ac55448c` (171 entries, 138 changed files), the identical tree the pre-rewrite series produced, and `git am -3` reproduced it. After the rebase onto `8acf64368b` the same fixed point holds for the 172-entry series: the base's series and this one, each applied to a pristine pin, give the identical sha1 tree `abc996438a3ba0291dbf19429e60a3924f5f6740` - verified with the build applier, with plain `git apply --recount -p1` + `git write-tree`, and with a `git am` replay of all 172 entries that lands on the same tree, the two applied working directories being identical file for file (ignored files included). The change is therefore form only; no patched line of upstream vLLM moves. `tools/musa_sync.py` gains `check-series` and a hardened `verify`/`--replay`/`--round-trip`: mailbox format, contiguous numbering, the manifest census, every gitlink shape, and a verdict line on every path that has one. `tools/patch_validate.py`, `Makefile.sync`, the developer guide and the tests follow. Measured on this tree: `check-series` PASS (172 clean / 172 total), `--replay` and `--round-trip` PASS in the states they document, `verify` `174 clean / 210 total / 1 need attention` - that single row is the base's own pre-existing `module-drift` tripwire for `v1.attention.backends.mla.common`, and `regen --area module` clears it (175 clean / 210 total) - and `pytest tests/test_musa_sync.py` -> 104 passed (122 across the gate's three test files). A from-scratch image built from this tree is byte-identical to it on all 56 changed paths, and the 8-leg model smoke matrix on that image repeats `pass=5 fail=2 skip=1`, leg for leg as before; both are recorded in the PR description. Rebased onto 8acf643 (#240/#260/#263) and re-derived: the series is 172 entries (the base's 0172 is re-emitted by `regen`, so it is no longer inherited verbatim). `tests/test_musa_sync.py` no longer pins the entry count - it derives it from the series directory - because a fixed count reddens the gate on every base-side patch addition, which is exactly the coupling this PR removes. Re-measured on the 172-entry series, the declared-blob census the gate's docstrings cite is 232 postimages / 232 preimage pairs (230 at 171 entries): 0 postimages and 133 preimages resolve in a pin-only checkout, and 232/232 postimages are the declared path's blob in the entry's own commit - so the docstrings now state the measured numbers.
…gate) (#250) At `v0.28.0-dev` the documented regeneration path was dead. `musa_sync.py rebase` (== `make -f Makefile.sync apply-patches`) stops at `0014-...` with `corrupt patch at line 78`, and after repairing that entry it stops at `0140` with `Patch format detection failed`. The build and `verify` stayed green because `build_apply.py:51-60` applies entries with `git apply --recount -p1`, which infers the hunk counts from the patch body and accepts bare diffs, so only the `git am` path noticed. * three entries carried hunks whose header disagrees with their body: `0014`'s `@@ -6,58 @@` hunk carries 60 lines (and the later offsets are off by two as a result), plus one hunk each in `0133` and `0138`. `0014` is restored from the commit it was generated from; every hunk in the series now matches its header. * twelve entries were bare diffs rather than mailboxes (`0140`-`0150`, `0166`), which `git am` refuses. Every entry is a mailbox again, renumbered contiguously. The rewritten series is a fixed point: at the pre-rebase 171-entry head it applied to the pinned upstream (`2cf0a6915ce544dc493a0990f2ea38d81601128a`) to `f09964462be438ffdb982ebf018b1692ac55448c` (171 entries, 138 changed files), the identical tree the pre-rewrite series produced, and `git am -3` reproduced it. After the rebase onto `8acf64368b` the same fixed point holds for the 172-entry series: the base's series and this one, each applied to a pristine pin, give the identical sha1 tree `abc996438a3ba0291dbf19429e60a3924f5f6740` - verified with the build applier, with plain `git apply --recount -p1` + `git write-tree`, and with a `git am` replay of all 172 entries that lands on the same tree, the two applied working directories being identical file for file (ignored files included). The change is therefore form only; no patched line of upstream vLLM moves. `tools/musa_sync.py` gains `check-series` and a hardened `verify`/`--replay`/`--round-trip`: mailbox format, contiguous numbering, the manifest census, every gitlink shape, and a verdict line on every path that has one. `tools/patch_validate.py`, `Makefile.sync`, the developer guide and the tests follow. Measured on this tree: `check-series` PASS (172 clean / 172 total), `--replay` and `--round-trip` PASS in the states they document, `verify` `174 clean / 210 total / 1 need attention` - that single row is the base's own pre-existing `module-drift` tripwire for `v1.attention.backends.mla.common`, and `regen --area module` clears it (175 clean / 210 total) - and `pytest tests/test_musa_sync.py` -> 104 passed (122 across the gate's three test files). A from-scratch image built from this tree is byte-identical to it on all 56 changed paths, and the 8-leg model smoke matrix on that image repeats `pass=5 fail=2 skip=1`, leg for leg as before; both are recorded in the PR description. Rebased onto 8acf643 (#240/#260/#263) and re-derived: the series is 172 entries (the base's 0172 is re-emitted by `regen`, so it is no longer inherited verbatim). `tests/test_musa_sync.py` no longer pins the entry count - it derives it from the series directory - because a fixed count reddens the gate on every base-side patch addition, which is exactly the coupling this PR removes. Re-measured on the 172-entry series, the declared-blob census the gate's docstrings cite is 232 postimages / 232 preimage pairs (230 at 171 entries): 0 postimages and 133 preimages resolve in a pin-only checkout, and 232/232 postimages are the declared path's blob in the entry's own commit - so the docstrings now state the measured numbers. Co-authored-by: Xiaodong Ye <yexiaodong60@mthreads.com>
Summary
Model Runner V2 copies each step's sampled tokens to the host on a single
output_copy_stream(wait_stream(main)→ D2H →copy_event.record). On MUSA,once the next step's
wait_streamis queued on that stream, the previous step'scopy_eventdoes not complete until the next step finishes on the GPU (alsoobserved with
query()polling, so it is event completion, not wake-up latency).Effects:
get_outputalternates between returning instantly and waiting two steps, soon every other step the host only launches step N+2 after N+1 drains →
~2 ms GPU idle per such step (GPU busy time per step is unchanged).
This patch rotates two output copy streams, one per in-flight step, so a step's
event never has the next step's wait queued behind it.
Evidence (Qwen3-VL-8B-Instruct, TP1)
Decode trace (32 streams): output-event wait 55.4 → 26.3 ms median,
GPU idle 5.24 → 3.39 ms/step, P99 ITL 64.7 → 33.5 ms.
Greedy outputs identical before/after.