IndexTTS-2.5 在单卡上跑批量合成 / 先量显存再定并发

文章导读
单卡跑 IndexTTS-2.5 批量合成时遇到显存不足,处理顺序通常不是先砍并发,而是先量一条任务的显存峰值。知道「加载模型后占多少、单条推理峰值多少、结束后回落到多少」这三个数,才能算出当前卡上还能塞进几条并发;否则无论把并发降到几,都只是猜。批量大小和并发数是两个不同的量:批量决定一次提交多少任务,并发决定同时有几个任务占用显存,真正会杀进程的通常是后者。
📋 目录
  1. 用显存观测命令记录单次推理峰值
  2. 确认基线在不同文本长度下的变化
  3. 在脚本里加并发上限与任务队列
  4. 逐步加压并记录失败点
  5. 给出批量与并发的取舍规则
A A

单卡跑 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 数量,和任务总数解耦。

IndexTTS-2.5 在单卡上跑批量合成 / 先量显存再定并发
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,让任务排队慢慢跑,通常比缩短文本更省事。
  • 文本长度差异大:按最长文本的那一档定并发,或者把长文本拆成单独队列、给更小的并发,短文本队列可以提高。混在一个队列里,长文本会把峰值顶上去,短文本也要跟着受限制。
  • 磁盘写入或后处理是瓶颈:继续加并发只会堆积待写文件,显存压力不降反升,这时保持低并发、把队列拉长更合适。
  • 不确定的一律先降一档再跑一批任务验证,看失败数和耗时是否回到可接受范围,再决定要不要往上调。

判断依据始终是三个可观测值:单条峰值、长文本档峰值、当前并发下的失败与耗时变化。只要这三个数还稳定,批量大小可以加大;一旦峰值贴边,就先减并发、再谈其他。切到更长文本、换采样率或改输出格式后,基线会变,需要重新量一遍再定并发。