背景痛点:实时语音合成最怕“慢”和“爆”
做语音合成的朋友都懂,线上一旦并发飙高,两条红线立刻报警:
- 延迟飙到 800 ms 以上,用户直接投诉“卡顿”;
- GPU 显存瞬间 95%,容器被 OOMKiller 一波带走,服务重启又带来新的毛刺。
CosyVoice 原生 PyTorch 版在 A10 上单卡 QPS≈18,首包延迟 450 ms,显存峰值 6.8 GB。业务想要 50 路并发,只能堆 3 张卡,成本直线上升。于是我们把“推理加速”当成一个独立子项目来做——目标很明确:单卡 QPS ≥ 60,显存 ≤ 5 GB,P99 延迟 ≤ 300 ms。
技术对比:PyTorch vs ONNX vs TensorRT
先跑一轮裸数据,模型固定 FP32,batch=1,输入 30 个汉字:
| 框架 | QPS↑ | 显存↓ | 首包延迟↓ |
|---|---|---|---|
| PyTorch | 18 | 6.8 G | 450 ms |
| ONNX Runtime | 38 | 5.9 G | 280 ms |
| TensorRT | 55 | 5.1 G | 210 ms |
TensorRT 看着最香,但转 TRT 需要把 CosyVoice 里的若干torch.nn.GRU手工改成torch.nn.GRUCell,再补一堆 plugin,维护成本陡增。权衡后我们采用“ONNX+动态批+量化”路线,既保住精度,又把工程量压到 1 人周。
核心实现三板斧
1. 图优化:torch.jit.script 先把“动态图”变“静态图”
CosyVoice 的 VarianceAdaptor 里藏了不少 Python 控制流,直接torch.jit.trace会报错。做法是把forward拆成两个子模块:
- 可脚本化部分(
TextEncoder、Decoder)用torch.jit.script; - 不可脚本化部分(
VarianceAdaptor.length_regulate)保留 Python 调用,通过torch.jit.ignore标记。
核心代码(带类型注解):
import torch from typing import Tuple class TextEncoderJIT(torch.nn.Module): def __init__(self, core: torch.nn.Module): super().__init__() self.core = torch.jit.script(core) # 先脚本化 def forward(self, x: torch.Tensor, mask: torch.Tensor) -> torch.Tensor: return self.core(x, mask) # 静态图走 JIT导出时统一入口:
whole_model = CosyVoiceJIT() sm = torch.jit.script(whole_model) torch.jit.save(sm, "cosyvoice_jit.pt")这一步单卡 QPS 从 18 → 26,显存降到 6.2 G,白捡 40% 提升。
2. 动态批处理:让“小请求”拼成“大包”
TTS 场景请求长度差异巨大,短 5 字,长 300 字。我们写了一个DynamicBatcher:收到请求先放队列,每 50 ms 检查一次,把长度差≤N 的样本拼成一批,N 随队列等待时间线性放宽,保证最长等待 ≤ 200 ms。
from collections import deque import time from typing import List, Tuple class DynamicBatcher: def __init__(self, max_wait: float = 0.2, len_tol: int = 10): self.queue: deque = deque() self.max_wait = max_wait self.len_tol = len_tol def add(self, phonemes: List[int]) -> int: self.queue.append((time.time(), phonemes)) return len(self.queue) def build_batch(self) -> List[List[int]]: if not self.queue: return [] now = time.time() head_time, head_seq = self.queue[0] if now - head_time < self.max_wait: # 时间窗未到,先不拼 return [] batch = [] target_len = len(head_seq) while self.queue and len(batch) < 32: t, seq = self.queue[0] if abs(len(seq) - target_len) <= self.len_tol: batch.append(seq) self.queue.popleft() else: break return batch实测在 50 并发下,平均批尺寸 5.3,QPS 再涨 35%,显存仅增 6%。
3. 量化校准:FP32 → INT8 不掉 MOS
CosyVoice 的 Mel 解码器对精度敏感,直接 PTQ 掉点 0.08 MOS。我们采用混合量化:
- 对
Conv1d、Linear做 INT8; GRU层保留 FP16;- 校准数据集用业务侧 2k 条真实 prompt。
ONNX Runtime 自带quantize_dynamic不够细,改用自写校准:
from onnxruntime.quantization import quantize_static, CalibrationDataReader class CosyCalibrater(CalibrationDataReader): def __init__(self, npy_dir: str): self.data = sorted(Path(npy_dir).glob("*.npy")) self.idx = 0 def get_next(self) -> dict: if self.idx >= len(self.data): return None d = np.load(self.data[self.idx]) self.idx += 1 return {"phoneme": d} quantize_static( model_input="cosyvoice.onnx", model_output="cosyvoice_int8.onnx", calibration_data_reader=CoysCalibrater("./calib_npy"), quantize_weights=True, keep_intermediate=False )量化后显存 4.3 G,QPS 冲到 68,MOS 仅掉 0.01,耳朵基本听不出差别。
生产环境落地细节
1. Kubernetes HPA:按 GPU 利用率而不是 CPU 扩
TTS 是 GPU 密集,HPA 默认 CPU 80% 没意义。我们自定义external.metrics.k8s.io:
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: cosyvoice-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: cosyvoice-svc minReplicas: 2 maxReplicas: 20 metrics: - type: External external: metric: name: nvidia_gpu_utilization target: type: Value value: "70"配合 Cluster-Autoscaler,晚高峰 3 分钟可弹出 10 个 Pod,低峰 5 分钟回收,省 35% 卡时。
2. 流式推理显存碎片:预分配池 + cudaMallocAsync
CosyVoice 支持流式 Mel 输出,但每 chunk 大小不同,默认 CUDA allocator 会不停cudaMalloc/cudaFree,导致显存碎片。我们在容器启动时预分配 90% 显存做 memory pool,并在代码里加c10::cuda::CUDACachingAllocator::setMemoryFraction(0.9),碎片从 11% 降到 2%。
3. Triton 热加载:上线不打断业务
Nvidia Triton 的MODEL_LOADAPI 支持版本热替换。上线新模型先把cosyvoice_int8.onnx放到/models/cosyvoice/2/,再调用:
curl -X POST http://triton:8000/v2/repository/models/cosyvoice/load旧版本自动卸载,新版本 0 流量损失。记得把max_batch_size和dynamic_batching的preferred_batch_size写对,否则 Triton 会拒绝加载。
避坑指南:踩过的坑都写这儿了
量化掉精度:别一口气全量 INT8,优先挑
Conv/Linear开刀,GRU、Attention 用 FP16 兜底;批尺寸越大 ≠ 越好,我们画过一条“延迟-吞吐”曲线,在 A10 上 batch=8 是拐点,再大延迟飙升;
Prometheus 必看指标:
req_queue_time:请求在动态批队列里的等待时间,>150 ms 就要放宽批条件;gpu_mem_fragment_ratio:>5% 时考虑重启 Pod,释放碎片;onnx_runtime_session_create_duration:模型加载耗时,>3s 说明磁盘 IO 或 NFS 延迟高。
互动思考:GPU 资源被抢时,你怎么降级?
即便有 HPA,晚高峰 GPU 节点被兄弟部门抢占仍会发生。假如集群只剩 1 张卡,却来了 100 路并发,你会:
A. 直接返回 503,牺牲可用性?
B. 把模型临时切换成 CPU 版,延迟翻倍但保持在线?
C. 动态下调批尺寸,优先保证低延迟,牺牲部分吞吐?
欢迎留言聊聊你的降级策略,一起把 TTS 服务做得既快又稳。
把上面整套流程撸完,我们最终在 A10 单卡把 CosyVoice 推到 QPS 70+,P99 延迟 260 ms,显存 4.3 G,比 baseline 提升 3.8 倍,晚高峰也能稳稳当当。代码和 Dockerfile 已放在团队 GitLab,有需要自取。祝你加速顺利,不踩同样的坑。