Repository navigation
[CosyVoice3][Perf] Replay the flow's Euler solve as piecewise CUDA graphs per frame bucket - #657
Conversation
|
Scripts behind the Correctness and Performance numbers (not committed; run from the repo root with Scripts
|
Automated reviewThe PR splits CosyVoice3's flow solve into an eager Findings: none. I traced the replay path through
Scope: as the description asks, I reviewed only this PR's commit: Description:
Advisory, not a merge gate. Rules: |
Added to the Performance toggle: torch 2.12.1+cu130 (CUDA 13.0, cuDNN 9.2), driver 580.173.02, the same build for every number in the PR.
Reran it on the head: every value is unchanged, and the table is now labeled with the head. |
0a6e6e5 to
d700882
Compare
Live test: [CosyVoice3][Perf] Replay the flow's Euler solve as piecewise CUDA graphs per frame bucketWorks: throughput +50% at c1 and +75% at c8, TTFA p50 down 15–35%, WER unchanged; absolute numbers are from a heavily host-loaded node and not comparable to the PR's. 1x H200, Claims
Performance
CommandsOther
|
merceod
left a comment
There was a problem hiding this comment.
Reviewed c35a2d6 and 6ee4473 against #495 on a klone H200 (clock-limited node); 0a6e6e5 is the same patch rebased. Bucket-edge check with real weights: replay equals eager on the same padded inputs 120/120, the eager fallback triggers past 4,200 frames and for uncaptured batch sizes, the eager path is bit-identical to #495 48/48. Served on the current heads, 30 of 32 clips are byte-identical to #495 and the WER is identical; on the older heads short clips differed in their last chunk only (log-mel SNR 26 dB or better), which the description's "23/27 identical" undersells for short sentences.
Timing, 3 interleaved launches per arm, medians with ranges that never overlap: c=1 first audio p50 489 to 234 ms and RTF 0.30 to 0.14, c=8 first audio 3,428 to 1,357 ms, RTF 1.49 to 0.55, audio per second 5.2 to 14.0. With this PR a c=8 stream keeps up with playback on this node, without it it does not. The costs: startup 66 to 84 s against 169 to 205 s, and 11.3 GB at ready against 6.4 GB (97 graphs, 4.9 GiB, the description says 4.9).
One note on the premise rather than the code: dc248c1 (now in #495) attributes about 155 ms per call to cuDNN's float32 plan on the look-ahead convolutions and turns cuDNN off there, while this PR's description attributes the eager solve cost to kernel launches. The A/B above ran on the heads with that change, so the gain stands either way, but the description's explanation is dated.
Not run: a served request past the largest bucket with ignore_eos (the offline check covers that path), a float32 flow.
2d21bfe to
dc1002c
Compare
|
Thanks for the review and the live test.
Updated the description; no code change. The look-ahead convolutions run in the eager
The Correctness toggle now gives 30 of 32 byte-identical on the current heads, and that the older heads' differences were in short clips' last chunk only.
|
dc1002c to
f96c09c
Compare
Served check on one H200 that the renamed flow override takes effect: this head merged onto current main (clean merge),
|
7e99011 to
d5f118f
Compare
…er frame bucket and batch size
…st replays a padded batch
… captures reuse its pool blocks
…ccelerator graph names; the CPU capture stand-in follows the runner's capture with static outputs
…s, and an empty list captures none flow_capture_frame_buckets, flow_capture_batch_sizes and flow_capture_batch_frames were config fields only, so a deployment could not skip the capture's startup time and GPU memory. They now come from model_kwargs; flow_capture_frame_buckets: [] (or a frame budget of 0) captures no region and every solve runs eagerly. The default is unchanged.
… plan (None, every bucket) main's runners take the planned buckets as a required argument now; the GPU-only test still called it with none, so it raised a TypeError on a GPU while CPU CI skipped it.
d5f118f to
ee60508
Compare
|
@vasilevklart rebased on main now that #495 is in, so the diff is just this PR's commits (plus one test stub that takes #810's |
vasilevklart
left a comment
There was a problem hiding this comment.
This doubles main on 1x H200 (c1 8.2 → 14.9 audio-s/s, first audio 313 → 164 ms, same WER), but vllm-omni 0.32.0 on the same GPU and texts is 2.2-2.9x ahead at c8/c32 (68 / 102 audio-s/s vs 30 / 36) with lower WER (1.87% vs 2.77%) so the gap opens with concurrency (inline)
| del graph_walk, engine_inputs, kwargs | ||
| return dict(zip(rid, self._run(rid, speech_tokens, voice, final, n_timesteps), strict=True)) | ||
| del graph_walk, kwargs | ||
| out = self._run(rid, speech_tokens, voice, final, n_timesteps, self._runners(engine_inputs)) |
There was a problem hiding this comment.
Rows with the same step count solve together here, but the flow batch is capped at flow_max_batch_size = 8 and each row's tokens still sync to the host in prepare_inputs (:649)
@vasilevklart thanks for running it against vllm-omni. Those two look like the main candidates for the c8/c32 gap, and both are on main as they are here: this PR captures batch sizes 1 to 8 under that cap (fewer for the larger frame buckets, within 8,192 batch-frames) and leaves I'd keep this PR to the capture and work on the gap in a follow-up:
measured on one H200 at c8 and c32 against vllm-omni 0.32.0 on your texts. OK to merge this one as is, or would you rather have 1 here? |
vasilevklart
left a comment
There was a problem hiding this comment.
lgtm, 1 and 2 as a follow-up sounds good
|
ThankS! |
#495 has landed, so this is now rebased on main and the diff is only this PR's commits.
Fixes #599
What does this PR do?
CosyVoice3's flow node runs its 10-step Euler solve eagerly, about 145 ms a call at streamed-hop sizes whatever the size (41 ms replayed; likely launch-bound, not profiled), which every request pays. The flow node now replays the solve as piecewise CUDA graphs the engine captures, one per frame bucket and batch size.
cosyvoice3/components/flow.pysplits the solve intoprepare_solve(pads) and a fixed-shapesolve_prepared, so nothing host-side is in the captured part; eager output is unchanged.cosyvoice3/submodules.pyreturns onePiecewiseBatchedConfigper frame bucket and replays the smallest bucket and batch size that fit, else runs eagerly; the engine captures (AGENTS.md invariant 1).cosyvoice3/config.py: 20 buckets, 100 to 4,200 frames, batch 1 to 8 within 8,192 batch-frames (97 graphs).flow_capture_frame_buckets,flow_capture_batch_sizesandflow_capture_batch_framescan be set inmodel_kwargs;flow_capture_frame_buckets: []captures nothing and every solve runs eagerly, as on [Model] Add Fun-CosyVoice3: zero-shot voice-cloning TTS with streamed 24 kHz audio #495.One H200,
tts_speech_bench, #495 vs this PR: 8.7 to 14.9 audio-s/s at c1 and 18.9 to 34.7 at c32, first audio p50 306 to 172 ms at c1. Capture is on by default and costs 4.8 GiB of GPU memory and about 72 s of startup (94 s vs 21 s to/health);model_kwargs: {flow_capture_frame_buckets: []}turns it off for a deployment that cares more about either.How was it tested?
Rebased on main 18b0859 (ee60508):
ruff check .clean; CPU Core (ci.ymlpaths w/o the TP lockstep sim,-n 4): 8,632 passed, 935 skipped, 40 failed, the same 40 as on main 18b0859 in this venv (37 Rust-runtime cases that need a newermstar_rustbuild than this venv's, 3 needftfy). The only change in the rebase: the CPU capture stand-in intest_cosyvoice3_flow_capture.pytakes thenode_namethat #810 now passes tocapture_into_graph.On d5f118f (before the rebase, on #495's head 1d346b8, which is on main a3d73a0):
ruff check .clean. CPU Core withci.yml's paths incl. the TP lockstep sim (pytest -n 4 --dist worksteal, shared venv): 6788 passed, 1200 skipped, 6 failed; the same 6 fail on main a3d73a0 in that venv (they needprometheus_client,soundfileorftfy), so none are this PR's.python -m pytest test/modular/test_cosyvoice3_flow_capture.py test/modular/test_cosyvoice3_model.py test/integration/test_cosyvoice3_real_weights.py: 134 passed, 11 skipped on CPU; on 7e99011, 145 passed on one H200 with the checkpoint, the CUDA capture test included (bf16 and float32). New:test_capture_fields_come_from_model_kwargsandtest_a_deployment_can_turn_capture_off(both fail before themodel_kwargschange).Perf A/B on one H200,
benchmark.tts_speech_bench, #495 / this PR / #631 rotateda b c c b a a b cwith 3 server runs each, then 2 runs withflow_capture_frame_buckets: []: the numbers in the chart, 0 errors in 1,377 requests per arm, ranges over the runs never overlap; capture off matches #495 within spread (8.8 / 13.9 / 16.3 / 18.8 audio-s/s). Table and commands in Performance. Measured on 437bcbf (#495) and 0847206 (this PR); the heads since add main's #807 (chat attachments), the store-error wording in_resolve_ref_audioand a GPU-test fix, none on the measured path.E2E compare matrix's
cosyvoice3clone case against #495's head (one H200): no regressions, same WER (2.54%, 24/24 identical transcripts) and speaker similarity (0.958), c=1 latency 761 to 413 ms, c=max throughput 11.1 to 22.8 audio-s/s (Performance).Not tested
A served float32 flow estimator (the GPU test covers its capture); other GPUs; a smaller bucket ladder for small GPUs (the fields are settable now, but no smaller ladder was measured).
Depends on and merge order
Depends on: #495, now on main. Nothing else to wait for.
Design
prepare_solveright-pads a batch to the bucket's frame count. Embeddings past a row's tokens are zeroed, the DiT's convolutions are left-padded (causal), attention masks every key past a row's valid frames (chunk_attention_mask), androw_keepzeroes the attention output of padded query frames. So the frames of a row do not read the padding; in exact arithmetic a padded solve gives each row the frames of its own solve. In floating point, another shape can pick other kernels, which is the same spread the eager path already has when it pads a short row to a longer one in a batch (Correctness).solve_prepared, the chunk mask, the noise slice and the 10 Euler steps with guidance, at the default step count. It reads nothing on the host.prepare_solve(embedding, look-ahead, prompt mel, speaker projection) stays eager and writes the static inputs.FlowSubmodule._solvetakes the smallest bucket that holds the batch's longest row, then the runner pads the batch to the smallest captured batch size. A padding row has zero inputs andvalidclamped to 1, so its attention stays finite; its output is dropped. A solve past the largest bucket (4,200 frames, about 84 s of prompt plus speech), a batch size that bucket did not capture, or anothern_timestepsruns the same code eagerly.batch * frames <= 8192. Below about 3,000 batch-frames an eager solve takes about 145 ms whatever the size, likely bound by its kernel launches (a replay of the same kernels is 41 ms at 250 frames); past that it is bound by compute, and replay saves 1 to 13% (Performance), so finer buckets there would cost startup and memory for little.model_kwargs(sorted, repeats dropped, positive integers; a frame budget of 0 is allowed). An empty bucket list, or a frame budget of 0, leavesget_piecewise_accelerator_graph_configsempty, so the engine captures nothing and_solveruns every solve eagerly; the served numbers with it off match [Model] Add Fun-CosyVoice3: zero-shot voice-cloning TTS with streamed 24 kHz audio #495 within run-to-run spread (table above). The default stays on._without_tf32, as the eager solve runs, and the flow node opts out of autocast, so capture and eager run the same precision.mstar/model/captures, syncs or manages streams; the node returnsPiecewiseBatchedConfigs fromget_piecewise_accelerator_graph_configsand callsrunner.run, as Kokoro does. The runner's output is a copy, so a mel outlives the next replay.Correctness
Served, one H200, the same clips as Performance (27 sentences of
t2s.txt,--voice zero_shot --seed 7). WER isbenchmark.tts_speech_wer(whisper-large-v3-turbo); speaker similarity is the cosine of each clip's CAMPPlus x-vector (the one the flow is conditioned on) to the reference clip's. Measured on d7aa2c0 and 11c30c2 (older heads of #495 and this PR; the solve and routing are unchanged since):--extra stream=false)Before vs after at c1: 23 of 27 clips are byte identical; the other 4 have the same length (same tokens) and differ in the flow's rounding, as below. On the current heads, with #495's look-ahead change, a review run found 30 of 32 clips byte identical to #495 at the same WER; the differences on the older heads were in short clips' last chunk only (log-mel SNR 26 dB or better). At c8 the batch composition varies between starts, so even before vs before leaves 6 of 27 clips different; WER and speaker similarity are the check there.
Flow solve parity, on 11c30c2, real weights, bf16 estimator, fixed speech tokens and the model's fixed noise:
The eager path itself is unchanged:
solve_rowson #495's head and on this branch give bit identical mels (CPU, float32, 3 and 10 steps, padded batches with final and non-final rows). The GPU testtest_captured_solve_equals_the_eager_solve_of_the_same_padded_inputsis the in-repo form of the first row (bf16 and float32 estimators, random weights); it passes on one H200 on the head.Performance
One H200 (torch 2.12.1+cu130, spot VM), one server per run,
configs/cosyvoice3.yamlplusmodel_kwargs.voices_dirholding the CosyVoice Mandarin prompt clip as thezero_shotpreset,MSTAR_SHM_ARENA=1, servers started and stopped with.claude/skills/benchmarking/scripts/server.sh. Arms rotated #495 (437bcbf), this PR (0847206), #631 (bc022bd) asa b c c b a a b c, 3 server runs each, then 2 runs of this PR withflow_capture_frame_buckets: []. Each cell is the median over server runs of the harness's median. Inputs:t2s.txt(27 sentences) for c1, the same file 3 times (81 lines) for c8 and 6 times (162 lines) for c16 and c32, so every concurrency gets at least 5x its count in requests.Total audio per pass matched within 0.4% between #495 and this PR, so audio-s/s is not a length artifact. Startup to
/health(server.sh's poll): 21 s on #495, 90 to 94 s with capture, 21 s with it off; GPU memory after startup (nvidia-smi): 6.2 GiB on #495 and with capture off, 11.0 GiB with capture. The c1 gain is larger than in the earlier run below because #495's look-ahead change (cuDNN off for those convolutions) has since landed underneath.Earlier H200 run (d7aa2c0 vs 11c30c2, before #495's look-ahead change, ABBA, 2 server starts per arm, 3 repeats each): c1 first audio p50 454 to 333 ms, c8 1309 to 953 ms, c16 3218 to 2109 ms, c32 8879 to 6070 ms; throughput 7.3 to 10.1, 11.8 to 17.7, 12.9 to 19.8, 15.5 to 22.8 audio-s/s.
Flow solve microbenchmark, on 11c30c2, real weights, bf16 estimator, TF32 off as the node runs it: the eager 10-step solve (wall time to GPU completion) against a replay of the same solve captured as one CUDA graph, median of 5 (3 for the largest):
Up to about 3,000 batch-frames the eager solve takes about 145 ms whatever its size, while a replay of the same kernels takes 41 to 138 ms: kernel launches are the likely cost (a hypothesis; not profiled). That is the regime streamed hops live in (prompt plus 25 to 100 settled tokens, a few hundred frames). The scripts behind that table, the parity table and the capture cost are in this comment.
E2E compare matrix (#652's
cosyvoice3clone case, 24 clones, c=1 and c=max, 3 passes), one H200, #495's head as base; startup 22 / 92 s, peak VRAM 15 / 20 GB: no regressions.Tests in detail
test/modular/test_cosyvoice3_flow_capture.py(CPU tests drive the realPiecewiseAcceleratorGraphRunnerwith capture replaced by an eager rerun of the region):model_kwargs(sorted, deduplicated, bad values refused), and an empty bucket list or a zero frame budget captures no region while the solve gives the same mel as a replay;warmup_and_captureits plan (None, every bucket); the old call raised aTypeErroron a GPU while CPU CI skipped it.Resources
/healthflow_capture_frame_buckets: [])The flow node declares no engine resources, so the regions declare no step. Pool and buffer sizes measured on 11c30c2 with
capture_mem.pyfrom the linked comment, which builds the flow node as the server does and captures its regions throughPiecewiseAcceleratorGraphRunner.Checklist
ruff check .passesAI assistance: written with AI coding agent