单卡跑 IndexTTS-2.5 批量合成时遇到显存不足,处理顺序通常不是先砍并发,而是先量一条任务的显存峰值。知道「加载模型后占多少、单条推理峰值多少、结束后回落到多少」这三个数,才能算出当前卡上还能塞进几条并发;否则无论把并发降到几,都只是猜。批量大小和并发数是两个不同的量:批量决定一次提交多少任务,并发决定同时有几个任务占用显存,真正会杀进程的通常是后者。
单卡批量合成建议按「先测单条峰值 → 再定并发上限 → 最后加压验证」的顺序做。适用场景是单卡、多任务、文本长度不固定的批量合成。操作上先用 nvidia-smi 或 torch 的显存统计记录加载后占用与单条峰值,再用队列把并发锁死在可控范围。验证方式是逐步加压看在哪一档出现 OOM 或明显变慢。风险边界:显存余量要给推理中间张量和音频缓冲留空间,压到接近上限的运行方式,换一条长文本就可能崩。
用显存观测命令记录单次推理峰值
先用一条你最常出现的中等长度文本做单次推理,把基线量出来。观测可以分两层:系统层用 nvidia-smi,进程内用 PyTorch 的显存统计,两者一起看才不容易误判——nvidia-smi 看到的是整卡占用,包含其他进程和缓存。
# 持续观察整卡占用,1 秒刷新
nvidia-smi `--query-gpu`=memory.used,memory.total,utilization.gpu `--format`=csv -l 1
# 或者半秒刷新一次,看峰值更细
watch -n 0.5 nvidia-smi
进程内读数放在推理前后各取一次,并在推理结束后再取一次。用 torch.cuda.max_memory_allocated() 拿的是分配器视角的峰值,和 nvidia-smi 的整卡值会有差异,记录时注明用的是哪一个。
import torch
def snap(tag):
print(tag,
"allocated_mb=", round(torch.cuda.memory_allocated()/1024**2, 1),
"reserved_mb=", round(torch.cuda.memory_reserved()/1024**2, 1),
"peak_mb=", round(torch.cuda.max_memory_allocated()/1024**2, 1))
snap("model_loaded") # 模型加载完成后,推理前
# ... 执行一次合成 ...
snap("after_one_infer") # 单条推理结束后
# 测下一个样本前重置峰值,避免被上一条的峰值污染
torch.cuda.reset_peak_memory_stats()
需要记录的字段建议固定为:模型加载后占用、单条推理峰值、单条推理结束后的回落值、单条耗时、输出音频时长。观测时机对应「模型刚加载完 / 推理进行中 / 推理结束后」。其中回落值最有参考意义,它说明释放是不是干净;如果每次推理后占用都往上抬一截,那问题不在并发,而在缓存或对象没释放。
确认基线在不同文本长度下的变化
只有一条基线不够,因为文本长度对峰值的影响往往比并发更直接。做法是准备短、中、长三档文本,其他条件尽量保持一致:同一个 speaker、同一个随机种子、同样的输出时长上限,只改文本内容。
samples = {
"short": "一句十来个字的短句。",
"medium": "一段中等长度的文本,大概两三句话,用于覆盖日常合成场景。",
"long": "一段明显更长的文本,包含多个分句和标点,用来逼近你在批量任务里会遇到的长输入。",
}
for name, text in samples.items():
torch.cuda.reset_peak_memory_stats()
snap(f"{name}_before")
wav = tts.synthesize(text, speaker=spk)
snap(f"{name}_after")
print(name, "chars=", len(text), "audio_sec=", len(wav)/sample_rate)
把三档结果写进一张表,字段最少包含:档位、字符数、输出音频时长、峰值显存、耗时。记录方式是每档重复跑几次、取观察到的最高峰值,而不是取平均,因为 OOM 只认峰值。如果长文本的峰值明显高出中短文本,那你的并发上限应当按长文本这一档来定,或者干脆把长文本单独排队、降低它的并发。
在脚本里加并发上限与任务队列
批量脚本最容易出的问题是一次性把所有任务提交出去,每个任务各自申请显存,堆到 GPU 直接挂掉。改成「固定数量的 worker 从队列取任务」,并发数就等于 worker 数量,和任务总数解耦。
import queue, threading, traceback
MAX_WORKERS = 2 # 并发上限,先按实测峰值能容纳的数量填,宁小勿大
QUEUE_SIZE = 20 # 队列长度,防止任务列表无限堆积
RETRY = 1 # 单条失败后的重试次数
q = queue.Queue(maxsize=QUEUE_SIZE)
lock = threading.Lock()
failed = []
def worker():
while True:
try:
task = q.get_nowait()
except queue.Empty:
return
for attempt in range(RETRY + 1):
try:
run_one(task) # 你的单条合成函数
break
except RuntimeError as e:
# 常见的是 CUDA out of memory
if "out of memory" in str(e) and attempt < RETRY:
torch.cuda.empty_cache()
continue
with lock:
failed.append({"task": task, "error": traceback.format_exc()})
break
finally:
q.task_done()
threads = [threading.Thread(target=worker, daemon=True) for _ in range(MAX_WORKERS)]
for t in threads:
t.start()
for task in all_tasks:
q.put(task)
for t in threads:
t.join()
三个参数各有分工:MAX_WORKERS 控制同时占用显存的任务数,QUEUE_SIZE 控制待处理任务在内存里的排队长度,RETRY 决定单条失败后是重试还是直接标记失败。失败不要静默吞掉,把任务标识和错误栈落到日志或失败清单文件里,跑完统一重跑,比中途崩掉整批任务好收拾。torch.cuda.empty_cache() 只在失败重试和批次之间用,它不是显存优化手段。
逐步加压并记录失败点
并发数不要一次跳到目标值。从 1 开始,按 1 → 2 → 3 → 4 递增,每一档跑足够数量的任务,记录三件事:是否出现 CUDA out of memory、单条耗时是否明显变长、进程有没有被系统直接杀掉(这种情况日志里可能只看到进程消失,而没有异常栈)。
for workers in [1, 2, 3, 4, 6]:
stats = run_batch(all_tasks[:N], max_workers=workers)
print({
"workers": workers,
"ok": stats.ok,
"failed": stats.failed,
"oom": stats.oom_count,
"avg_sec": round(stats.avg_sec, 2),
"p95_sec": round(stats.p95_sec, 2),
"peak_gpu_mb": stats.peak_gpu_mb,
})
判定上限不只看崩没崩:某一档开始失败数为零但单条耗时比上一档翻倍,说明已经进入抢显存或频繁换入换出的状态,这一档就应当视为不可用。记录时把「崩溃点」和「明显变慢点」分开写,两者往往不在同一档。找到上限后,实际使用值建议比上限低一档。
给出批量与并发的取舍规则
规则不落在统一数字上,而落在你那张压力表里的余量。可以先按下面的顺序判断:
- 显存余量充足(整卡占用离总量还有明显距离,且长文本档也稳):并发可以按压力表里最后一档可用值保持,批量一次性排进队列即可,队列长度只用来控内存,不用来控显存。
- 显存余量紧张、但任务文本普遍偏短:优先降并发而不是降批量。批量影响的是总时长,并发才影响峰值显存;把并发压到 1~2,让任务排队慢慢跑,通常比缩短文本更省事。
- 文本长度差异大:按最长文本的那一档定并发,或者把长文本拆成单独队列、给更小的并发,短文本队列可以提高。混在一个队列里,长文本会把峰值顶上去,短文本也要跟着受限制。
- 磁盘写入或后处理是瓶颈:继续加并发只会堆积待写文件,显存压力不降反升,这时保持低并发、把队列拉长更合适。
- 不确定的一律先降一档再跑一批任务验证,看失败数和耗时是否回到可接受范围,再决定要不要往上调。
判断依据始终是三个可观测值:单条峰值、长文本档峰值、当前并发下的失败与耗时变化。只要这三个数还稳定,批量大小可以加大;一旦峰值贴边,就先减并发、再谈其他。切到更长文本、换采样率或改输出格式后,基线会变,需要重新量一遍再定并发。