[perf] Fun-ASR-Nano fine-tuning on B200: 0.244 s to 0.068 s per step at 1 GPU, mostly from cuDNN SDPA plan builds on every new batch shape - #3705
Conversation
LauraGPT
left a comment
There was a problem hiding this comment.
Thanks for the detailed experiment and for explicitly reporting the measurements outside the numerical tolerances. I reviewed the two-file diff at c478743. Two constructor-scope regressions need addressing before this is ready; inline comments below.
For a bounded check, I AST-extracted the exact two added constructor blocks from both files and executed them with real PyTorch 2.11.0+cu128 on an H100-visible host, using a CPU eval-mode Linear decoder stand-in. All four cases confirmed that the cuDNN SDPA process flag becomes false even with torch_compile=false; omitting the option also replaces the CPU eval decoder's forward with the lazy compile wrapper. The original process flag was restored. This is not full model initialization, compiled execution, B200 training, or independent verification of the speedup/numerics.
Please keep the B200 timing claims scoped to that experiment, and retain the reported loss/token-accuracy/gradient-norm tolerance violations as unmet gates rather than describing numerical equivalence as passing. A documented, explicit experimental training opt-in with regression tests for unchanged defaults would make this much easier to assess. Frozen-encoder train/eval policy should remain a separate recipe decision.
Review prepared with Codex assistance.
| # ASR batches produce new pairs on ~1 step in 6. Flash / mem-efficient handle the | ||
| # masked case without per-shape plans, so take cuDNN out of the order. | ||
| if torch.cuda.is_available() and hasattr(torch.backends.cuda, "enable_cudnn_sdp"): | ||
| torch.backends.cuda.enable_cudnn_sdp(False) |
There was a problem hiding this comment.
[P2] Do not change the process-wide SDPA policy during model construction. enable_cudnn_sdp(False) persists beyond this instance and affects unrelated models in the same process; it still runs when llm_conf.torch_compile is false. The guard checks only whether any CUDA device exists, not the model's device, training mode, or the measured backend configuration. Please move this policy to an explicit training entry-point opt-in, or otherwise avoid changing the caller's global policy. Add a default/opt-out regression asserting that constructing this model leaves the pre-existing flag unchanged. The mirrored recipe block has the same issue.
| # inputs_embeds; lm_head and the loss stay eager). Bound-method assignment keeps the | ||
| # module tree and the state_dict keys as they are. dynamic=True: a new (B, L) every | ||
| # batch; a 1-sequence batch would be specialised into its own graph, so it runs eagerly. | ||
| if llm_conf.get("torch_compile", True) and torch.cuda.is_available(): |
There was a problem hiding this comment.
[P2] Preserve eager execution as the default for existing callers. This condition defaults torch_compile to true and only checks host CUDA availability, so even an eval-mode CPU decoder on a GPU host is wrapped; it also changes ordinary batched inference, not just the fine-tuning recipe measured here. Batch size one is the only eager bypass. The reported cold/warm first-step compile costs therefore become an implicit behavior change for existing configurations. Please make compilation explicitly opt-in for the experimental training path and test unchanged default/disabled behavior plus the intended enabled path in both model copies.
c478743 to
3b8281f
Compare
…lan builds cost ~1 s on 1 step in 6) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BfjQZ3d4EfZYoEKtDbB9ms
…mic shapes; 1-sequence batches eager) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BfjQZ3d4EfZYoEKtDbB9ms
3b8281f to
6f16546
Compare
…xplicit opt-ins Review of modelscope#3705: the constructor changed the process-wide cuDNN SDPA flag for every caller, and torch_compile defaulted to on for every model on a CUDA host, including CPU eval decoders and batched inference. - llm_conf.sdpa_backends (default None = torch's own selection): when set, e.g. [flash, efficient, math], the LLM decoder forward runs under torch.nn.attention.sdpa_kernel(...) with exactly those backends; the process flags are restored when the forward returns. torch.backends.cuda.enable_cudnn_sdp is no longer called. - llm_conf.torch_compile now defaults to False. The compiled graph is used only for inputs on a CUDA device, decided per call (funasr-train-ds builds the model on the CPU and moves it to the GPU afterwards, so a construction-time device test would never see CUDA; a decoder running on the CPU stays eager). The 1-sequence-batch eager path is unchanged. When both switches are on, the SDPA context wraps the compiled call. - Both model copies call one helper, funasr/models/fun_asr_nano/llm_forward_opts.py, so the two blocks cannot drift; the recipe already imports funasr.models.fun_asr_nano.* helpers. - finetune.sh turns both on (++llm_conf.torch_compile=true, ++llm_conf.sdpa_backends="[flash,efficient,math]"); docs/finetune.md documents the keys, the first-step compile cost and where sdpa_backends matters. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cdPcxsNZ5nE3qys9t14sj
…alone; opt-ins install scoped wrappers CPU-only, no weights: tiny stand-ins for the LLM (a .model.forward and get_input_embeddings), encoder and adaptor, so FunASRNano.__init__ runs end to end for both the built-in class and the recipe's model.py (loaded by path). - default / explicitly disabled llm_conf: torch.backends.cuda.cudnn_sdp_enabled() is unchanged after construction and llm.model.forward is the class method - torch_compile=true with the decoder running on the CPU (an eval decoder on a GPU host) takes the eager path on every call; built on the CPU and moved to a CUDA device (the trainer's order) it uses the compiled graph, keeps 1-sequence batches eager and matches the eager result - sdpa_backends=[flash, efficient, math]: cuDNN is off and flash on inside the forward only; the flags are restored on return and unchanged by construction - both switches compose on CUDA; unknown or empty backend names raise ValueError - the two ++llm_conf.* lines of finetune.sh parse through Hydra's override parser into the values the constructor expects Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cdPcxsNZ5nE3qys9t14sj
LauraGPT
left a comment
There was a problem hiding this comment.
I checked the new shared helper at8482dd2. With real torch2.11.0+cu128 and CPU decoder stand-ins, default configuration leaves both forward and SDPA flags untouched, explicit compile on CPU takes the eager path, and an explicitly selected SDPA context restores the flags after a sequential forward. The old unconditional-constructor/default-on findings therefore should not be applied unchanged to this revision. The recipe now explicitly enables the options.
One scope claim remains too strong: sdpa_kernel is not model-local/thread-isolated in this runtime. I held a configured math-only forward open in a worker using Events and observed SDPA flags from the other thread. cuDNN/flash/mem-efficient were disabled in both threads during that forward, then restored afterward. No concurrent ASR, model-weight inference, CUDA compilation, backward pass or B200 benchmark was run; this is a real flag-observation check of the exact helper, not the submitted full-constructor test suite.
Please describe this as temporary process-wide backend selection with restoration, not 'other models in the same process are not affected' or 'no process-wide torch flag is changed'. Document the concurrency limitation for explicitly enabled use. I have not reproduced the submitted training-speed/numerical results, and the new constructor/GPU tests are not independently accepted by this limited check.
| backend names, e.g. ``[flash, efficient, math]``. When set, the LLM decoder forward runs | ||
| under ``torch.nn.attention.sdpa_kernel(...)`` with exactly these backends enabled, and the | ||
| process-wide flags (``torch.backends.cuda.enable_cudnn_sdp`` and friends) are restored as | ||
| soon as the forward returns; other models in the same process are not affected. The |
There was a problem hiding this comment.
[P2] State the process-wide scope of this context. On torch2.11.0, an unrelated thread observes the selected backend flags while this forward is active; restoration on return does not make the selection model-local. Please remove the isolation guarantee and describe the concurrency limitation for callers enabling this option.
Summary
Two opt-in switches for the Fun-ASR-Nano fine-tuning recipe, both read from
llm_confinFunASRNano.__init__and both off by default, so every other way of building the model (inference,decode.py, custom configs) is unchanged.finetune.shturns both on. 4 commits: the two original ones, then the review round (96c30ae72scoping and defaults,8482dd2a9tests).llm_conf.sdpa_backends(defaultNone= torch's own backend selection). When set, e.g.[flash, efficient, math], the LLM decoder forward runs undertorch.nn.attention.sdpa_kernel([...])with exactly those backends; the process-wide flags are restored when the forward returns, so the choice is scoped to this model's forward and nothing else in the process is affected. Why: on sm_90 and sm_100 with torch 2.11, the Qwen3 attention with its explicit padding mask goes to cuDNN, which builds a new execution plan for every(batch, padded length)pair, about 1 s each. Token-bucketed ASR batches produce a new pair on about 1 step in 6: 59 of 382 steps, 56 s of a 96 s training phase. Flash and mem-efficient attention handle the masked case without per-shape plans.llm_conf.torch_compile(defaultFalse). When true, the decoder stack (llm.model) is wrapped intorch.compile(dynamic=True); the compiled graph is used only for inputs on a CUDA device, decided per call (funasr-train-dsbuilds the model on the CPU and moves it to the GPU afterwards), so a decoder running on the CPU stays eager. After the plan builds are gone, a step is 8,080 kernel launches for 37.5 ms of GPU work: the host is the bottleneck, and the 28 decoder layers are about 2,900 of those launches; the compiled decoder halves the launch count. Embeddings still come in asinputs_embeds;lm_headand the loss stay eager. A batch with one sequence runs the eager forward, because Dynamo would specialise a dimension of size 1 into its own graph.Both model copies (
funasr/models/fun_asr_nano/model.pyand the recipe'sexamples/industrial_data_pretraining/fun_asr_nano/model.py, loaded bytrust_remote_code=true) call one helper,funasr/models/fun_asr_nano/llm_forward_opts.py, installed by bound-method assignment onllm.model.forward(module tree and state_dict keys unchanged). When both switches are on, the SDPA context wraps the compiled call.docs/finetune.mddocuments both keys.Result
Measured on 1 × B200; not measured elsewhere.
finetune.shas shipped (LLM fine-tuning, encoder and adaptor frozen; on this branch that includes its two new++llm_conf.*lines), 3600 AISHELL-1 utterances = 382 steps, seed 1234, two interleaved runs per arm against the unmodified486b4b7ceon the same setup, nothing set in the environment:main)finetune.shoverrides onsdpa_backendsalone (two runs, same day and setup)torch_compileon topRun-to-run spread: 4.9 ms on the baseline mean, 0.9 ms on this branch; no recompile in 382 steps. With the defaults (no overrides) this branch is the baseline computation: nothing is installed on the model.
Type of change
Validation
Measured on 1 × B200 against the unmodified
486b4b7ce, both arms withfinetune.sh's own flags and nothing set in the environment; full command under Details.python -m pytest tests/test_fun_asr_nano_train_opts.py— 17 passed with a CUDA device, 13 passed / 4 skipped (the CUDA-only cases) withCUDA_VISIBLE_DEVICES=""; both model copies are parametrised through every testpython -m compileall funasr examples teststests/test_fun_asr_nano_lora_injection.py,test_fun_asr_nano_vllm_dtype.py,test_fun_asr_nano_autocast_device.pystill pass (14)examples/industrial_data_pretraining/fun_asr_nano/docs/finetune.md, new "Training-speed options" sectionCorrectness Verification
Recorded with probe on both arms under the same setup (hooks on
TarzanZhao/FunASR:perf/fun-asr-nano-training-speed-verify, this branch plus one commit, not in this PR): 6,128 checkpoints per run, baseline vs this branch with thefinetune.shoverrides on.6,105 of 6,128 checkpoints pass the declared tolerances; 23 do not. The failures are 2 of 382 per-step losses, 2 of 7 gradient norms and 19 argmax-token counts; the differences are at bf16 kernel-rounding scale (the switch moves the attention from cuDNN to the flash / mem-efficient kernels, and Inductor keeps fused intermediates in fp32, so the compiled bf16 forward rounds differently from eager), but they are failures of the gate as declared, not numerical equivalence.
The record of this head (opt-ins through
sdpa_kernelandtorch_compile=true) is identical in all 6,128 values to the record of the previous revision (process-wideenable_cudnn_sdp(False)plus compile on by default), so the scoping changed nothing in what is computed. If the project wants bit-identity with eager cuDNN attention, the recipe should not set the two overrides.User impact
Nobody who does not set the two keys. With the defaults,
FunASRNano.__init__installs nothing and changes no torch setting; the regression tests hold both copies to that. Users offinetune.shon a CUDA GPU get the switches: on sm_90 and sm_100 with a torch that puts cuDNN first in the SDPA order (2.5 and later),sdpa_backendsremoves a plan build of about 1 s on every new batch shape and the two together give the table above on the one B200 measured; on GPUs where torch does not pick cuDNN the SDPA scope changes nothing and the compiled decoder alone gave about 28 % in the previous round. The cost is a one-time compile on the first step (about 1 minute with a warm Inductor cache, 2.5 minutes cold on B200); removing either line fromfinetune.shturns that switch off. The encoder, the adaptor, non-CUDA runs and inference paths are untouched.Notes for reviewers
enable_cudnn_sdp(False)is removed; the choice isllm_conf.sdpa_backends, scoped withsdpa_kernelto this model's forward, andtest_default_llm_conf_leaves_sdpa_flag_and_forward_alone/test_sdpa_backends_scoped_to_forwardassert the flag is unchanged after construction and after a forward. Eager default:torch_compiledefaults toFalse, the compiled graph is used only for inputs on a CUDA device (per call, not host CUDA at construction), andtest_torch_compile_keeps_cpu_decoder_eagercovers the CPU-decoder-on-GPU-host case,test_torch_compile_wraps_cuda_llm_and_keeps_batch_one_eagerthe enabled path; the same tests run against the recipe copy. Timing claims are scoped to the B200 experiment; the out-of-tolerance checkpoints are reported as unmet gates above; the frozen-encoder train/eval policy is not touched.dynamic=Truehandles the varying(batch, length)of token-bucketed batches without recompiles in this run (0 in 382 steps). Batches of exactly one sequence take the eager path on purpose.TORCH_CUDNN_SDPA_DEPRIORITIZED=1in the environment has the same effect assdpa_backends=[flash,efficient,math]for anyone who prefers not to change the recipe (records bit-identical, previous round). The profile behind it is in Fine-tuning Fun-ASR-Nano on a B200: one step in six takes 1 s because cuDNN SDPA builds a plan for every new batch shape #3704.FunASRNanowith the keys set; DeepSpeed (use_deepspeed=true) was not tested.trainer_ds.pycallsmodel.train()each epoch); the SANM encoder is about 2,800 launches per step and would compile too if the dropout question were settled; AdamW keeps bf16 optimizer state for the LLM (fused AdamW changes rounding, so it was left out);reduce-overheadmode re-records a CUDA graph per shape and loses.Details: hardware, model, full command, traces
Hardware. 1 × NVIDIA B200 (sm_100, 183 GB) on an 8-GPU node, driver 580.126.20, CUDA 12.8 (the torch build), cuDNN 9.19, torch 2.11.0+cu128; the process pinned with
numactl --cpunodebind=0 --membind=0to the NUMA node of GPU 0. Weights: the ModelScope snapshot ofFunAudioLLM/Fun-ASR-Nano-2512on local disk; AISHELL-1 wavs on node-local disk.Model. SenseVoiceEncoderSmall (70 SANM layers, 221 M, frozen, fp32 with TF32 matmuls) + Transformer adaptor (12.6 M, frozen) + Qwen3-0.6B (28 layers, 596 M, bf16, trained) + a CTC decoder (39 M, trainable, not called in training); 868.86 M parameters, 635.12 M trainable. AdamW lr 2e-4, 2500 warm-up steps, grad clip 5. Each step is a token-budget batch of 6000 (
speech_length + text_length) with at most 10 utterances: on average 9.6 utterances, 640 LLM tokens and 722 fbank frames, with a new(batch, padded length)pair on about 1 step in 6.Data (once): the recipe's
tools/scp2jsonl.pyon an AISHELL-1wav.scpandtextpair producestrain.jsonlandval.jsonl(docs/finetune.md:15-53); 3600 training utterances (the first 3600 of a seeded sample of 36,000) and 200 dev utterances.The measured job, both arms; the checkout under test goes first on
PYTHONPATHsoimport funasrresolves to it. The two++llm_conf.torch_compile=true ++llm_conf.sdpa_backends=[flash,efficient,math]lines are passed on this branch only (they are the two linesfinetune.shadds; the baseline checkout does not know the keys):trust_remote_code=trueloads the recipe's ownmodel.pyfrom the working directory, which is why the change is mirrored intofunasr/models/fun_asr_nano/model.py.++train_conf.find_unused_parameters=trueis a no-op on one GPU; it is there because the multi-GPU form of the script needs it (the CTC decoder is trainable but unused). The trainer has no max-steps flag, so the run length is the dataset.++seed=1234seeds torch, numpy and random; the batch sampler shuffles withmanual_seed(epoch), so the batch order is fixed.Correctness run. On the verify branch, the same command with
PROBE=1 PROBE_OUT=<dir>/code.json; for the baseline record, check out486b4b7ce, cherry-pick the hooks commit, run into<dir>/base.json;probe compare <dir>/base.rank0.json <dir>/code.rank0.jsonprints every checkpoint against its tolerance. Also swept on the baseline and rejected as noise:OMP_NUM_THREADS8 and 1,PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True,++cudnn_benchmark=true,PYTORCH_NVML_BASED_CUDA_CHECK=1; excluded because they change the data or the numerics:++dataset_conf.num_workers=8,++optim_conf.fused=true. Earlier revisions of this PR: compile alone measured 1.38x (0.0985 to 0.0713 s) against a baseline withTORCH_CUDNN_SDPA_DEPRIORITIZED=1set; the process-wide switch plus compile-on-by-default measured 0.2438 to 0.0680 s (3.59x) on the same setup as this table.Traces (torch.profiler, rank 0, steps 4 to 9 of the unmodified script and steps 9 to 14 of the optimized arm, previous revision, same computation; open in https://ui.perfetto.dev):