Cache-aware Streaming ASR: up to 5x faster encoder with CUDA graphs#15863
Open
hamuzhan wants to merge 4 commits into
Open
Cache-aware Streaming ASR: up to 5x faster encoder with CUDA graphs#15863hamuzhan wants to merge 4 commits into
hamuzhan wants to merge 4 commits into
Conversation
Low-latency streaming inference of cache-aware FastConformer encoders is host-bound: each streaming step launches about 1.5k small kernels, so the GPU is idle for most of the step while the host enqueues them. The steady-state step has fully static shapes (the caches are allocated at full size up front and only the cache_last_channel_len values change between steps), so it can be captured once into a torch.cuda.CUDAGraph and replayed with a single launch. The RNNT/TDT label-looping decoder uses the same idea; the encoder is simpler because it has no data-dependent control flow, so plain CUDA graphs are enough (no conditional nodes, no cuda-python). CudaGraphsStreamingEncoderStep implements WithOptionalCudaGraphs and is attached via StreamingEncoder.set_streaming_cuda_graphs(). Notes: - outputs are copied inside the capture into stable buffers owned by the regular caching allocator, so the decoder's own CUDA graph does not alias the encoder graph's private pool (without this, decoding was corrupted); - graphs are dropped only after a full device sync, since teardown with replays in flight is not safe; - ASRModel.maybe_enable_cuda_graphs/disable_cuda_graphs also toggle the encoder graphs, so the train/val hooks manage them like the decoder graphs; - the first chunk, the final keep_all_outputs=True chunk, unseen shapes and capture failures run eager; - CUDA autocast runs eager: a captured graph fixes the dtype/kernel/workspace choices at capture time, so replaying it under a different autocast state could silently return wrong-precision outputs. Opt-in and off by default. For the covered non-autocast configurations the graph path is expected to preserve eager execution semantics. Signed-off-by: hamuzhan <hamzayigitkltr@gmail.com>
GPU tests use a small synthetic cache-aware ConformerEncoder (no downloads): - graphed steps are exactly equal to eager (torch.equal), across the cache-fill phase and the final keep_all_outputs=True step; - the static input cache buffers and the stable output cache buffers are distinct, so a replay does not overwrite its own inputs; - a new graph is captured after an att_context_size switch, and the max_graphs limit is respected; - the encoder runs eager in training mode, under CUDA autocast, and in forced no-graphs mode; - ASRModel.disable_cuda_graphs/maybe_enable_cuda_graphs also toggle the encoder graphs, plus the recursive WithOptionalCudaGraphs toggle. CPU tests cover the disabled (no-CUDA) path and argument validation. Signed-off-by: hamuzhan <hamzayigitkltr@gmail.com>
- add a use_cuda_graphs flag to speech_to_text_cache_aware_streaming_infer.py - document the feature and its eager fallbacks in the cache-aware streaming Conformer section of the ASR docs Signed-off-by: hamuzhan <hamzayigitkltr@gmail.com>
Author
Author
Collaborator
|
@hamuzhan thank you so much for your contribution! The speedup is truly impressive! |
Author
|
@artbataev Thank you for taking the time to review it. Looking forward to your feedback! Have a great week! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do ?
This is an opt-in, off-by-default speedup for the cache-aware FastConformer streaming encoder
steady-state path. Non-steady-state steps, CUDA autocast, capture failures and unseen shapes all
run eager, the number of retained graphs is bounded, and the tests cover the autocast fallback,
cache-buffer aliasing, the train/val lifecycle and the
max_graphslimit.Adds an opt-in CUDA graph path for the cache-aware streaming encoder step
(
StreamingEncoder.cache_aware_stream_step). This targets any cache-aware FastConformer streamingencoder that goes through
StreamingEncoder.cache_aware_stream_step(Parakeet / Nemotron Speech /stt_*_fastconformer_*_streaming_*); it is not a generic path for all ASR encoders. Low-latencystreaming inference of these models spends most of its time waiting on the host, not computing.
Each streaming step launches around 1.5k small kernels, so the GPU is idle for most of the step
while the host enqueues them.
The steady-state streaming step has fully static shapes. The caches are allocated at full size up
front and only the
cache_last_channel_lenvalues change between steps. That means the whole stepcan be captured once into a
torch.cuda.CUDAGraphand replayed with a single launch. The existingRNNT/TDT label-looping decoder (
GreedyBatchedLabelLoopingComputerBaseand theWithOptionalCudaGraphsinfrastructure) already applies this idea to decoding; this PR reuses the same
WithOptionalCudaGraphsenable/disable and fallback design for the encoder step. The encoder is simpler because it has no
data-dependent control flow, so plain CUDA graphs are enough (no conditional nodes, no
cuda-pythondependency).
You turn it on with
asr_model.encoder.set_streaming_cuda_graphs(True)oruse_cuda_graphs=trueinthe cache-aware streaming inference script. For the covered non-autocast configurations the graph
path is expected to preserve eager execution semantics, and the unit tests assert
torch.equal(not
allclose) against eager. Steps that are not part of the steady state (the first chunk, thefinal
keep_all_outputs=Truechunk, or any shape we have not seen) just run eager.A few design notes:
caching allocator. Without this, the decoder's own CUDA graph aliased the encoder graph's private
memory pool and corrupted decoding, so it is needed for correctness.
while replays are still in flight is not safe.
WithOptionalCudaGraphs. Implemented so the existingASRModeltrain/val hooks disable andre-enable the encoder graphs the same way they already do for the decoder (graphs do not release
memory during training).
streaming params that change the computation (
att_context_size,last_channel_cache_size,valid_out_len,drop_extra_pre_encoded) andkeep_all_outputsall match. Thecache_last_channel_lenvalue (which changes during the cache-fill phase) is a graph input, notpart of the key: its shape is fixed, so it is just copied into the static input buffer each step.
time, so replaying a non-autocast graph under autocast (or the reverse) can silently return
wrong-precision outputs. We could add the autocast state to the graph key, but a wrong key there
is a silent-correctness bug, so the graph path is skipped whenever CUDA autocast is active
(cache-aware streaming models run in float32 anyway). There is a test for this. The autocast check
handles both the device-specific and the no-argument
torch.is_autocast_enabledsignatures forPyTorch compatibility.
same shape is not retried on every step (unless a mode is forced for testing).
Results on
nvidia/nemotron-3.5-asr-streaming-0.6b(GH200, fp32/TF32), steady-state step p50:[56, 0](80 ms)[56, 0](80 ms)[56, 3](320 ms)[56, 3](320 ms)[56, 13](1120 ms)The win is biggest in the low-latency, low-batch case (the voice-agent use case), where launch
overhead dominates the step. End-to-end on the documented script (
[56,0], batch 10): 9.87 s ->3.08 s (3.2x).
real-time streams (est.)is a rough capacity estimate, not a full-pipeline benchmark. It isbatch * chunk_duration / step_p50using the per-step encoder+decoder time measured above (chunkduration =
(right_context + 1) * 80 ms); it does not model audio I/O, tokenization or host-sidescheduling at high concurrency, so treat it as an upper bound that scales with the step speedup.
Memory overhead (extra peak device memory retained by the graph pool plus static input/output
buffers vs eager, encoder-only, GH200, same model):
[56, 0][56, 0][56, 3][56, 13]The overhead scales with batch (one step of intermediate activations) and is essentially
independent of the look-ahead. Each captured graph retains its pool until
disable_cuda_graphs()or the helper is detached, so at most
max_graphs(default 8) pools can be live at once.Correctness:
speech_to_text_cache_aware_streaming_infer.py ... use_cuda_graphs=true compare_vs_offline=truereports 0 differences streaming-vs-offline, identical WER, and transcriptsthat match the eager run exactly (checked at
[56,13]and[56,0]). Tests cover:torch.equal), including the cache-fill phase and thefinal
keep_all_outputs=Truestep;not overwrite its own inputs);
att_context_sizeswitch (no stale-graph reuse), and themax_graphslimit is respected (further keys stay eager);WithOptionalCudaGraphstoggle, and that the realASRModel.disable_cuda_graphs()/maybe_enable_cuda_graphs()also toggle the encoder graphs;Collection: ASR
Changelog
CudaGraphsStreamingEncoderStep(nemo/collections/asr/parts/submodules/streaming_encoder_cuda_graphs.py):keyed capture/replay of the streaming encoder step with warmup, stable output buffers, eager
fallback, and a forced-mode hook for testing; implements
WithOptionalCudaGraphs.StreamingEncoder.set_streaming_cuda_graphs()and routecache_aware_stream_stepthrough theoptional graph path (
nemo/collections/asr/parts/mixins/streaming.py).ASRModel.maybe_enable_cuda_graphs()/disable_cuda_graphs()to also toggle the encoderstreaming graphs, so the training/validation hooks manage them like the decoder graphs
(
nemo/collections/asr/models/asr_model.py).use_cuda_graphsflag tospeech_to_text_cache_aware_streaming_infer.py.CacheAwareASRInferenceWrapper.set_streaming_cuda_graphs()delegation.tests/collections/asr/test_streaming_encoder_cuda_graphs.py(14 tests): graphed-vs-eagercorrectness for the supported non-autocast CUDA inference path (cache-fill phase, final
keep_all_outputsfallback),max_graphsbehavior, input/output cache-buffer aliasing,model-level train/val hooks, and CUDA autocast fallback.
Usage
From the simulation script:
python examples/asr/asr_cache_aware_streaming/speech_to_text_cache_aware_streaming_infer.py \ pretrained_name=nvidia/nemotron-3.5-asr-streaming-0.6b \ dataset_manifest=<manifest.json> \ att_context_size="[56,0]" target_lang=en-US \ use_cuda_graphs=trueGitHub Actions CI
The GitHub Actions CI will run automatically when the "Run CICD" label is added to the PR.
To re-run CI remove and add the label again.
To run CI on an untrusted fork, a NeMo user with write access must first click "Approve and run".
Before your PR is "Ready for review"
Pre checks:
PR Type:
If you haven't finished some of the above items you can still open "Draft" PR.
Who can review?
Anyone in the community is free to review the PR once the checks have passed.
Contributor guidelines contains specific people who can review PRs to various areas.
cc @nithinraok (NeMo core / ASR).
Additional Information
set_streaming_cuda_graphs(True)oruse_cuda_graphs=trueis set. It no-ops without CUDA.WithOptionalCudaGraphsinterface and the same enable/disable + graceful-fallback pattern used bythe label-looping decoders, so the train/val lifecycle behaves identically for both.
Limitations / scope:
keep_all_outputs=Truechunk, unseen shapes, active autocast, and capturefailures fall back to eager.
disable_cuda_graphs()/ detach (see the memory table above).config wiring is left for a follow-up.
device change re-captures, but DDP inference has not been exercised here.
Follow-ups (separate PRs, not in here): wiring the flag through the streaming Pipeline API config;
cutting the attention op count (SDPA plus positional-embedding precompute); a cache ring-buffer.