Repository navigation
perf(voxcpm2): size the step graphs by the request, not by max_length - #950
Conversation
Both MiniCPM step runtimes were built in the generator constructor with config.max_length (8192 on the shipped package) as cache_steps. cache_steps is a graph dimension, not just an allocation bound: the step graph's attention mask is cache_steps long and every generated patch runs flash attention over all of it, so a three-second phrase attends over 8192 rows of which it fills ~114, and 36 layers x 8192 x 256 f32 keys and values stay resident (604 MiB of cache tensors plus the same again in the per-layer host import scratch, 1.21 GiB) for the life of the runtime. The capacity was not reachable from the CLI: the voxcpm2 option table has mem_saver and prompt_cache_slots, no cache key. Size the step runtimes per request from the budget the request can actually fill, bucketed, and grow them in place when the bucket fills: - engine::runtime::bucketed_cache_steps() is the bucketing higgs_audio_tts already used, lifted out of that family's generator so both share it. - generate_once() builds both step graphs at bucketed(prefill_rows, max_tokens) and rebuilds only when the bucket changes, so a fixed-shape workload keeps one pair of graphs; the AR loop doubles the bucket when it fills, carrying the state through export_state/import_state, and the last bucket is capped at the request's own prefill_rows + max_tokens bound. - VoxCPM2MiniCPMStepRuntime gains cache_steps()/valid_steps() for that check. Measured on CUDA (RTX 4070 Ti, sm_89, q8_0 package, seed=1234, three text lengths, one process per run, device memory from an nvidia-smi sampler with the desktop baseline subtracted): | request | audio ms | wall ms | peak RSS MB | peak device MB | |---|---|---|---|---| | 43 chars, main | 3040 | 1010 | 1408 | 4227 | | 43 chars, this | 3040 | 1174 | 830 | 3077 | | 214 chars, main | 11360 | 3048 | 1407 | 4431 | | 214 chars, this | 11520 | 2601 | 832 | 4319 | | 630 chars, main | 35200 | 8254 | 1407 | 6661 | | 630 chars, this | 35360 | 7275 | 833 | 5446 | Peak RSS drops ~575 MB at every length, resident device memory drops ~1.1 GiB on the two requests that need a long cache, and the short request pays +164 ms because the step graphs are now built on the first request instead of at load. Renders are bit-identical for a request that fits one bucket (md5 00896604 in both builds). The 214- and 630-character requests gain 4 patches (160 ms) and change md5. That is not the growth path: with the same code and cache_steps pinned back to max_length, all three md5s reproduce main exactly (00896604/c6e4c2b5/5cbd0784). It is the attention reduction order - flash attention over 135 masked rows and over 8192 masked rows are not the same floating-point sum, and voxcpm2's generation is stochastic (its durations already differ across platforms at a fixed seed, unlike f5_tts). Rounding the buckets to powers of two (128/256/512) does not restore parity. bucketed_cache_steps is the shared bucketing higgs_audio_tts already had as a file-local helper, so it now has unit coverage in transformer_kv_ring_test (ctest): the exact and clamped cases a caller hits, plus bounds and monotonicity in the request over a prompt x tokens grid.
|
Both checked, added to the PR body under «Review follow-up». The #921 failure mode is absent. One live WER does not move. All 16 clips through faster-whisper The stand also gave a server-side timing row the CLI stand in the body did not cover (one session, |
…en3 one-pass voicing, voxcpm2 step graphs, f5 CUDA constants, WavLM, cosyvoice3 NPU) Upstream commits 9464bbe..a8f9858 (9): - perf(qwen3_tts): opt-in voicing chunked text in one decoder pass (0xShug0#910) - server: default_session_options for configured and UI-loaded models (0xShug0#944) - Kitten TTS 2 BF16/Q8/Q4 GGUF support (0xShug0#947) - perf(voxcpm2): size step graphs by the request, not max_length (0xShug0#950) - fix(f5_tts): free evicted graphs' staged CUDA constants (0xShug0#953) - feat(cosyvoice3): flow DiT estimator on the Rockchip NPU (0xShug0#951) - refactor(f5_tts): drop unused raw DiT weight store (0xShug0#956) - WavLM memory reduced with graph allocation (0xShug0#954) - server: voice presets carry request options (0xShug0#942) # Conflicts: # src/framework/modules/speech_encoders/wavlm_encoder.cpp # src/models/qwen3_tts/session.cpp
What
The second half of #917 — the shape of the computed graph, not just the size of the allocation.
VoxCPM2FeatureGeneratorRuntimebuilt both MiniCPM step runtimes in its constructor withconfig.max_length(8192 on the shippedq8_0package) ascache_steps.cache_stepsis a graphdimension, not only an allocation bound:
so every generated patch runs flash attention over all 8192 rows, of which a three-second phrase fills
~114, and
36 layers × 8192 × 256 × 4 B × 2keys+values stay resident for the life of the runtime(604 MiB of cache tensors, plus the same again in the per-layer host import scratch — the 1.21 GiB in
the issue). Nothing in the voxcpm2 option table reaches the capacity: it has
mem_saverandprompt_cache_slots, no cache key, so a user cannot shrink it from the CLI.Fix
Size the step runtimes from the budget the request can fill, bucketed, and grow in place:
engine::runtime::bucketed_cache_steps()is the bucketinghiggs_audio_ttsalready had as afile-local helper (
bucketed_initial_cache_steps), lifted into the framework header so both familiesshare one copy. higgs' call site is unchanged in behaviour.
generate_once()builds both step graphs atbucketed(prefill_rows, max_tokens)and rebuilds onlywhen the bucket changes, so a fixed-shape workload keeps one pair of graphs. The AR loop doubles the
bucket when it fills and carries the state through
export_state/import_state; the last bucket iscapped at the request's own
prefill_rows + max_tokens, which is the quantity the existingCapacityErroralready compares againstmax_length.VoxCPM2MiniCPMStepRuntimegainscache_steps()/valid_steps()for that check.Measured — CUDA (RTX 4070 Ti, sm_89,
seed=1234, one process per run, device memory =nvidia-smipeak minus desktop baseline)624cf918624cf918624cf918Peak RSS −575 MB at every length; resident device memory −1.1 GiB on the requests that need a long
cache; the short request pays +164 ms (+16%) because the step graphs are now built on the first
request instead of at load (the med/long rows, where the per-step attention saving dominates the one
build, are −15% and −12% wall).
The render changes on long requests — disclosed, and isolated
The 43-character request is bit-identical (
md5 00896604in both builds). The 214- and 630-characterrequests gain 4 patches (160 ms) and change md5.
That is not the growth path. With this same code and
cache_stepspinned back tomax_length, allthree md5s reproduce
624cf918exactly (00896604/c6e4c2b5/5cbd0784) — so lazy construction,bucketing, and the
export_state/import_stategrowth round-trip are bit-exact. The delta is theattention reduction order: flash attention over 135 masked rows and over 8192 masked rows are not the
same floating-point sum, and voxcpm2's generation is stochastic (its durations already differ across
platforms at a fixed seed, unlike f5_tts). Rounding the buckets to powers of two (128/256/512) does
not restore parity — measured, same 4-patch delta — so this is inherent to shrinking a masked
static cache, not to the bucket shape.
If bit-parity on long requests is a hard requirement, the shape that keeps it is a session option that
defaults to
max_length(thevoxtral_realtime.stream_decode_cache_stepspattern) and opts into therequest-sized buckets — say so and I will re-cut it that way.
Validation
voxcpm2,higgs_audio_tts, CUDA/sm_89;the higgs target compiles from the lifted helper (pure move, call site unchanged).
bucketed_cache_stepsgets unit coverage in the existingtransformer_kv_ring_test(ctest), whichalready links
engine_runtime: the exact/clamp cases a caller hits (114+128 → 242,100+200 → 256,200+8000 → 512,8000+0 → 8000,0+0 → 0) plus bounds and monotonicity in the request overa prompt × tokens grid. Passes on this tree (CUDA/sm_89 build,
rc=0).624cf918; the branch is rebased onto current main with an identicalpatch-id, so the numbers describe the shipped diff.
ENGINE_BUILD_WARMBENCHparity harness, which storesno md5 goldens), so the check here is the md5 ladder above.
--backend cpu --threads 4, same three texts, serialladder with
pgrep audiocpp_cliempty before every run):624cf918624cf918624cf918Peak RSS −1.7 GiB at every length, wall −44%/−8%/−12%. The short request — the one that pays the
graph build — is 44% faster here, not 16% slower as on CUDA: on 8 A55/A76 cores the attention over
8192 masked rows costs far more than the one graph build it replaces. md5s match on the 43- and
214-character requests (
e4598585,9adec9ed); the 630-character request diverges in both directionsof the same effect (
3a396c07→ae564b26, 37920 → 36160 ms of audio), as on CUDA.Review follow-up: the long-request drift, measured
Stand:
audiocpp_serveron an RTX 4070 Ti (CUDA,--threads 4), one live session, 8 requests in theorder med, long, med, med, short, long, short, short, fixed seed, base =
624cf918, head = thisbranch.
00896604×300896604×3c6e4c2b5bae77d91c6e4c2b5×2bae77d91×25cbd0784×2edf85d5a×2#921's failure mode is absent. Every repeat that follows a request of a different length — the
first request after the step caches grow into a larger bucket, and the first after they come back down
to a smaller one — is bit-identical to the warm repeats of the same text, on both sides. The failure
mode in #921 was a per-length prompt-prefill graph rebuild perturbing the draw; this change sizes
the decode-step graphs, and resizing them does not: on the base side, where the capacity never changes
at all, the cold first med request (14.111 s of graph building) and its warm repeats are the same md5,
and the same holds here.
WER does not move. All 16 clips transcribed with faster-whisper
small.en(int8, CPU), scoredword-level against the input text:
The transcripts are word-for-word identical on both sides (118 hypothesis words against a 117-word
reference at the long length, on both sides). The long-request md5 change is the disclosed
summation-order effect of a wider attention graph — the audio differs, the words do not.