LTX-2.3 在单张 96GB GPU 上与 TTS 共存的冷启动方案
在尝试将 LTX-2.3(22B 的音频转视频模型)集成到语音角色扮演产品时,我在 VRAM 设计上遇到了难题。“如果以常驻服务器方式运行,它会吃掉 86 GiB,导致同一 GPU 上运行的 TTS / Ditto / MuseTalk 立即 OOM”——从这种典型的困境出发,我切换到了空闲 0 GiB / 峰值 40 GiB 的冷启动方案。
硬件是 RTX Pro 6000 Blackwell Max-Q(94.97 GiB),软件使用的是 LTX-2 官方仓库 和 bitsandbytes 0.49.1。
想要实现的目标
A2V(音频转视频)是一种从音频 + 参考图像 + 文本提示词生成唇形同步视频的模式。具体使用的是 A2VidPipelineTwoStage。
prompt + audio_path + image
↓ stage_1 (低分辨率生成 video latent,音频固定)
↓ spatial upsample 2x
↓ stage_2 (高分辨率细化,应用 distilled LoRA-384)
↓ video VAE decode + 直接嵌入输入音频
mp4 output
官方 pipeline 在每次 __call__ 时都会构建 → 运行 → 释放各个组件,因此每次都会产生约 50 秒的磁盘 I/O。我原本想将其持久化。
困境 1: 持久化模式的 VRAM 占用明细
如果将 LTX-2 的所有组件都常驻 VRAM,其大小如下(均为 bf16)。
| 组件 | VRAM |
|---|---|
| embeddings processor | 5.91 GiB |
| Gemma3-12B text encoder | 22.78 GiB |
| stage_1 transformer | 35.38 GiB |
| stage_2 transformer (应用 distilled LoRA) | 35.38 GiB |
| video VAE encoder | 0.60 GiB |
| audio VAE encoder | 0.04 GiB |
| spatial upsampler | 0.92 GiB |
| video decoder | 0.76 GiB |
| 总计 | 101.77 GiB |
只有 96 GiB 的 GPU 装不下 102 GiB。在加载 stage_2 transformer 的过程中,程序因 CUDA out of memory. Tried to allocate 128.00 MiB. 而崩溃。
困境 2: “Gemma 很小”是误解
直觉上会觉得“12B 的文本编码器应该很轻”,但实际加载后占用了 22.78 GiB。因为是 bf16 格式的 12B 参数,所以这理所当然。
文件名是 gemma-3-12b-it-qat-q4_0-unquantized,其中 qat-q4_0 表示“已针对 q4_0 进行 QAT(量化感知训练)”,unquantized 表示“权重以量化前的 bf16 格式存储”。这是一个应该按设计使用 q4_0 加载的模型,以 bf16 形式使用是一种 MoE 式的“正确但高成本”的运维方式。
解法 1: 使用 bitsandbytes 进行 4-bit 加载
LTX-2 的 Gemma 加载器内部使用了 transformers.Gemma3ForConditionalGeneration,因此 bnb 4-bit 可以直接生效。绕过 LTX-2 的自定义加载器路径,使用 from_pretrained 加载。
from transformers import BitsAndBytesConfig, Gemma3ForConditionalGeneration
quant_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
)
model = Gemma3ForConditionalGeneration.from_pretrained(
gemma_root,
quantization_config=quant_config,
device_map={"": "cuda:0"},
torch_dtype=torch.bfloat16, # ← 非量化层 (如 embedding) 的 dtype
local_files_only=True,
)
如果不显式指定 torch_dtype,embedding 会以 fp16 加载,与 Linear4bit 的 bnb_4bit_compute_dtype (bf16) 冲突:mat1 and mat2 must have the same dtype, but got Half and BFloat16。我也踩过这个坑。
LTX-2 对 Gemma 额外进行的 patch(RoPE inv_freq / embed_scale / position_ids 的 register_buffer),只需调用 create_and_populate(encoder) 即可直接应用。bnb 量化仅替换 nn.Linear,因此 Embedding 和 buffer 不受影响。
结果,Gemma 的 VRAM 从 22.78 GiB 压缩到 7.26 GiB。节省了 15 GiB。
困境 3: 即便如此,持久化模式仍无法共存
Gemma 4-bit + 其他常驻组件的总计分配为 86.26 GiB(reserved 88.27 GiB,nvidia-smi 显示 91 GiB)。剩余空间为 4 GiB。生成时的推理工作空间(含 CFG 约 +5 GiB)会超出这 4 GiB,达到峰值 91 GiB,因此如果让 TTS (3.4 GiB) + Ditto (3.0 GiB) 总共 6.4 GiB 共存,无论如何计算都会 OOM。
当时有三个选择:
- 移除 TTS+Ditto(在 A2V 验证期间无法使用语音聊天)
- 仅常驻一个 transformer(不彻底,仍有 OOM 风险)
- 冷启动:每次请求都构建 → 运行 → 释放所有权重
由于我希望在保持实时对话(MuseTalk + TTS,TTFA 约 930ms)运行的同时,将 LTX-2 用于“演出用途”,因此选择了方案 3。
解法 2: 冷启动架构
关键在于,“pipeline 对象本身很轻量(Builder 仅使用 mmap,不加载实际权重)”。保持 A2VidPipelineTwoStage 的实例,直接使用官方实现,即每次 __call__ 时各组件通过 context manager 进行构建 → 运行 → 释放。
class PersistentA2VPipeline:
def __init__(self, ..., cold_start: bool):
self.pipeline = A2VidPipelineTwoStage(...) # 仅 builder,VRAM 几乎为零
if cold_start:
return # 在此结束
# 仅在持久化模式下,从这里开始常驻加载
def _generate_cold(self, ...):
# pipeline.__call__ 内部循环处理组件的构建/释放
video, audio = self.pipeline(prompt=..., audio_path=..., images=...)
encode_video(video, audio, output_path, ...)
stage_1 和 stage_2 是串行执行的,因此同时占用 VRAM 的只有其中一个。实测峰值为 39.50 GiB。生成结束后完全释放,回到 allocated 0.01 GiB / nvidia-smi 显示 0.55 GiB(仅 CUDA 上下文)。
[mode] cold-start: components load per-request (slow first call, low idle VRAM)
[cuda] cold-start startup (no preload): allocated=0.00GiB
...
[cuda] after cold-start generate: allocated=0.01GiB peak=39.50GiB
在与语音聊天(TTS 3.4 + Ditto 3.0 = 6.4 GiB)并行运行时,LTX 占用 0 GiB;触发 A2V 的瞬间上升到 40 GiB,60 秒后回到 0 GiB,这是一种动态分配。
踩坑点:音频 VAE 的前处理
A2V 的 audio VAE encoder 要求输入为 2-channel(立体声)波形,而 TTS 的输出通常是单声道。如果传入单声道,Conv2d 会报错:expected input[1, 1, 207, 66] to have 2 channels, but got 1 channels instead。
此外,如果输入音频的长度短于 num_frames / frame_rate,编码后的 audio latent 会比预期短,导致 transformer 输入 shape 不匹配。
两者都通过 ffmpeg 进行前处理:
# 单声道 → 立体声 + 静音填充,一步完成
ffmpeg -y -i input.wav -ac 2 -af apad -t 2.041667 output.wav
在服务端使用 av 检查输入的 channels 和 duration,仅在必要时通过 ffmpeg subprocess 进行标准化,然后传递临时文件。如果两者都不需要,则零拷贝直接传递输入文件。
数据与权衡
| 指标 | 持久化模式 | 冷启动模式 |
|---|---|---|
| 空闲 VRAM | 86 GiB | 0 GiB |
| 生成时峰值 VRAM | 91 GiB | 40 GiB |
| 单次请求耗时 | ~17s(仅推理) | ~60s(含磁盘 I/O) |
| TTS+Ditto 共存 | 不可(OOM) | 可 |
| OS page cache 效果 | 无 | 第二次起 ~25-30s |
冷启动的代价是磁盘 I/O 时间(从 NVMe 读取 73 GB,约 40 秒)。第一次需要 60 秒,之后 OS page cache 生效,大约 25-30 秒。不适合连续使用的场景,但对于“作为演出每 1-2 分钟一次”“在场景切换瞬间插入”这类情况则没有问题。
战略定位
实际上,最初我打算将 LTX-2 用作实时对话的主要虚拟形象。我曾认为通过低分辨率生成再 upscale 可以解决速度问题,但在实测中发现 256×256 分辨率下质量崩溃(偏离了训练 bucket 分布),因此放弃了实时路线。从信息丢失状态进行的 AI upscaler 无法恢复唇形同步精度。
取而代之的是:
- 实时对话: MuseTalk + 多语言 TTS(TTFA ~930ms,现有方案)
- 异步演出: LTX-2 用于“场景切换瞬间”“情感峰值”“移动位置虚拟形象”等可以容忍 60 秒生成等待的场合
我按此进行了角色划分。冷启动方案正是基于“这种等待可以作为演出的一部分被接受”的前提才成立的架构选择。
在 hage,我们持续开发语音角色扮演 × 多语言高质量 TTS × 唇形同步虚拟形象。关于本次的 LTX-2 集成,以及将 Qwen3-TTS 的 VRAM 从 15 GB 压缩到 7 GB 等工程类文章,请参阅 /articles。
