Skip to content

Commit 2576a51

Browse files
committed
merge vllm: fix use_low_frame_rate (CER 8.91% matches PyTorch), add benchmark
2 parents 31119ec + 5363779 commit 2576a51

2 files changed

Lines changed: 263 additions & 15 deletions

File tree

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
#!/usr/bin/env python3
2+
"""Fun-ASR-Nano vLLM Benchmark: Speed + CER comparison.
3+
4+
Usage:
5+
# Full benchmark (PyTorch + vLLM + CER)
6+
CUDA_VISIBLE_DEVICES=0 python benchmark_vllm.py \
7+
--audio-dir /path/to/benchmark_audio \
8+
--label-json /path/to/benchmark_testset.json
9+
10+
# Quick test (first 20 files)
11+
CUDA_VISIBLE_DEVICES=0 python benchmark_vllm.py \
12+
--audio-dir /path/to/benchmark_audio \
13+
--label-json /path/to/benchmark_testset.json \
14+
--max-files 20
15+
"""
16+
17+
import argparse
18+
import json
19+
import os
20+
import re
21+
import time
22+
23+
import kaldialign
24+
import numpy as np
25+
import soundfile as sf
26+
import torch
27+
28+
29+
def normalize_zh(text):
30+
"""Remove punctuation for CER calculation."""
31+
text = re.sub(r'[^\w一-鿿]', '', text)
32+
return text.upper()
33+
34+
35+
def compute_cer(refs, hyps):
36+
"""Compute CER between reference and hypothesis lists."""
37+
total_ref = 0
38+
total_errs = 0
39+
for ref, hyp in zip(refs, hyps):
40+
r = list(normalize_zh(ref))
41+
h = list(normalize_zh(hyp))
42+
total_ref += len(r)
43+
ali = kaldialign.align(r, h, '*')
44+
total_errs += sum(1 for a, b in ali if a != b)
45+
return total_errs / total_ref * 100 if total_ref > 0 else 0
46+
47+
48+
def vad_segment(files, device="cuda:0"):
49+
"""Pre-segment all files with fsmn-vad."""
50+
from funasr import AutoModel
51+
52+
vad_model = AutoModel(model="fsmn-vad", device=device, disable_update=True)
53+
all_segments = []
54+
for fi, wav_path in enumerate(files):
55+
audio, sr = sf.read(wav_path)
56+
if audio.ndim > 1:
57+
audio = audio[:, 0]
58+
audio = audio.astype(np.float32)
59+
res = vad_model.generate(input=wav_path, dynamic_silence=False)
60+
segs = res[0]["value"]
61+
for seg in segs:
62+
s0 = int(seg[0] * sr / 1000)
63+
s1 = int(seg[1] * sr / 1000)
64+
seg_audio = audio[s0:s1]
65+
if len(seg_audio) > sr * 0.5:
66+
all_segments.append((fi, seg[0], seg[1], seg_audio))
67+
return all_segments
68+
69+
70+
def concat_results(all_segments, seg_texts, n_files):
71+
"""Concatenate segment texts back to per-file results."""
72+
file_texts = {}
73+
for (fi, _, _, _), text in zip(all_segments, seg_texts):
74+
if fi not in file_texts:
75+
file_texts[fi] = []
76+
file_texts[fi].append(text)
77+
return ["".join(file_texts.get(fi, [])) for fi in range(n_files)]
78+
79+
80+
def run_pytorch(seg_files, device="cuda:0"):
81+
"""PyTorch native inference."""
82+
from funasr import AutoModel
83+
84+
model = AutoModel(
85+
model="FunAudioLLM/Fun-ASR-Nano-2512", trust_remote_code=True,
86+
remote_code=os.path.join(os.path.dirname(__file__), "model.py"),
87+
device=device, disable_update=True,
88+
)
89+
model.generate(input=seg_files[0]) # warmup
90+
91+
t0 = time.perf_counter()
92+
texts = []
93+
for f in seg_files:
94+
res = model.generate(input=f)
95+
texts.append(res[0]["text"])
96+
t1 = time.perf_counter()
97+
return t1 - t0, texts
98+
99+
100+
def run_vllm(seg_files, device="cuda:0"):
101+
"""Our FunASRNanoVLLM batch inference."""
102+
from funasr.models.fun_asr_nano.inference_vllm import FunASRNanoVLLM
103+
104+
engine = FunASRNanoVLLM.from_pretrained(
105+
model="FunAudioLLM/Fun-ASR-Nano-2512", device=device, dtype="bf16",
106+
max_model_len=4096, gpu_memory_utilization=0.5,
107+
)
108+
engine.generate(inputs=[seg_files[0]], language="中文") # warmup
109+
110+
t0 = time.perf_counter()
111+
results = engine.generate(inputs=seg_files, language="中文", max_new_tokens=500)
112+
t1 = time.perf_counter()
113+
texts = [r["text"] for r in results]
114+
return t1 - t0, texts
115+
116+
117+
if __name__ == '__main__':
118+
parser = argparse.ArgumentParser(description="Fun-ASR-Nano vLLM Benchmark")
119+
parser.add_argument("--audio-dir", type=str, required=True)
120+
parser.add_argument("--label-json", type=str, required=True)
121+
parser.add_argument("--device", type=str, default="cuda:0")
122+
parser.add_argument("--max-files", type=int, default=0, help="Limit files (0=all)")
123+
parser.add_argument("--skip-pytorch", action="store_true")
124+
args = parser.parse_args()
125+
126+
with open(args.label_json) as f:
127+
dataset = json.load(f)
128+
129+
files = []
130+
refs = []
131+
for item in dataset:
132+
wav_path = os.path.join(args.audio_dir, f"{item['id']:03d}.wav")
133+
if os.path.exists(wav_path):
134+
files.append(wav_path)
135+
refs.append(item["ref"])
136+
if args.max_files > 0:
137+
files = files[:args.max_files]
138+
refs = refs[:args.max_files]
139+
140+
total_audio = sum(sf.info(f).duration for f in files)
141+
print(f"Dataset: {len(files)} files, {total_audio:.0f}s audio")
142+
143+
# VAD pre-segment
144+
print("\n>>> VAD pre-segmenting...")
145+
all_segments = vad_segment(files, device=args.device)
146+
print(f" {len(all_segments)} segments (avg {sum(len(s[3])/16000 for s in all_segments)/len(all_segments):.1f}s)")
147+
148+
# Save segments
149+
os.makedirs("/tmp/benchmark_segs", exist_ok=True)
150+
seg_files = []
151+
for i, (fi, s, e, audio) in enumerate(all_segments):
152+
path = f"/tmp/benchmark_segs/{i:04d}.wav"
153+
sf.write(path, audio, 16000)
154+
seg_files.append(path)
155+
156+
# PyTorch
157+
if not args.skip_pytorch:
158+
print("\n>>> PyTorch native...")
159+
pt_time, pt_seg_texts = run_pytorch(seg_files, device=args.device)
160+
pt_texts = concat_results(all_segments, pt_seg_texts, len(files))
161+
cer_pt = compute_cer(refs, pt_texts)
162+
print(f" Time: {pt_time:.1f}s | RTFx: {total_audio/pt_time:.1f} | CER: {cer_pt:.2f}%")
163+
164+
del torch.cuda.memory_allocated
165+
torch.cuda.empty_cache()
166+
167+
# vLLM
168+
print("\n>>> vLLM (FunASRNanoVLLM batch)...")
169+
vllm_time, vllm_seg_texts = run_vllm(seg_files, device=args.device)
170+
vllm_texts = concat_results(all_segments, vllm_seg_texts, len(files))
171+
cer_vllm = compute_cer(refs, vllm_texts)
172+
print(f" Time: {vllm_time:.1f}s | RTFx: {total_audio/vllm_time:.1f} | CER: {cer_vllm:.2f}%")
173+
174+
# Summary
175+
print(f"\n{'='*60}")
176+
print(f"BENCHMARK RESULTS ({len(files)} files, {total_audio:.0f}s)")
177+
print(f"{'-'*60}")
178+
print(f"{'Method':<20} {'Time':<10} {'RTFx':<10} {'CER'}")
179+
print(f"{'-'*60}")
180+
if not args.skip_pytorch:
181+
print(f"{'PyTorch':<20} {pt_time:<10.1f} {total_audio/pt_time:<10.1f} {cer_pt:.2f}%")
182+
print(f"{'vLLM (ours)':<20} {vllm_time:<10.1f} {total_audio/vllm_time:<10.1f} {cer_vllm:.2f}%")
183+
if not args.skip_pytorch:
184+
print(f"{'-'*60}")
185+
print(f"Speedup: {total_audio/vllm_time / (total_audio/pt_time):.1f}x | CER diff: {cer_vllm - cer_pt:+.2f}%")
186+
print(f"{'='*60}")

