#!/usr/bin/env python3 # -*- encoding: utf-8 -*- # Copyright FunASR (https://github.com/alibaba-damo-academy/FunASR). All Rights Reserved. # MIT License (https://opensource.org/licenses/MIT) """ Generic vLLM inference wrapper for ALL LLM-based ASR models in FunASR. Applicable models (any model with audio_encoder + adaptor + LLM architecture): - FunASRNano (Fun-ASR-Nano-2512, Fun-ASR-MLT-Nano-2512) - LLMASR (Whisper + Qwen/Vicuna/LLaMA) - GLMASR (GLM-ASR-Nano) NOT applicable (these models don't use autoregressive LLM decoding): - Paraformer (non-autoregressive CIF predictor + attention decoder) - SenseVoice (Whisper-like encoder-decoder, not LLM-based) - Conformer/Transformer ASR (CTC/attention, no LLM) - CT-Transformer (punctuation model, small transformer) - Qwen3-ASR (uses external qwen-asr package with its own optimized inference) Usage: from funasr.auto.auto_model_vllm import AutoModelVLLM # Works for any LLM-based ASR model model = AutoModelVLLM( model="FunAudioLLM/Fun-ASR-Nano-2512", tensor_parallel_size=2, ) results = model.generate(["audio.wav"]) # Also works for LLMASR models model = AutoModelVLLM( model="/path/to/llm_asr_model", tensor_parallel_size=4, ) """ import glob import json import logging import os import re import shutil import time from typing import List, Optional, Union import numpy as np import torch import torch.nn as nn logger = logging.getLogger(__name__) dtype_map = {"bf16": torch.bfloat16, "fp16": torch.float16, "fp32": torch.float32} # Models that use LLM and can benefit from vLLM _LLM_BASED_MODELS = {"FunASRNano", "LLMASR", "LLMASRNAR", "GLMASR", "QwenAudioWarp"} # Models that CANNOT use vLLM (no autoregressive LLM) _NON_LLM_MODELS = { "Paraformer": "Non-autoregressive model using CIF predictor. No LLM decoding.", "SenseVoice": "Whisper-like encoder-decoder. Not LLM-based.", "CTTransformer": "Small punctuation model. No benefit from vLLM.", "Conformer": "CTC/attention encoder-decoder. No LLM.", "Qwen3ASR": "Uses external qwen-asr package with optimized inference.", } def check_vllm_applicable(model_name: str) -> bool: """Check if a model can use vLLM inference. Args: model_name: The model class name from config.yaml. Returns: True if vLLM is applicable. Raises: ValueError: If model explicitly cannot use vLLM, with explanation. """ if model_name in _LLM_BASED_MODELS: return True for non_llm, reason in _NON_LLM_MODELS.items(): if non_llm in model_name: raise ValueError( f"Model '{model_name}' cannot use vLLM: {reason}\n" f"vLLM only accelerates autoregressive LLM decoding. " f"Use the standard FunASR AutoModel for this model." ) return False def prepare_vllm_weights(model_dir: str, output_dir: str = None) -> str: """Extract LLM weights from model.pt into vLLM-compatible format. Works for any model that stores LLM weights with 'llm.' prefix in model.pt and has a config directory (e.g., Qwen3-0.6B/) with model config and tokenizer. Args: model_dir: Path to the FunASR model directory. output_dir: Where to save extracted weights. Auto-detected if None. Returns: Path to vLLM-ready model directory. """ if output_dir is None: # Find the LLM config directory from omegaconf import OmegaConf config_path = os.path.join(model_dir, "config.yaml") if os.path.exists(config_path): config = OmegaConf.load(config_path) llm_conf = OmegaConf.to_container(config.get("llm_conf", {}), resolve=True) llm_path = llm_conf.get("init_param_path", "") if llm_path and not os.path.isabs(llm_path): llm_path = os.path.join(model_dir, llm_path) if os.path.isdir(llm_path): output_dir = llm_path + "-vllm" else: output_dir = os.path.join(model_dir, "llm-vllm") else: output_dir = os.path.join(model_dir, "llm-vllm") # Check if already prepared if glob.glob(os.path.join(output_dir, "*.safetensors")) or glob.glob( os.path.join(output_dir, "model*.bin") ): logger.info(f"vLLM weights already at {output_dir}") return output_dir os.makedirs(output_dir, exist_ok=True) # Find and copy LLM config/tokenizer files from omegaconf import OmegaConf config = OmegaConf.load(os.path.join(model_dir, "config.yaml")) llm_conf = OmegaConf.to_container(config.get("llm_conf", {}), resolve=True) llm_config_dir = llm_conf.get("init_param_path", "") if llm_config_dir and not os.path.isabs(llm_config_dir): llm_config_dir = os.path.join(model_dir, llm_config_dir) if os.path.isdir(llm_config_dir): for fname in os.listdir(llm_config_dir): src = os.path.join(llm_config_dir, fname) dst = os.path.join(output_dir, fname) if os.path.isfile(src) and not os.path.exists(dst): shutil.copy2(src, dst) # Extract LLM weights from model.pt model_pt = os.path.join(model_dir, "model.pt") if not os.path.exists(model_pt): raise FileNotFoundError(f"model.pt not found at {model_pt}") logger.info(f"Extracting LLM weights from {model_pt}...") checkpoint = torch.load(model_pt, map_location="cpu") state_dict = checkpoint.get("state_dict", checkpoint) llm_state = {} for key, value in state_dict.items(): if key.startswith("llm."): llm_state[key[4:]] = value # Remove 'llm.' prefix if not llm_state: raise RuntimeError("No LLM weights found (expected 'llm.*' prefix)") logger.info(f"Extracted {len(llm_state)} LLM weight tensors") try: from safetensors.torch import save_file save_path = os.path.join(output_dir, "model.safetensors") save_file(llm_state, save_path) index = { "metadata": {"total_size": sum(v.numel() * v.element_size() for v in llm_state.values())}, "weight_map": {k: "model.safetensors" for k in llm_state.keys()}, } with open(os.path.join(output_dir, "model.safetensors.index.json"), "w") as f: json.dump(index, f, indent=2) except ImportError: torch.save(llm_state, os.path.join(output_dir, "model.bin")) return output_dir class AutoModelVLLM: """Generic vLLM wrapper for LLM-based ASR models. Automatically detects model architecture, extracts LLM weights, loads audio components in PyTorch, and uses vLLM for generation. Works for: FunASRNano, LLMASR, GLMASR, and any model with audio_encoder + audio_adaptor + LLM architecture. Args: model: Model name (hub) or local directory path. hub: "ms" (ModelScope) or "hf" (HuggingFace). device: Device for audio encoder/adaptor. dtype: Compute dtype ("bf16", "fp16", "fp32"). tensor_parallel_size: GPUs for vLLM tensor parallelism. gpu_memory_utilization: GPU memory fraction for vLLM. max_model_len: Maximum sequence length. Example: >>> model = AutoModelVLLM(model="FunAudioLLM/Fun-ASR-Nano-2512") >>> results = model.generate(["audio.wav"], language="中文") """ def __init__( self, model: str, hub: str = "ms", device: str = "cuda:0", dtype: str = "bf16", tensor_parallel_size: int = 1, gpu_memory_utilization: float = 0.8, max_model_len: int = 4096, enforce_eager: bool = False, **kwargs, ): # Resolve model directory if os.path.isdir(model): self.model_dir = model else: if hub in ("ms", "modelscope"): from modelscope.hub.snapshot_download import snapshot_download self.model_dir = snapshot_download(model, revision=kwargs.get("revision", "master")) elif hub in ("hf", "huggingface"): from huggingface_hub import snapshot_download self.model_dir = snapshot_download(model) else: raise ValueError(f"Unsupported hub: {hub}") # Check model type from omegaconf import OmegaConf config = OmegaConf.load(os.path.join(self.model_dir, "config.yaml")) self.model_type = config.get("model", "unknown") check_vllm_applicable(self.model_type) self.device = device self.dtype = dtype self.torch_dtype = dtype_map.get(dtype, torch.bfloat16) # Use the specialized implementation if available if self.model_type == "FunASRNano": from funasr.models.fun_asr_nano.inference_vllm import FunASRNanoVLLM self._engine = FunASRNanoVLLM( model_dir=self.model_dir, device=device, dtype=dtype, tensor_parallel_size=tensor_parallel_size, gpu_memory_utilization=gpu_memory_utilization, max_model_len=max_model_len, enforce_eager=enforce_eager, **kwargs, ) elif self.model_type in ("GLMASR", "glmasr"): from funasr.models.glm_asr.inference_vllm import GLMASRVLLMEngine self._engine = GLMASRVLLMEngine( model_dir=self.model_dir, device=device, dtype=dtype, tensor_parallel_size=tensor_parallel_size, gpu_memory_utilization=gpu_memory_utilization, max_model_len=max_model_len, **kwargs, ) elif self.model_type in ("LLMASR", "LLMASRNAR"): self._engine = self._build_llmasr_engine( config, tensor_parallel_size, gpu_memory_utilization, max_model_len, enforce_eager, **kwargs, ) else: # Generic fallback using the FunASRNano approach # (works for any model with audio_encoder + adaptor + LLM) from funasr.models.fun_asr_nano.inference_vllm import FunASRNanoVLLM self._engine = FunASRNanoVLLM( model_dir=self.model_dir, device=device, dtype=dtype, tensor_parallel_size=tensor_parallel_size, gpu_memory_utilization=gpu_memory_utilization, max_model_len=max_model_len, enforce_eager=enforce_eager, **kwargs, ) def _build_llmasr_engine(self, config, tensor_parallel_size, gpu_memory_utilization, max_model_len, enforce_eager, **kwargs): """Build vLLM engine for LLMASR models (Whisper + Qwen/Vicuna).""" from funasr.models.fun_asr_nano.inference_vllm import FunASRNanoVLLM # LLMASR follows same pattern as FunASRNano return FunASRNanoVLLM( model_dir=self.model_dir, device=self.device, dtype=self.dtype, tensor_parallel_size=tensor_parallel_size, gpu_memory_utilization=gpu_memory_utilization, max_model_len=max_model_len, enforce_eager=enforce_eager, **kwargs, ) def generate(self, inputs, **kwargs): """Run ASR inference. Args: inputs: Audio file path(s), numpy arrays, or tensors. **kwargs: Model-specific parameters (language, hotwords, etc.) Returns: List of result dicts with "key" and "text" fields. """ self._warn_if_audio_too_long(inputs) return self._engine.generate(inputs, **kwargs) def _warn_if_audio_too_long(self, inputs, max_safe_sec=40.0): """Warn (once) if a single audio input is long enough to be truncated. Fun-ASR-Nano is a segment-level (LLM-)ASR model. Decoding very long audio in a single pass can silently truncate or degrade the output -- the decode hits ``max_new_tokens`` long before the audio ends, so the user gets a partial transcript with no error. The right usage is to pre-segment with VAD; this warning points users there instead of letting them get a silently truncated result. It does not change the output. """ if getattr(self, "_warned_audio_too_long", False): return items = inputs if isinstance(inputs, (list, tuple)) else [inputs] for item in items: duration = None try: if isinstance(item, str): import soundfile as sf duration = sf.info(item).duration elif isinstance(item, np.ndarray): duration = item.shape[-1] / 16000.0 elif isinstance(item, torch.Tensor): duration = item.shape[-1] / 16000.0 except Exception: continue if duration is not None and duration > max_safe_sec: logger.warning( "AutoModelVLLM received a %.0fs audio input. Fun-ASR-Nano is a " "segment-level model; decoding very long audio in a single pass can " "truncate or degrade the result. Pre-segment with VAD and pass the " "segments, or use the high-level `funasr.AutoModel(model=..., " 'vad_model="fsmn-vad")`, which segments long audio automatically.', duration, ) self._warned_audio_too_long = True break @classmethod def supported_models(cls): """Return dict of model types and their vLLM support status.""" info = {} for m in _LLM_BASED_MODELS: info[m] = {"supported": True, "reason": "LLM-based, autoregressive generation"} for m, reason in _NON_LLM_MODELS.items(): info[m] = {"supported": False, "reason": reason} return info