6116b1f3c6
Update API Documentation / build-api-docs (push) Has been cancelled
Add complete FunASR codebase including models, runtime, and documentation.
352 lines
14 KiB
Python
352 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""GLM-ASR vLLM inference engine.
|
|
|
|
Architecture: audio_tower (Whisper-like) + multi_modal_projector + language_model (Llama)
|
|
Strategy: audio_tower + projector in PyTorch, language_model in vLLM via EmbedsPrompt.
|
|
|
|
Usage:
|
|
from funasr.models.glm_asr.inference_vllm import GLMASRVLLMEngine
|
|
|
|
engine = GLMASRVLLMEngine.from_pretrained("zai-org/GLM-ASR-Nano-2512")
|
|
results = engine.generate(inputs=["audio.wav"])
|
|
print(results[0]["text"])
|
|
"""
|
|
|
|
import glob
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import time
|
|
|
|
import numpy as np
|
|
import torch
|
|
|
|
logger = logging.getLogger(__name__)
|
|
dtype_map = {"bf16": torch.bfloat16, "fp16": torch.float16, "fp32": torch.float32}
|
|
|
|
# Warn only once per process so batch loops do not spam the log.
|
|
_warned_rep_penalty = False
|
|
|
|
|
|
def _safe_repetition_penalty(repetition_penalty):
|
|
"""Force ``repetition_penalty`` to the neutral value for prompt-embeds mode.
|
|
|
|
GLM-ASR feeds vLLM precomputed embeddings (``enable_prompt_embeds=True``), so
|
|
a request carries no prompt token IDs. vLLM applies ``repetition_penalty`` by
|
|
scattering over those IDs, so any value other than 1.0 indexes an empty
|
|
token-id tensor and aborts the engine with a CUDA
|
|
``scatter gather index out of bounds`` assertion (issue #2948). We therefore
|
|
warn once and fall back to the neutral value of 1.0.
|
|
"""
|
|
global _warned_rep_penalty
|
|
|
|
if repetition_penalty is None or repetition_penalty == 1.0:
|
|
return 1.0
|
|
|
|
if not _warned_rep_penalty:
|
|
logger.warning(
|
|
"repetition_penalty=%s is not supported in vLLM prompt-embeds mode "
|
|
"(no prompt token IDs to penalize) and would trigger a CUDA scatter "
|
|
"index-out-of-bounds crash; using repetition_penalty=1.0 instead. "
|
|
"See https://github.com/modelscope/FunASR/issues/2948.",
|
|
repetition_penalty,
|
|
)
|
|
_warned_rep_penalty = True
|
|
return 1.0
|
|
|
|
|
|
def prepare_glmasr_vllm_dir(model_dir: str) -> str:
|
|
"""Extract language_model weights into vLLM-compatible Llama format."""
|
|
output_dir = os.path.join(model_dir, "language_model_vllm")
|
|
|
|
if glob.glob(os.path.join(output_dir, "*.safetensors")):
|
|
logger.info(f"vLLM LM weights already at {output_dir}")
|
|
return output_dir
|
|
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
|
|
from safetensors import safe_open
|
|
from safetensors.torch import save_file
|
|
|
|
st_files = sorted(glob.glob(os.path.join(model_dir, "*.safetensors")))
|
|
lm_state = {}
|
|
for st_file in st_files:
|
|
with safe_open(st_file, framework="pt") as f:
|
|
for key in f.keys():
|
|
if key.startswith("language_model."):
|
|
lm_state[key[len("language_model."):]] = f.get_tensor(key)
|
|
|
|
if not lm_state:
|
|
raise RuntimeError("No language_model weights found in safetensors")
|
|
|
|
logger.info(f"Extracted {len(lm_state)} LM tensors")
|
|
save_file(lm_state, os.path.join(output_dir, "model.safetensors"))
|
|
|
|
with open(os.path.join(model_dir, "config.json")) as f:
|
|
full_config = json.load(f)
|
|
text_config = full_config["text_config"]
|
|
text_config["architectures"] = ["LlamaForCausalLM"]
|
|
text_config["model_type"] = "llama"
|
|
with open(os.path.join(output_dir, "config.json"), "w") as f:
|
|
json.dump(text_config, f, indent=2)
|
|
|
|
for fname in os.listdir(model_dir):
|
|
if "tokenizer" in fname or fname == "generation_config.json":
|
|
src = os.path.join(model_dir, fname)
|
|
dst = os.path.join(output_dir, fname)
|
|
if os.path.isfile(src) and not os.path.exists(dst):
|
|
shutil.copy2(src, dst)
|
|
|
|
index = {
|
|
"metadata": {"total_size": sum(v.numel() * v.element_size() for v in lm_state.values())},
|
|
"weight_map": {k: "model.safetensors" for k in lm_state.keys()},
|
|
}
|
|
with open(os.path.join(output_dir, "model.safetensors.index.json"), "w") as f:
|
|
json.dump(index, f, indent=2)
|
|
|
|
logger.info(f"Saved vLLM LM to {output_dir}")
|
|
return output_dir
|
|
|
|
|
|
# Warn only once per process so batch loops do not spam the log.
|
|
_warned_dup_keys = False
|
|
|
|
|
|
def _dedup_keys(keys):
|
|
"""Make result keys unique while preserving order and first-occurrence names.
|
|
|
|
Each result key is derived from the audio file basename
|
|
(``os.path.splitext(os.path.basename(path))[0]``), so two inputs that live
|
|
in different directories but share a basename -- e.g. ``spk1/segment.wav``
|
|
and ``spk2/segment.wav`` -- both map to ``"segment"``. A downstream
|
|
``{r["key"]: r["text"]}`` mapping (the canonical FunASR result shape) would
|
|
then silently drop all but the last colliding entry, returning fewer
|
|
transcripts than inputs with no error. Appending a deterministic ``_N``
|
|
suffix to later collisions keeps every transcript addressable.
|
|
|
|
Args:
|
|
keys: Result keys in input order.
|
|
|
|
Returns:
|
|
A new list of unique keys, same length and order as ``keys``. The first
|
|
occurrence of each key is preserved unchanged; the n-th repeat becomes
|
|
``"<key>_<n-1>"`` (e.g. ``"seg"`` -> ``"seg"``, ``"seg_1"``, ``"seg_2"``).
|
|
"""
|
|
global _warned_dup_keys
|
|
|
|
seen = set()
|
|
out = []
|
|
collided = False
|
|
for key in keys:
|
|
if key not in seen:
|
|
seen.add(key)
|
|
out.append(key)
|
|
continue
|
|
# Find the first free "<key>_<n>" so a suffixed key cannot itself clash
|
|
# with an existing one (e.g. inputs "seg", "seg_1", "seg").
|
|
collided = True
|
|
n = 1
|
|
candidate = f"{key}_{n}"
|
|
while candidate in seen:
|
|
n += 1
|
|
candidate = f"{key}_{n}"
|
|
seen.add(candidate)
|
|
out.append(candidate)
|
|
|
|
if collided and not _warned_dup_keys:
|
|
logger.warning(
|
|
"Duplicate result keys from audio basenames were made unique with "
|
|
"'_N' suffixes (e.g. two files named 'segment.wav' in different "
|
|
"directories map to the same key); pass distinct filenames if you "
|
|
"rely on the basename as the result key."
|
|
)
|
|
_warned_dup_keys = True
|
|
|
|
return out
|
|
|
|
|
|
class GLMASRVLLMEngine:
|
|
"""GLM-ASR with vLLM backend.
|
|
|
|
Audio tower + projector run in PyTorch on a single device.
|
|
Language model is decoded by vLLM with PagedAttention for high throughput.
|
|
|
|
Args:
|
|
model_dir: Path to GLM-ASR model directory.
|
|
device: Device for audio encoder.
|
|
dtype: Compute dtype ("bf16", "fp16", "fp32").
|
|
tensor_parallel_size: GPUs for vLLM tensor parallelism.
|
|
gpu_memory_utilization: GPU memory fraction for vLLM KV cache.
|
|
max_model_len: Maximum sequence length for vLLM.
|
|
"""
|
|
|
|
def __init__(self, model_dir, device="cuda:0", dtype="bf16",
|
|
tensor_parallel_size=1, gpu_memory_utilization=0.5,
|
|
max_model_len=4096, **kwargs):
|
|
from vllm import LLM
|
|
from transformers import AutoProcessor, AutoConfig, AutoModel as HFAutoModel
|
|
|
|
from funasr.models.glm_asr.vllm_utils import warn_if_degraded_dtype
|
|
|
|
self.device = device
|
|
self.torch_dtype = dtype_map.get(warn_if_degraded_dtype(dtype), torch.bfloat16)
|
|
self.model_dir = model_dir
|
|
|
|
logger.info(f"Loading GLM-ASR audio components from {model_dir}")
|
|
full_model = HFAutoModel.from_pretrained(
|
|
model_dir, dtype=self.torch_dtype, device_map=device, trust_remote_code=True
|
|
)
|
|
full_model.eval()
|
|
|
|
self.audio_tower = full_model.audio_tower
|
|
self.multi_modal_projector = full_model.multi_modal_projector
|
|
self.get_audio_features = full_model.get_audio_features
|
|
self.embed_tokens = full_model.language_model.get_input_embeddings()
|
|
self._full_model_config = full_model.config
|
|
|
|
# Free LM weights from GPU (vLLM loads its own copy)
|
|
del full_model.language_model
|
|
torch.cuda.empty_cache()
|
|
|
|
self.processor = AutoProcessor.from_pretrained(model_dir, trust_remote_code=True)
|
|
|
|
# Prepare and load vLLM engine
|
|
vllm_dir = prepare_glmasr_vllm_dir(model_dir)
|
|
logger.info(f"Initializing vLLM LM from {vllm_dir}")
|
|
self.vllm_engine = LLM(
|
|
model=vllm_dir,
|
|
enable_prompt_embeds=True,
|
|
tensor_parallel_size=tensor_parallel_size,
|
|
gpu_memory_utilization=gpu_memory_utilization,
|
|
max_model_len=max_model_len,
|
|
dtype={"bf16": "bfloat16", "fp16": "float16", "fp32": "auto"}.get(dtype, dtype),
|
|
trust_remote_code=True,
|
|
)
|
|
self.tokenizer = self.vllm_engine.get_tokenizer()
|
|
logger.info("GLM-ASR vLLM engine ready")
|
|
|
|
@torch.no_grad()
|
|
def _encode_audio(self, audio_input):
|
|
"""Encode a single audio input through audio_tower + projector.
|
|
|
|
Returns:
|
|
audio_embeds: (1, T, hidden_size) tensor
|
|
"""
|
|
import librosa
|
|
|
|
if isinstance(audio_input, str):
|
|
audio, _ = librosa.load(audio_input, sr=16000)
|
|
elif isinstance(audio_input, np.ndarray):
|
|
audio = audio_input.astype(np.float32)
|
|
elif isinstance(audio_input, torch.Tensor):
|
|
audio = audio_input.cpu().numpy().astype(np.float32)
|
|
else:
|
|
raise ValueError(f"Unsupported audio type: {type(audio_input)}")
|
|
|
|
inputs = self.processor.feature_extractor(audio, sampling_rate=16000, return_tensors="pt")
|
|
input_features = inputs["input_features"].to(self.device, dtype=self.torch_dtype)
|
|
feat_len = input_features.shape[-1]
|
|
input_features_mask = torch.ones(1, feat_len, dtype=torch.long, device=self.device)
|
|
|
|
audio_outputs = self.get_audio_features(
|
|
input_features, input_features_mask, return_dict=True
|
|
)
|
|
audio_embeds = audio_outputs.pooler_output
|
|
return audio_embeds.unsqueeze(0)
|
|
|
|
def _build_prompt_embeds(self, audio_embeds, prompt="转录以下音频内容"):
|
|
"""Build [prefix_text_emb | audio_emb | suffix_text_emb]."""
|
|
prefix_text = "<|user|>\n<|begin_of_audio|>"
|
|
suffix_text = f"<|end_of_audio|><|user|>\n{prompt}<|assistant|>\n"
|
|
|
|
prefix_ids = self.tokenizer.encode(prefix_text, add_special_tokens=False)
|
|
suffix_ids = self.tokenizer.encode(suffix_text, add_special_tokens=False)
|
|
prefix_emb = self.embed_tokens(torch.tensor(prefix_ids, dtype=torch.long, device=self.device))
|
|
suffix_emb = self.embed_tokens(torch.tensor(suffix_ids, dtype=torch.long, device=self.device))
|
|
|
|
audio_emb = audio_embeds[0] if audio_embeds.dim() == 3 else audio_embeds
|
|
return torch.cat([prefix_emb, audio_emb, suffix_emb], dim=0)
|
|
|
|
def generate(self, inputs, prompt="转录以下音频内容", max_new_tokens=500,
|
|
temperature=0.0, top_p=1.0, top_k=-1, repetition_penalty=1.0,
|
|
**kwargs):
|
|
"""Run batch ASR inference.
|
|
|
|
Args:
|
|
inputs: Audio file path(s), numpy arrays, or tensors.
|
|
prompt: Instruction prompt for ASR.
|
|
max_new_tokens: Maximum tokens to generate per sample.
|
|
temperature: Sampling temperature (0 = greedy decoding).
|
|
top_p: Nucleus sampling parameter.
|
|
top_k: Top-k sampling (-1 = disabled).
|
|
repetition_penalty: Repetition penalty factor. Non-neutral values are
|
|
forced back to 1.0 here because this engine feeds vLLM precomputed
|
|
embeddings (``enable_prompt_embeds=True``); see
|
|
``resolve_repetition_penalty`` and issue #2948.
|
|
|
|
Returns:
|
|
List of {"key": str, "text": str}
|
|
"""
|
|
from vllm import SamplingParams
|
|
try:
|
|
from vllm.inputs import EmbedsPrompt
|
|
except ImportError:
|
|
from vllm.inputs.data import EmbedsPrompt
|
|
|
|
if isinstance(inputs, (str, np.ndarray, torch.Tensor)):
|
|
inputs = [inputs]
|
|
|
|
sampling_params = SamplingParams(
|
|
max_tokens=max_new_tokens,
|
|
temperature=temperature,
|
|
top_p=top_p,
|
|
top_k=top_k if top_k > 0 else -1,
|
|
repetition_penalty=_safe_repetition_penalty(repetition_penalty),
|
|
skip_special_tokens=True,
|
|
)
|
|
|
|
t0 = time.perf_counter()
|
|
prompts = []
|
|
for audio_input in inputs:
|
|
audio_embeds = self._encode_audio(audio_input)
|
|
full_embeds = self._build_prompt_embeds(audio_embeds, prompt=prompt)
|
|
prompts.append(EmbedsPrompt(prompt_embeds=full_embeds.float()))
|
|
t1 = time.perf_counter()
|
|
logger.info(f"Audio encoding: {len(inputs)} samples in {t1-t0:.3f}s")
|
|
|
|
outputs = self.vllm_engine.generate(prompts, sampling_params, use_tqdm=False)
|
|
t2 = time.perf_counter()
|
|
logger.info(f"vLLM generation: {t2-t1:.3f}s")
|
|
|
|
raw_keys = [
|
|
os.path.splitext(os.path.basename(x))[0] if isinstance(x, str) else f"sample_{i}"
|
|
for i, x in enumerate(inputs)
|
|
]
|
|
keys = _dedup_keys(raw_keys)
|
|
|
|
results = []
|
|
for i, output in enumerate(outputs):
|
|
token_ids = list(output.outputs[0].token_ids)
|
|
text = self.tokenizer.decode(token_ids, skip_special_tokens=True)
|
|
text = re.sub(r'\s+', ' ', text).strip()
|
|
results.append({"key": keys[i], "text": text})
|
|
|
|
return results
|
|
|
|
@classmethod
|
|
def from_pretrained(cls, model="zai-org/GLM-ASR-Nano-2512", hub="ms",
|
|
device="cuda:0", dtype="bf16", **kwargs):
|
|
"""Load from hub or local path."""
|
|
if os.path.isdir(model):
|
|
model_dir = model
|
|
else:
|
|
if hub in ("ms", "modelscope"):
|
|
from modelscope.hub.snapshot_download import snapshot_download
|
|
model_dir = snapshot_download(model, revision=kwargs.pop("revision", "master"))
|
|
else:
|
|
from huggingface_hub import snapshot_download
|
|
model_dir = snapshot_download(model)
|
|
return cls(model_dir=model_dir, device=device, dtype=dtype, **kwargs)
|