funasr/models/fun_asr_nano/inference_vllm.py

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,17 @@ def _encode_audio(self, audio_input: Union[str, torch.Tensor, np.ndarray]):
406406
encoder_out, encoder_out_lens = self.audio_encoder(speech, speech_lengths)
407407
encoder_out_for_adaptor = encoder_out.to(dtype=self.torch_dtype)
408408
adaptor_out, adaptor_out_lens = self.audio_adaptor(encoder_out_for_adaptor, encoder_out_lens)
409+
410+
# Apply low frame rate: compute effective token count from fbank length
411+
# Matches PyTorch model.py data_load_speech formula exactly
412+
if self.use_low_frame_rate:
413+
for i in range(adaptor_out.shape[0]):
414+
fbank_len = speech_lengths[i].item()
415+
olens = 1 + (fbank_len - 3 + 2 * 1) // 2
416+
olens = 1 + (olens - 3 + 2 * 1) // 2
417+
fake_token_len = (olens - 1) // 2 + 1
418+
adaptor_out_lens[i] = fake_token_len
419+
409420
return adaptor_out, adaptor_out_lens, encoder_out, encoder_out_lens
410421

411422
def _build_prompt_text(
@@ -450,12 +461,12 @@ def _build_input_embeds(
450461
"""
451462
prompt = self._build_prompt_text(hotwords, language, itn)
452463

453-
# ChatML format
464+
# ChatML format with speech markers and thinking prefix
454465
prefix_text = (
455466
f"<|im_start|>system\n{system_prompt}<|im_end|>\n"
456467
f"<|im_start|>user\n{prompt}<|startofspeech|>"
457468
)
458-
suffix_text = "<|endofspeech|><|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n"
469+
suffix_text = "<|endofspeech|><|im_end|>\n<|im_start|>assistant\n"
459470

460471
# Tokenize
461472
prefix_ids = self.tokenizer.encode(prefix_text, add_special_tokens=False)
@@ -522,21 +533,67 @@ def generate(
522533
skip_special_tokens=True,
523534
)
524535

525-
# Encode all audio samples and build embedding prompts
536+
# Batch encode audio and build embedding prompts
526537
prompts = []
527538
encoder_outputs = []
528539

529540
t0 = time.perf_counter()
530-
for audio_input in inputs:
531-
adaptor_out, adaptor_out_lens, encoder_out, encoder_out_lens = self._encode_audio(
532-
audio_input
533-
)
534-
encoder_outputs.append((encoder_out, encoder_out_lens))
535541

536-
input_embeds = self._build_input_embeds(
537-
adaptor_out, adaptor_out_lens, hotwords, language, itn
542+
# Pre-compute text embeddings (shared across batch)
543+
prompt_text = self._build_prompt_text(hotwords, language, itn)
544+
prefix_text = f"<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n<|im_start|>user\n{prompt_text}"
545+
suffix_text = "<|im_end|>\n<|im_start|>assistant\n"
546+
prefix_ids = self.tokenizer.encode(prefix_text, add_special_tokens=False)
547+
suffix_ids = self.tokenizer.encode(suffix_text, add_special_tokens=False)
548+
prefix_emb = self.embed_tokens(torch.tensor(prefix_ids, dtype=torch.long, device=self.device))
549+
suffix_emb = self.embed_tokens(torch.tensor(suffix_ids, dtype=torch.long, device=self.device))
550+
551+
# Batch encode audio (groups of 8 for memory efficiency)
552+
batch_size_enc = 8
553+
all_adaptor_outs = []
554+
all_adaptor_lens = []
555+
for i in range(0, len(inputs), batch_size_enc):
556+
batch_inputs = inputs[i:i+batch_size_enc]
557+
# Load and extract fbank for batch
558+
from funasr.utils.load_utils import load_audio_text_image_video, extract_fbank
559+
audio_tensors = []
560+
for audio_input in batch_inputs:
561+
if isinstance(audio_input, str):
562+
data_src = load_audio_text_image_video(audio_input, fs=self.frontend.fs)
563+
elif isinstance(audio_input, np.ndarray):
564+
data_src = torch.from_numpy(audio_input).float()
565+
elif isinstance(audio_input, torch.Tensor):
566+
data_src = audio_input.float()
567+
else:
568+
raise ValueError(f"Unsupported audio input type: {type(audio_input)}")
569+
audio_tensors.append(data_src)
570+
571+
speech, speech_lengths = extract_fbank(
572+
audio_tensors, data_type="sound", frontend=self.frontend, is_final=True
538573
)
539-
# vLLM EmbedsPrompt expects float32 or the model's dtype
574+
speech = speech.to(self.device, dtype=torch.float32)
575+
speech_lengths = speech_lengths.to(self.device)
576+
577+
with torch.no_grad():
578+
enc_out, enc_lens = self.audio_encoder(speech, speech_lengths)
579+
adp_out, adp_lens = self.audio_adaptor(enc_out.to(dtype=self.torch_dtype), enc_lens)
580+
581+
# Apply low frame rate token length correction
582+
if self.use_low_frame_rate:
583+
for j in range(len(batch_inputs)):
584+
fbank_len = speech_lengths[j].item()
585+
olens = 1 + (fbank_len - 3 + 2 * 1) // 2
586+
olens = 1 + (olens - 3 + 2 * 1) // 2
587+
adp_lens[j] = (olens - 1) // 2 + 1
588+
589+
for j in range(len(batch_inputs)):
590+
all_adaptor_outs.append(adp_out[j, :adp_lens[j], :])
591+
all_adaptor_lens.append(adp_lens[j])
592+
encoder_outputs.append((enc_out[j:j+1, :enc_lens[j], :], enc_lens[j:j+1]))
593+
594+
# Build prompts
595+
for audio_emb in all_adaptor_outs:
596+
input_embeds = torch.cat([prefix_emb, audio_emb, suffix_emb], dim=0)
540597
prompts.append(EmbedsPrompt(prompt_embeds=input_embeds.float()))
541598

542599
t1 = time.perf_counter()
@@ -551,10 +608,15 @@ def generate(
551608
# Process results
552609
results = []
553610
for i, output in enumerate(outputs):
554-
text = output.outputs[0].text
555-
if not text and output.outputs[0].token_ids:
556-
text = self.tokenizer.decode(list(output.outputs[0].token_ids), skip_special_tokens=True)
557-
text_clean = re.sub(r"\s+", " ", text.replace("/sil", " ")).strip()
611+
token_ids = list(output.outputs[0].token_ids)
612+
text = self.tokenizer.decode(token_ids, skip_special_tokens=True)
613+
# Clean vLLM artifacts: remove garbage prefix/tags
614+
text = re.sub(r'<[^>]*>', '', text)
615+
text = re.sub(r'\[[^\]]*\]', '', text)
616+
text = re.sub(r'endofpatch|/sil|FFFF|</strong>', '', text)
617+
# Strip non-CJK/non-alnum prefix garbage
618+
text = re.sub(r'^[^\w一-鿿]+', '', text)
619+
text_clean = re.sub(r"\s+", " ", text).strip()
558620

559621
key = (
560622
os.path.splitext(os.path.basename(inputs[i]))[0]

0 commit comments

Comments
 (0)