Skip to content

Commit c39ff39

Browse files
authored
feat(fun_asr_nano): batched VAD-segment decoding (~1.75x faster) (#2979)
* feat(fun_asr_nano): batched VAD-segment decoding (1.6x faster, lower CER) Fun-ASR-Nano inference raised `NotImplementedError("batch decoding is not implemented")` for more than one input, so `AutoModel.generate(..., batch_size_s=...)` processed VAD segments one at a time (batch_size=1). For the small Qwen3-0.6B decoder this badly underutilizes the GPU. This adds `_inference_llm_batch`: build each segment's `inputs_embeds` via the existing single-sample `inference_prepare`, left-pad them into one batch (with attention mask + position_ids), and run a single `llm.generate`. `inference_llm` routes multi-segment input there. The single-segment path is unchanged. Benchmark (Fun-ASR-Nano-2512, 184 Chinese files / 11,539 s, H100, same VAD): - batch_size=1 (before): RTFx 19.8, CER 10.74% - batch_size_s=120 (this PR): RTFx 31.8, CER 9.23% (avg of repeated runs) ~1.6x faster AND slightly lower CER (batched batch_decode also handles a special-token edge case that errored a few files in the per-segment path). CTC timestamps are not produced in batched mode; use the single-segment path when timestamps are needed. * feat(fun_asr_nano): batched VAD-segment decoding Batch multiple VAD segments into one llm.generate (left-padding + attention_mask + position_ids) for much better GPU utilization of the small LLM decoder. Auto-activates for multi-segment input, but only when no CTC decoder is loaded, so timestamp behavior is preserved. Tested: faster decoding with equal/better CER on the benchmark set. --------- Co-authored-by: LauraGPT <LauraGPT@users.noreply.github.com>
1 parent 8107365 commit c39ff39

1 file changed

Lines changed: 84 additions & 0 deletions

File tree

funasr/models/fun_asr_nano/model.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,83 @@ def inference(
718718
**kwargs,
719719
)
720720

721+
def _inference_llm_batch(self, data_in, data_lengths, key, tokenizer, frontend, **kwargs):
722+
"""Batched LLM decoding for multiple VAD segments at once.
723+
724+
Builds each segment's inputs_embeds via the single-sample
725+
inference_prepare, left-pads them into one batch, and runs a single
726+
llm.generate. This greatly improves GPU utilization for the small LLM
727+
decoder (the per-segment, batch_size=1 path underuses the GPU).
728+
CTC timestamps are not produced in batched mode.
729+
"""
730+
# normalize nested key (e.g. [[k1, k2, ...]]) like the single-sample path
731+
if key is not None and len(key) > 0 and isinstance(key[0], (list, tuple)):
732+
key = list(key[0])
733+
embs = []
734+
keys = []
735+
for i, d in enumerate(data_in):
736+
k_i = [key[i]] if key is not None and i < len(key) else None
737+
emb_i, _c, _b, _s, _m = self.inference_prepare(
738+
[d], data_lengths, k_i, tokenizer, frontend, **kwargs
739+
)
740+
embs.append(emb_i)
741+
keys.append(key[i] if key is not None and i < len(key) else f"rand_{i}")
742+
743+
llm_dtype = kwargs.get("llm_dtype", "fp32")
744+
if llm_dtype == "fp32":
745+
llm_dtype = "fp16" if kwargs.get("fp16", False) else llm_dtype
746+
llm_dtype = "bf16" if kwargs.get("bf16", False) else llm_dtype
747+
dt = dtype_map[llm_dtype]
748+
device = embs[0].device
749+
self.llm = self.llm.to(dt)
750+
751+
B = len(embs)
752+
D = embs[0].shape[-1]
753+
Tmax = max(e.shape[1] for e in embs)
754+
padded = torch.zeros(B, Tmax, D, device=device, dtype=dt)
755+
attn = torch.zeros(B, Tmax, dtype=torch.long, device=device)
756+
for i, e in enumerate(embs):
757+
Ti = e.shape[1]
758+
padded[i, Tmax - Ti :, :] = e[0].to(dt) # left padding
759+
attn[i, Tmax - Ti :] = 1
760+
761+
device_type = torch.device(kwargs.get("device", "cuda")).type
762+
with torch.autocast(
763+
device_type=device_type if device_type in ["cuda", "xpu", "mps"] else "cpu",
764+
enabled=True if llm_dtype != "fp32" else False,
765+
dtype=dt,
766+
):
767+
# left padding requires explicit position_ids so each segment's real
768+
# tokens get positions 0,1,2,... regardless of the padding length.
769+
position_ids = attn.long().cumsum(-1) - 1
770+
position_ids.masked_fill_(attn == 0, 1)
771+
generated_ids = self.llm.generate(
772+
inputs_embeds=padded,
773+
attention_mask=attn,
774+
position_ids=position_ids,
775+
max_new_tokens=kwargs.get("max_length", 512),
776+
pad_token_id=(
777+
self.llm.config.pad_token_id
778+
if self.llm.config.pad_token_id is not None
779+
else self.llm.config.eos_token_id
780+
),
781+
**kwargs.get("llm_kwargs", {}),
782+
)
783+
texts = tokenizer.batch_decode(
784+
generated_ids, skip_special_tokens=kwargs.get("skip_special_tokens", True)
785+
)
786+
results = []
787+
for i, t in enumerate(texts):
788+
t = kwargs.get("prev_text", "") + t
789+
results.append(
790+
{
791+
"key": keys[i],
792+
"text": re.sub(r"\s+", " ", t.replace("/sil", " ")),
793+
"text_tn": re.sub(r"[^\w\s\u3000\u4e00-\u9fff]+", "", t),
794+
}
795+
)
796+
return results, {}
797+
721798
def inference_llm(
722799
self,
723800
data_in,
@@ -737,6 +814,13 @@ def inference_llm(
737814
frontend: Audio frontend for feature extraction.
738815
**kwargs: Additional keyword arguments.
739816
"""
817+
# Only batch when CTC timestamps are not needed; the batched path does not
818+
# produce ctc_timestamps, so fall back to the single-sample path when a CTC
819+
# decoder is loaded (preserves timestamp behavior).
820+
if len(data_in) > 1 and self.ctc_decoder is None:
821+
return self._inference_llm_batch(
822+
data_in, data_lengths, key, tokenizer, frontend, **kwargs
823+
)
740824
inputs_embeds, contents, batch, source_ids, meta_data = self.inference_prepare(
741825
data_in, data_lengths, key, tokenizer, frontend, **kwargs
742826
)

0 commit comments

Comments
 (0)