cuda: make graph-cache eviction reachable; qwen3_asr: survive concurrent-load memory pressure - #293
cuda: make graph-cache eviction reachable; qwen3_asr: survive concurrent-load memory pressure#293derekja wants to merge 2 commits into
Conversation
|
RTF data, qwen3_asr 0.6B Q8 (the family this PR touches):
nemotron_asr 0.6B Q8 (regression check for the shared ggml/framework changes):
Long-file RTF is unchanged on both families and nemotron sits inside its baseline spread everywhere. Short/medium qwen is measurably faster on the patched build (2 s: 0.0326 → 0.0258; ranges don't overlap) — plausibly the reset-before-rebuild change relieving allocator pressure, though I haven't isolated it; no length is slower. Consistent with the patch adding no code to the warm path (eviction runs at graph destruction, the pool trim only on a previously-fatal allocation failure). |
|
@derekja Could you clean up the churn in the PR caused by the CRLF changes? Changes to GGML are usually high-risk because of their large blast radius and generally require a regression sweep, unless they’re purely additive and opt-in. The current PR shows full-file churn, which makes it difficult to tell what actually changed. |
… before rebuild, exception-safe gallocr Follow-up to 0xShug0#276. Under concurrent /v1/audio/transcriptions load the qwen3_asr family leaks VRAM until allocation fails, after which every request 500s until process restart. Serial load is stable (0xShug0#276 works); the concurrent-only signature is the tell: ggml's CUDA-graph cache (cuda_graphs) is keyed by cgraph->nodes[0], a host pointer into the graph's ggml_init arena. Every graph destructor already calls engine::core::release_backend_graph_resources, which looks up "ggml_backend_cuda_clear_graph" by proc address -- but the CUDA backend never exported that name, so eviction has been a silent no-op since it was added. Single-threaded servers get away with it: the arena is munmap'd and the next same-size ggml_init reuses the address, so the stale entry is overwritten in place. Concurrent requests (one detached thread per HTTP request) perturb the address space, each rebuild mints a fresh key, and the orphaned entries -- each holding a cudaGraph_t + cudaGraphExec_t -- accumulate until cudaMalloc fails. Measured on a 12 GB H100L vGPU slice, Qwen3-ASR-0.6B Q8, 8 concurrent streams of mixed 1-8 s utterances: VRAM 6.2 -> 8.9 -> 10.5 GB (ceiling) across identical repeated sweeps, then permanent 500s. Identical serial sweeps: byte-stable. nemotron_asr under the same concurrent load: byte-stable (its shapes do not churn), which localized the leak. Three changes: 1. ggml-cuda.cu: export ggml_backend_cuda_clear_graph through get_proc_address, making the existing destructor-side eviction calls effective (docs/build/HIP.md already claims this works; now it does). 2. qwen3_asr thinker/audio_encoder: reset the old graph before constructing its replacement. Assigning make_unique over a live unique_ptr holds both arenas at the rebuild peak, which doubles the transient footprint precisely when interleaved streams force a rebuild on nearly every request. 3. qwen3_asr: hold ggml_gallocr_t in a unique_ptr (the voxtral_realtime pattern) so the CapacityError/runtime_error throws in the graph constructors stop leaking the partially reserved arena -- previously every failed rebuild after an OOM deepened the OOM. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The legacy CUDA pool (no VMM) caches every buffer it ever allocated and only flushes when ITS OWN cudaMalloc fails; graph arenas allocate through ggml_backend_cuda_buffer_type_alloc_buffer, which does not flush anything. Under concurrent load with CUDA graphs enabled the pool ratchets up to the device ceiling, after which every graph (re)build fails permanently even though gigabytes of idle cached buffers are reclaimable -- the terminal all-500s state. (With GGML_CUDA_DISABLE_GRAPHS=1 the same workload is byte-stable, which is how the pool was isolated as the reservoir.) Adds ggml_cuda_pool::clear() (no-op by default, clear_pool() on the legacy pool), an exported ggml_backend_cuda_trim_pools(), a framework resolver engine::core::trim_backend_pools(), and a trim-and-retry-once on the allocation-failure paths of all four qwen3_asr graphs. A trim costs a device sync and only ever fires on a failure that was previously fatal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
e9667c0 to
cb427c9
Compare
|
you bet, thanks. running regression tests overnight and will post when complete. |
|
Thanks for the careful look, and apologies for the churn — that was my tooling silently normalizing line endings on the three On blast radius, agreed — here's the breakdown and the regression sweep. What the ggml side actually changes (+29 lines, no deletions): two proc-address table entries, Regression sweep (12 GB H100L vGPU, CUDA backend, this branch):
One pre-existing note from running the unit suite: Happy to run any additional sweep you'd like — the concurrent-load harness that produced the leak repro is scriptable and I can share it. |
Follow-up to #276. That fix made serial qwen3_asr load memory-stable (verified: three identical single-stream sweeps, VRAM byte-identical). Under concurrent
/v1/audio/transcriptionsload a distinct failure remained: VRAM grows on every run — including identical repeats of the same shapes — until allocation fails, after which every request returns 500 until the process is restarted.Measured on a 12 GB H100L vGPU slice (no VMM), Qwen3-ASR-0.6B Q8, 8 concurrent streams of mixed 1–8 s utterances posting accumulated audio ~1/s: 6.2 → 8.9 → 10.5 GB (ceiling) across repeated sweeps, then permanent 500s.
nemotron_asrunder the identical workload is byte-stable, which localized the problem to the qwen3_asr graph lifecycle. WithGGML_CUDA_DISABLE_GRAPHS=1the same workload is also byte-stable, which localized the reservoir.Four changes, in causal order:
Export
ggml_backend_cuda_clear_graphthroughget_proc_address. Every graph destructor already callsengine::core::release_backend_graph_resources, which resolves this name — but the CUDA registry never exported it, so eviction has been a silent no-op since it was added (docs/build/HIP.mdclaims it works; now it does). Single-threaded servers got away with it because the freed graph arena's address is typically reused by the next same-sizeggml_init, overwriting the stalecuda_graphsentry in place; concurrent requests (one detached thread per request) perturb the address space, so each rebuild minted a fresh key and orphaned acudaGraph_t+cudaGraphExec_t.Release the old graph before constructing its replacement (prefill/decode/classification/encoder). Assigning
make_uniqueover a liveunique_ptrholds both arenas at the rebuild peak — and interleaved streams force a rebuild on nearly every request because the prefill/encoder caches are exact-shape single slots.Hold
ggml_gallocr_tin aunique_ptr(the existingvoxtral_realtimepattern) so theCapacityError/runtime_errorthrows in the graph constructors stop leaking the partially reserved arena. Previously every failed rebuild after an OOM deepened the OOM.Trim idle pool memory and retry once when graph allocation fails. The legacy pool (no-VMM path) caches every buffer it ever allocated and only flushes when its own
cudaMallocfails; graph arenas allocate throughggml_backend_cuda_buffer_type_alloc_buffer, which flushes nothing — so once the pool ratchets to the ceiling, every graph build fails forever while gigabytes of idle cached buffers sit reclaimable. Addsggml_cuda_pool::clear(), an exportedggml_backend_cuda_trim_pools(), a framework resolver, and trim-and-retry on the four qwen3_asr allocation-failure paths. The trim costs one device sync and only runs on a previously-fatal failure.Validation (same slice/model/harness): the workload that previously produced the terminal all-500 state now self-heals — at the ceiling the trim reclaimed 2.3 GB mid-load, 30/32 utterances in the pressure-point sweep still transcribed, and the server returned to full health (WER identical to baseline, finals p50 9 ms) with no restart. Serial behavior is unchanged. Transcripts across the load runs are unchanged (corpus WER 0.05 at every level, same as pre-patch).
Repro used throughout: N concurrent realtime websocket streams → accumulated-utterance POSTs of mixed lengths; happy to share the harness scripts if useful.