LTX-2.3 を 96GB GPU 1 枚で TTS と同居させる cold-start 構成

#ltx2#vram#cold-start#bitsandbytes#blackwell

LTX-2.3 を 96GB GPU 1 枚で TTS と同居させる cold-start 構成

音声ロールプレイ製品に LTX-2.3(22B の audio-to-video モデル)を組み込もうとして、VRAM 設計でハマった。「常駐サーバーで動かしたら 86 GiB 食って、同じ GPU で動かしている TTS / Ditto / MuseTalk が即 OOM」という典型的な詰みから、idle 0 GiB / peak 40 GiB の cold-start 構成に切り替えた記録。

ハードウェアは RTX Pro 6000 Blackwell Max-Q(94.97 GiB)、ソフトウェアは LTX-2 公式リポジトリ と bitsandbytes 0.49.1。

やりたかったこと

A2V (audio-to-video) は、音声 + 参照画像 + テキストプロンプトからリップシンク動画を生成するモード。具体的には A2VidPipelineTwoStage を使う。

prompt + audio_path + image
   ↓ stage_1 (低解像度で video latent 生成、audio 固定)
   ↓ spatial upsample 2x
   ↓ stage_2 (高解像度で refinement、distilled LoRA-384 適用)
   ↓ video VAE decode + 入力 audio をそのまま埋め込み
mp4 output

公式パイプラインは __call__ ごとに各コンポーネントを build → run → free する作りなので、毎回 50 秒前後のディスク I/O が発生する。これを persistent 化したかった。

詰み 1: persistent モードの VRAM 内訳

LTX-2 の構成要素を全部 VRAM 常駐させると、以下のサイズになった(すべて bf16)。

コンポーネントVRAM
embeddings processor5.91 GiB
Gemma3-12B text encoder22.78 GiB
stage_1 transformer35.38 GiB
stage_2 transformer (distilled LoRA 適用)35.38 GiB
video VAE encoder0.60 GiB
audio VAE encoder0.04 GiB
spatial upsampler0.92 GiB
video decoder0.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 は「QAT (Quantization-Aware Training) で q4_0 用に訓練済み」の意味、unquantized は「重みは量子化前の bf16 で保管」の意味。設計通りに使うなら q4_0 でロードすべきモデルで、bf16 のまま使うのは MoE 的な「正しいけど高コストな運用」。

解 1: bitsandbytes で 4-bit ロード

LTX-2 の Gemma loader は内部で transformers.Gemma3ForConditionalGeneration を使っているので、bnb 4-bit は素直に効く。LTX-2 のカスタム loader 経路を bypass して 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 でロードされて Linear4bitbnb_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: それでも persistent は同居不可

Gemma 4-bit + その他常駐の合計は 86.26 GiB allocated(reserved 88.27 GiB、nvidia-smi 91 GiB)。残ヘッドルームは 4 GiB。生成時の推論ワークスペース(CFG 込みで +5 GiB 級)でこの 4 GiB を踏み越えて peak 91 GiB に達するので、TTS (3.4 GiB) + Ditto (3.0 GiB) の合計 6.4 GiB を同居させると どう計算しても OOM

選択肢は 3 つあった:

  1. TTS+Ditto を退避(A2V 検証中はボイスチャットを使えない)
  2. transformer 片方だけ常駐(中途半端、OOM リスク残る)
  3. cold-start: 全部の重みをリクエスト毎に build → run → free

リアルタイム会話 (MuseTalk + TTS、TTFA 約 930ms) を動かしたまま LTX-2 を「演出用途」として使いたかったので、3 を選んだ。

解 2: cold-start アーキテクチャ

ポイントは、「pipeline オブジェクト自体は軽い (Builder は mmap だけで実 weight をロードしない)」という性質。A2VidPipelineTwoStage のインスタンスは保持しつつ、__call__ ごとに各 component が context manager で build → run → free する公式実装をそのまま使う。

class PersistentA2VPipeline:
    def __init__(self, ..., cold_start: bool):
        self.pipeline = A2VidPipelineTwoStage(...)  # builder のみ、VRAM ほぼゼロ
        if cold_start:
            return  # ここで終わる
        # persistent モードの場合のみ、ここから常駐ロードを開始

    def _generate_cold(self, ...):
        # pipeline.__call__ がコンポーネントの build/free を内部で回す
        video, audio = self.pipeline(prompt=..., audio_path=..., images=...)
        encode_video(video, audio, output_path, ...)

stage_1 と stage_2 は直列実行なので、同時に VRAM に乗るのは片方だけ。peak は実測で 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 に戻る、という動的アロケーション。

ハマりどころ:音声 VAE の前処理

A2V の audio VAE encoder は 2-channel (stereo) waveform を要求する一方、TTS の出力は典型的に mono。mono を渡すと Conv2d が expected input[1, 1, 207, 66] to have 2 channels, but got 1 channels instead で死ぬ。

加えて、入力音声の長さが num_frames / frame_rate より短いと、encoded audio latent が想定より短くなって transformer 入力で shape mismatch する。

両方 ffmpeg で前処理:

# mono → stereo + silence padding を 1 回で
ffmpeg -y -i input.wav -ac 2 -af apad -t 2.041667 output.wav

server 側で input の channels と duration を av で確認して、必要時のみ ffmpeg subprocess で正規化、temp file を渡す。両方不要ならコピーゼロで入力ファイルそのまま渡す。

数字とトレードオフ

指標persistentcold-start
idle VRAM86 GiB0 GiB
生成時 peak VRAM91 GiB40 GiB
1 リクエスト所要時間~17s(推論のみ)~60s(ディスク I/O 込み)
TTS+Ditto 同居不可(OOM)
OS page cache 効果なし2 回目以降 ~25-30s

cold-start の代償はディスク I/O 時間(73 GB を NVMe から読む、~40 秒)。1 回目は 60 秒、その後 OS page cache が効いて 25-30 秒程度。連発する用途には不向きだが、「演出として 1-2 分に 1 回」「シーン切替の瞬間に挟む」ようなケースなら問題ない。

戦略的な位置づけ

実は LTX-2 を当初は realtime 会話のメインアバターとして使おうとしていた。低解像度で生成して upscale すれば速度問題を解決できると考えていたが、256×256 で品質が崩壊する(訓練 bucket 分布から外れる)ことを実測で確認した時点で realtime 路線を撤退した。情報が失われた状態からの AI upscaler は、リップシンク精度を復元できない。

代わりに、

  • realtime 会話: MuseTalk + 多言語 TTS(TTFA ~930ms、既存)
  • 非同期演出: LTX-2 を「シーン切替の瞬間」「感情ピーク」「ロケ移動アバター」など 60 秒の生成待ちが許容される箇所で使う

という役割分担に切った。cold-start 構成はこの「待ちが演出の一部として許される」前提でこそ成立する設計選択。


hage では音声ロールプレイ × 多言語高品質 TTS × リップシンクアバターの実装を続けています。今回の LTX-2 統合や、Qwen3-TTS の VRAM を 15 GB → 7 GB に圧縮した話など、エンジニアリング系の記事を /articles に置いています。