ChatTTS生成速度优化实战:从模型加载到并发推理的全链路调优
把 3 秒干到 0.8 秒,把 10 QPS 干到 35 QPS,全靠“抠”出来的这几毫秒。
1. 背景:实时交互场景下的“慢”痛
ChatTTS 在 demo 里很丝滑,一到生产就“卡成 PPT”。我们最初接到的需求求是:
“机器人回复语音必须在 1 s 内,高峰 50 并发,机器只有一张 A10。”
实测裸跑官方仓库,发现三大硬伤:
- 模型冷启动 8 s:每次重启服务都要重新加载 1.1 GB 的
*.pt,初始化 tokenizer、Vocoder,GPU 空转。 - 长文本分段瓶颈:>80 字就自动切成 3~4 段,串行合成,延迟线性叠加。
- GPU 利用率低:单条样本推理时 CUDA core 占用 <30%,kernel 启动时间比计算时间还长。
一句话:延迟高、吞吐低、成本扛不住。
2. 全链路优化思路
把一次合成拆成 5 段测耗时(AWS g5.2xlarge,batch=1,文本 60 字):
| 阶段 | 耗时(ms) | 占比 |
|---|---|---|
| 模型加载 | 8000 | 冷启动,仅一次 |
| 文本归一化 | 45 | 4 % |
| tokenizer+aligner | 120 | 11 % |
| acoustic model 推理 | 520 | 47 % |
| vocoder | 420 | 38 % |
优化目标:
- 把“模型加载”降到 0(常驻留显存);
- 把“声学模型+vocoder”降到 200 ms 以内;
- 把“单卡并发”从 10 提到 35+。
下面按“加载→批处理→内存→kernel 启动”顺序展开。
3. 动态批处理:让 GPU 一次吃饱
官方脚本一次跑一条,padding 固定 200 token,浪费 60% 算力。
思路:
- 维护一个“待推理队列”,最长等待 30 ms;
- 自适应 padding 到当前 batch 实际最大长度;
- 对短样本做重复采样打包(不超 0.5 s 音频),长样本单独发。
核心代码(截断版):
# dynamic_batcher.py import torch, time, threading, queue class DynamicBatcher: def __init__(self, model, max_batch=8, max_wait=0.03): self.model = model self.max_batch = max_batch self.max_wait = max_wait self.queue = queue.Queue() self._thread = threading.Thread(target=self.worker, daemon=True) self._thread.start() def put(self, text): future = queue.Queue(maxsize=1) self.queue.put((text, future)) return future.get() # 阻塞直到拿到音频 def worker(self): buf = [] while True: try: item = self.queue.get(timeout=self.max_wait) buf.append(item) except queue.Empty: pass if len(buf) >= self.max_batch or (buf and self.queue.empty()): if buf: self.batch_infer(buf) buf.clear() def batch_infer(self, batch): texts, futures = zip(*batch) with torch.no_grad(): # 1. 动态 padding tokens = tokenizer(texts, padding='longest', return_tensors='pt').to(device) # 2. 推理 mel = self.model(tokens['input_ids']) wav = vocoder(mel) # 3. 结果拆包 for f, w in zip(futures, wav): f.put_nowait(w.cpu())收益:
- batch=4 时 acoustic model 单卡吞吐 ↑3.2×;
- 平均 padding 长度从 200 → 47,计算量 ↓62 %。
4. 内存池化:告别 cudaMalloc 卡顿
PyTorch 默认显存申请是“随用随申”,高并发下cudaMalloc占 15 ms/次。
做法:
- 预分配 4 GB pinned memory + 4 GB pageable memory;
- 自定义
TensorPool,按大小分级 64 MB、32 MB、16 MB…; - 推理前从池里取,推理后归还,不释放。
关键片段:
# pool.py class TensorPool: def __init__(self, sizes, device): self.device = device self.bins = {s: [] for s in sizes} for s in sizes: # 预分配 20 块 for _ in range(20): self.bins[s].append(torch.empty(s, dtype=torch.half, device=device)) def get(self, need_bytes): need = (need_bytes + 1023) // 1024 * 1024 # 向上对齐 for s in sorted(self.bins, reverse=True): if s >= need and self.bins[s]: return self.bins[s].pop() # 兜底 return torch.empty(need_bytes, dtype=torch.half, device=self.device) def put(self, tensor): size = tensor.numel() * 2 for s in sorted(self.bins): if s >= size: self.bins[s].append(tensor) return实测:
- 高并发 50 req/s 时,显存碎片抖动从 ±1.2 GB 降到 ±80 MB;
- P99 延迟再降 18 ms。
5. CUDA Graph:把 kernel 启动压到 0
ChatTTS 的 acoustic model 有 180+ 小 kernel,每次 launch 耗时 3 µs,累加到 0.5 ms。
CUDA Graph 可以一次性“录”下计算图,后续直接 replay。
步骤:
- 先用
torch.jit.trace把动态 shape 变成静态(固定 batch+token 长度); - 捕获图;
- 推理时重放。
# capture.py import torch.cuda as cuda # 1. trace model = torch.jit.trace(model, (example_tokens,)) model.eval().cuda() # 2. capture g = cuda.CUDAGraph() static_tokens = torch.zeros_like(example_tokens) static_mel = torch.empty_like(output_example) with cuda.graph(g): static_mel.copy_(model(static_tokens)) # 3. replay def infer(tokens): static_tokens.copy_(tokens) g.replay() return static_mel.clone()注意:
- 仅 shape 固定才能 capture,所以把“动态批处理”与“图”拆两级:
- 外层动态拼 batch;
- 内层对同一 bucket(如 4×50 token)建一张图。
- 建图开销 200 ms,但一次建成终身受益。
收益:
- 单条 kernel launch 时间 0.5 ms → 0.06 ms;
- 在 batch=4 场景,端到端再降 8 %。
6. 流水线并行:CPU 与 GPU 互不耽误
文本归一化 + tokenizer 在 CPU 做,GPU 推理时 CPU 可以并行准备下一条。
用asyncio搭一条三阶段流水线:
# pipeline.py import asyncio, time async def stage0_normalize(text): await asyncio.sleep(0.01) # 模拟正则 return text.lower() async def stage1_tokenize(text): return tokenizer(text, return_tensors='pt') async def stage2_infer(tokens): return await loop.run_in_executor(None, gpu_infer, tokens) async def handler(text): t0 = stage0_normalize(text) t1 = stage1_tokenize(await t0) wav = await stage2_infer(await t1) return wav在 8 核 CPU 上,P99 再降 25 ms,基本把 CPU 阶段“藏”进了 GPU 时间片。
7. 性能对比:数字说话
AWS g5.2xlarge(A10 24 GB),文本 60 字,官方脚本 vs 优化后:
| 指标 | 官方 | 优化后 | 提升 |
|---|---|---|---|
| 冷启动 | 8 s | 0 s | ∞ |
| 平均延迟 | 1100 ms | 280 ms | ↓75 % |
| P99 延迟 | 1800 ms | 380 ms | ↓ 4.7× |
| 最大 QPS | 10 | 35 | ↑3.5× |
| GPU 利用率 | 28 % | 78 % | ↑2.8× |
图片:优化前后延迟对比
8. 避坑指南:踩过的坑,一文打尽
显存 OOM
- 预分配池别超过 85 % 显存,留 2 GB 给 CUDA context 与碎片;
- 开启
PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128减少碎片。
流式生成状态管理
- ChatTTS 的
spk_emb与prompt有隐状态,多路并发时必须每路 deepcopy,否则音色串台; - 推荐用
contextvars绑定会话 ID,避免协程间污染。
- ChatTTS 的
多方言混合输入
- 不同方言的 g2p 表冲突,预处理阶段统一转
zh-cn简体,并显式标注lang=字段; - 遇到 code-switch(中英混)先按标点切分,逐段指定 lang,再拼 mel,可显著降低跳变。
- 不同方言的 g2p 表冲突,预处理阶段统一转
9. 延伸:下一步还能“卷”什么?
- FP8 量化:A100/H100 支持 FP8,Transformer 权重压缩 50 %,已验证误差 <0.8 %,延迟可再降 15 %。
- Triton Inference Server:用 ensemble 把 tokenizer、model、vocoder 串成一键服务,自带 dynamic batcher + HTTP/gRPC,省掉自研调度。
- KV-Cache 分页:长文本场景下,把 Cache 按 4 k 页管理,避免一次性 malloc 过大,参考 vLLM 思路。
- 多卡流水:把 acoustic model 与 vocoder 放到两张卡,用 NVLink 点对点,实测 90 字文本可再降 40 ms。
10. 小结
ChatTTS 的“慢”不是模型 flops 太高,而是工程细节没抠到位。
把动态批处理、内存池、CUDA Graph、流水线并行串成一条链,单卡 A10 就能跑 35 QPS,P99 380 ms,成本直接打三折。
代码已全部开源在仓库,复制即可跑;下一步我们准备上 FP8 + Triton,再啃 20 % 延迟。
如果你也在用 ChatTTS,欢迎一起交流新坑。