[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
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
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.
There was a problem hiding this comment.
Checked revision 2c1a0c8: the helper docstring, both model comments, training guide and PR description now accurately state that the SDPA flags are temporarily process-wide and warn against concurrent shared-process use. This addresses the wording/concurrency-boundary request in this thread.
I also reran the bounded exact-helper check with real PyTorch 2.11.0+cu128, CUDA hidden and CPU decoder stand-ins: defaults leave forward and flags untouched; explicit compile with CPU input uses eager forward; an Event-held math-only forward changes the flags observed by another thread, then restores them on return. All assertions passed. The new revision also simplifies the backend parser and reduces the submitted tests, so this is not full-constructor, combined-option, CUDA compilation/backward or B200 numerical/performance acceptance. I have not independently run the submitted constructor/GPU suite or verified the reported training tolerances. No model weights were loaded.
8482dd2 to
2c1a0c8
Compare
|
The latest body edit at unchanged head 2c1a0c8 introduces two broader claims than the evidence supports:
The table still shows the numerical threshold exceedances, which is useful. Please also retain an explicit statement that 23/6,128 checks fail the predeclared tolerances and numerical equivalence has not passed. Attributing the discrepancies to bf16 rounding does not turn those failures into accepted correctness gates without a justified acceptance decision. My previous reply confirms only the corrected SDPA-scope wording and bounded CPU-helper behavior, not independent acceptance of the full constructor/GPU tests or training numerics/performance. This comment reviews the body change only; the code head is unchanged, so I did not repeat the previous tests. |
|
@LauraGPT Hi, thanks for the careful comments. I think I have fixed these problems and the pr description. I just make this PR ready to review. |
|
@TarzanZhao I checked the revised description and the transition to ready for review at head The three description points in my previous comment are now addressed: the measured speedup is limited to the single B200, the opt-in keys are correctly described as available to callers of both constructors, and the 23/6,128 failures against the predeclared tolerances are explicitly retained, with the rounding explanation marked as unverified. The source head has not changed since the previous scoped CPU-helper check, so I have not rerun that unchanged probe. That check is not independent validation of the full constructor suite, CUDA compilation/backward, B200 performance, or numerical equivalence. In particular, the reported numerical gate remains unmet; making the PR ready for review does not itself resolve that acceptance question. This acknowledges the corrected description, not merge approval. |
Summary
When I fine-tuned Fun-ASR-Nano with
finetune.shon one B200, a training step took 0.244 s on average, and two causes accounted for most of that time. First, torch sends the Qwen3 attention to cuDNN, which builds a new execution plan whenever the batch shape changes; that happened on 59 of 382 steps and cost about 1 s each. Second, on the other steps the GPU was mostly idle, waiting for the CPU to launch 8,080 small kernels that together ran for only 37 ms. This PR adds two opt-in switches underllm_conf, both turned on infinetune.sh:sdpa_backendskeeps the attention off cuDNN, andtorch_compilecompiles the decoder so it launches far fewer kernels. On that B200 the mean step drops to 0.068 s (3.6x); I have not measured other GPUs. The two switches:llm_conf.sdpa_backends. On the B200 (sm_100) I measured, torch sends the Qwen3 attention to cuDNN because it carries a padding mask, and cuDNN rebuilds its execution plan for every new batch shape, about 1 s each, on one step in six. Setting it to[flash, efficient, math]runs the decoder forward undersdpa_kernelwith those backends, which need no per-shape plans. The flags are process-wide while the forward runs and are restored on return, so leave it unset when several models share one process.llm_conf.torch_compile. Once the plan builds are gone, the step is launch-bound: 8,080 kernels for 37 ms of GPU work, 2,900 of them in the decoder. Setting it wraps the decoder stack intorch.compile(dynamic=True), which halves the launch count. Inputs on the CPU and single-sequence batches take the eager path, and the first step pays a one-time compile.Speedup Result
Measured on one B200.
main)finetune.shoverrides onsdpa_backendsalone (two runs, same day and setup)torch_compileon topType of change
Validation
Measured on 1 × B200 against the unmodified
486b4b7ce:finetune.shas shipped (on this branch, with its two new++llm_conf.*lines), 3600 AISHELL-1 utterances = 382 steps, seed 1234, two interleaved runs per arm, nothing set in the environment; full command under Details.python -m pytest tests/test_fun_asr_nano_train_opts.py: 4 cases x 2 model copies: 8 passed with a CUDA device, 6 passed / 2 skipped (the CUDA-only case) withCUDA_VISIBLE_DEVICES=""python -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
I trained the baseline and this branch on the same 382 batches and compared 6,128 values from each run: per-step loss, logit statistics and correct-token counts, parameter and gradient norms at seven steps, the final validation loss, and every batch shape. The tolerances in the table below were set before the runs. The recording code is on the
perf/fun-asr-nano-training-speed-verifybranch of my fork, not in this PR.23 of the 6,128 checks fail the tolerances set before the runs, so numerical equivalence has not been shown. The 23 are two per-step losses at 0.0128 and 0.0115 against a 0.01 limit, two gradient norms at 2 % against 1 %, and a one-token change in the correct-token count on 11 steps. The other 6,105 are within tolerance, and the final validation loss is 0.101834 on the baseline and 0.101967 on this branch with identical accuracy. My guess is bf16 rounding, since the flash and memory-efficient kernels and the compiled decoder round differently from eager cuDNN, but I have not verified that, and it does not make the failures acceptable by itself.
User impact
With the defaults the model is built exactly as before, and the new tests check that for both model copies. The two keys are read by both model constructors, so any caller that sets them gets the switches;
finetune.shis the recipe that sets them. The speedup above was measured on one B200 only. The cost is a one-time compile on the first step, about a minute with a warm Inductor cache and 2.5 minutes cold on that B200. Removing either key turns that switch off.Notes for reviewers
TORCH_CUDNN_SDPA_DEPRIORITIZED=1gives the same result assdpa_backendswithout editing the recipe. The profile 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.use_deepspeed=true) and more than one GPU.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):