LTX-2.5のテキストエンコードを分離して21分から37秒へ
この780MでのLTX-2.5の環境には、ずっと払い続けているコストが1つありました。テキストエンコードがCPUで走るので、プロンプトを変えるたびに、最初の1フレームが見えるまで20分ほどかかります。理由はメモリです。Gemma-4-12Bが8.9GB、diffusionモデルが9.1GB、iGPUに割り当てられるのが14GB。どちらかが外に出るしかありません。
Gemma-4-12BをGPUに載せれば1分もかかりません。diffusionモデルにVRAMを譲るために、CPU側に置かれています。
そもそも、この2つが同時にメモリへ載っている必要があるのか、という話になります。プロンプトのエンコードはdiffusionモデルに触れません。別のプログラムがエンコードだけを済ませて結果をディスクに書き、終了してくれるなら、エンコーダーは14GBを丸ごと使えて、ComfyUIはその存在すら知らずに済みます。
sd.cppからComfyUIへ、途中まで進んだテキストエンコードを渡します。ComfyUIはサンプリング前にテキスト経路の残り1段を実行します。
全部そちらでやらない理由
stable-diffusion.cppはLTX-2.5を最初から最後まで走らせられるので、ComfyUIを捨てるのが素直な選択に見えます。まずそれを測りました。Vulkanバックエンドでのサンプリングは1ステップおよそ213秒、ComfyUIは46秒。1本の生成が15分程度から1時間超になる計算です。
差はバックエンドの成熟度から来ています。この780M上のComfyUIはTheRockのROCmビルド経由でPyTorchを動かしていて、matmulとattentionはrocBLASとMIOpenに落ちます。どちらもgfx1103向けに調整されています。ggml-hipのほうは、主に他のターゲットで検証された、手作業で移植された小さめのカーネル群です。
その差が埋まるかを見るため、sd.cppをROCm向けにビルドしてみました。ビルドは通り、バイナリはGPUを正しく認識してAMD Radeon 780M Graphics, gfx1103, Wave Size: 32, VRAM: 14143 MiBと表示します。そして計算グラフが走った瞬間にsegfaultします。解像度を変えても、flash attentionを切っても、毎回同じ場所で落ちます。ここで動くバックエンドはVulkanだけで、それが遅いほうです。
というわけで、エンコードはsd.cpp、サンプリングはComfyUIに任せます。どちらも得意な側を手放さずに済みます。
パッチ
sd.cppにエンコードだけを行うモードはありません。CLIとHTTPサーバーの両方を確認しましたが、どの経路もファイル保存まで一気に走ります。足したのは--dump-conditioning <path>というフラグで、get_learned_conditionの直後に抜けて、テンソルをsafetensorsとして書き出します。ComfyUI-LTXVideoにはLTXVLoadConditioningというキャッシュ用のノードが元からあり、このテンソル形式を読めます。メタデータの受け渡しには、下の小さなパッチを加えます。
write_safetensors_file()が__metadata__ブロックを書けなかったので、そこにも省略可能な引数を1つ足しました。
このフラグはdiffusionモデルもVAEも読み込む前に抜けるため、エンコーダーが14GBを丸ごと使えてGPUで走ります。速くなった分の大半はここから来ています。
再現情報
以下はすべて、このrevisionで測っています。LTX-2.5対応は本家にまだマージされていないので、ここでのsd.cppはpwilkinのブランチです。
stable-diffusion.cpp pwilkin/stable-diffusion.cpp @ 1b75452 (ltx-2-5-support)
ComfyUI 8b2d2917
ComfyUI-LTXVideo ac4d998
この上に自分のパッチが2つ乗っていて、どちらも必要です。--dump-conditioningはpwilkinのブランチには入っていません。こちらのパッチはこれで、8ファイルで114行、1b75452にgit applyで当たります。
ac4d998時点のLTXVLoadConditioningは、optionsの辞書をテンソルからだけ組み立てていて、ファイルのメタデータブロックを開きません。そこに書いたunprocessed_ltxav_embedsのフラグはどこにも届かず、connectorが飛ばされて、下に書く茶色いノイズにそのまま行き着きます。こちらのパッチはこれで、ac4d998に当たります。元は前回の記事で、同じフラグがComfyUI自身の保存・読み込みノードを通る途中で落ちていた件の修正です。
sd.cppのビルドにはVULKAN_SDKが要ります。インストーラーはこれをシステム全体の環境変数として書くので、インストール前から開いていたシェルには入っておらず、CMakeがCould NOT find Vulkan (missing: Vulkan_LIBRARY Vulkan_INCLUDE_DIR glslc)で止まります。exportするか、3つのパスを直接渡してください。
cmake .. -G "Visual Studio 17 2022" -A x64 -DSD_VULKAN=ON \
-DVulkan_INCLUDE_DIR="$VULKAN_SDK/Include" \
-DVulkan_LIBRARY="$VULKAN_SDK/Lib/vulkan-1.lib" \
-DVulkan_GLSLC_EXECUTABLE="$VULKAN_SDK/Bin/glslc.exe"
以下のコマンドはGit Bashのものです。実際にそこで走らせています。PowerShellの場合、行末のバックスラッシュはバッククォートに変えるか、1行にまとめてください。
conditioningのファイルを書き出すのがエンコード側です。
sd-cli.exe --mode vid_gen \
--diffusion-model ltx-2.5-Q3_K_S.gguf \
--llm gemma4-12b-with-proj-ltx-2.5-Q5_K_M.gguf \
--vae ltx-2.5-video-vae-conv-bf16.safetensors \
--audio-vae ltx-2.5-audio-vae-bf16.safetensors \
-p "<プロンプト>" -W 768 -H 512 --video-frames 96 --steps 20 \
--cfg-scale 7.0 --rng cpu --seed 42 \
--dump-conditioning cond.safetensors
cond.safetensorsをComfyUI/models/embeddings/に置いて、LTXVLoadConditioningで読み込みます。テンソルはテキストだけのものなので、-W、-H、--video-frames、--stepsの値は中身に影響しません。同じファイルがどの出力サイズでも使えます。
下で測っている768x512、89フレームの生成では、この起動行が合計時間として最短でした。
python main.py --listen 0.0.0.0 --port 8188 \
--use-pytorch-cross-attention --reserve-vram 0 --disable-smart-memory --cpu-vae
512x288なら--cpu-vaeを外して、GPUにデコードさせます。
またVAEを取り違える
最初の実行は、何かが始まる前にバリデーションで落ちました。
VAE tensor 'first_stage_model.decoder.conv_in.conv.bias' not in model metadata
ltx_vae.hppは.conv.を挟むDiffusers形式の名前を期待しています。-bf16のVAEファイルはdecoder.conv_in.weightのような平坦な名前で、テンソルは396個。-conv-bf16のほうがコードの期待する構造で、170個です。前回の記事に書いたdiffusionデコーダーと畳み込みデコーダーの取り違えが、別のランタイムで2回目を踏んだ形で、直し方も同じです。もう一方のファイルを指すだけ。
デコードすると茶色いノイズになる出力
ダンプ自体はそのあと通りました。テンソルは幅6144で出てきます。LTX-2.5が求める数字(映像用の4096と音声用の2048)と一致します。ComfyUIは文句なく読み込みました。サンプリングも20ステップ走りました。デコードされた映像は、何も写っていない平坦な茶色のノイズでした。
エラーはどこにも出ません。形状も系列長も正しく、NaNもなく、最小値と最大値も妥当です。
LTX-2.5のテキスト経路には段が2つあって、sd.cppはそのうち1つを実装しています。1つ目がtext_embedding_projectionで、video_aggregate_embedとaudio_aggregate_embedという2つの線形層の出力を連結して6144になります。2つ目はvideo_embeddings_connectorとaudio_embeddings_connectorという小さなtransformerの対で、それぞれRoPEのself-attentionブロック2層が、系列を128個の学習可能なレジスタトークンで1024以上まで埋めます。
1つ目の出力は、その時点ですでに幅6144です。だから何も文句が出ません。av_model.pyはconditioningにunprocessed_ltxav_embedsのフラグが付いていなければconnectorを飛ばすので、cross-attentionに届く値は、どんな形状チェックにも見えない形で意味を失っています。
connectorをsd.cppに移植する必要は、結果としてありませんでした。あの重みはテキストエンコーダーではなくdiffusionのチェックポイント側に入っていて、ltx-2.5-Q3_K_S.ggufに258個、エンコーダーのファイルには0個です。そしてComfyUIは、conditioningがunprocessedだと分かればその段を自分で走らせます。ダンプ側はそう書いておいて、あとは読み込み済みの重みでComfyUIに仕上げてもらえば済みます。
もう1点あります。埋め込みと一緒にattention_maskのテンソルを書くと、cross-attentionがexpanded size (1024) must match existing size (53)で落ちます。connectorはパディングのあとで自前のマスクを組み立てるので、元のトークン数に合わせたマスクは、無いよりも悪い結果になります。
今のコスト
| 段階 | 時間 |
|---|---|
| ComfyUIのネイティブなエンコード(CPU) | 20分45秒 |
| sd.cppのエンコード(CPU) | 151秒 |
| sd.cppのエンコード(GPU) | 37秒 |
サンプリング自体はこの話に触れていないので、パイプライン全体を正直に確かめるなら、前回の記事が測った設定と比べるのが筋です。512x288、97フレーム、8ステップで、あの記事はconditioningを使い回した状態のサンプリングとデコードにおよそ5.4分と記録しています。同じ条件をこちらで走らせると4分18秒で、内訳はサンプリングが3分30秒(1ステップ26.31秒)、デコードが48秒。この20%の短縮は、下で測っている--reserve-vram 0と--disable-smart-memoryから来ています。エンコードの分離は、上の表にあるテキストエンコードの段階を変えます。
あの記事は画質重視の設定としてQ6_K、1344x768、25ステップも挙げていますが、時間は書いていません。この780Mでその条件を走らせると、サンプリングが1時間18分(1ステップ189秒)、デコードが8分で、合計1時間27分でした。4秒の動画にです。
ここで1つ、前提が変わっています。前回の記事は解像度がほぼ無料だと書いていました。7倍のピクセル数で時間は60%増、理由は生成時間の大半がモデルのロードとオフロードのオーバーヘッドで、ピクセルの計算ではなかったからです。モデルが丸ごと常駐する今はそれが成り立ちません。1344x768は1ステップ189秒、768x512は46秒で、2.6倍のピクセルに4.1倍の時間がかかっています。同じチップで逆の結論になったのは、下にあるメモリの挙動が動いたからです。
2つのフラグと、その衝突
起動スクリプトが最初のComfyUI-TheRockの設定から--reserve-vram 6を持ち続けていて、これはComfyUIが確保してよい量に上限をかけ、残りを他の用途に空けておくものです。この予約が入っていると、モデルは部分的にしか載りません。6021MBが常駐して3552MBがオフロードされ、1ステップ63.2秒。予約を0に落とすと使える量が13065MBになり、モデルが丸ごと収まって、1ステップ46.1秒になります。さらに--disable-smart-memoryを足すと46.0秒で、そこから動かなくなります。これで最初に説明のつかなかったばらつきの理由も分かりました。同じ設定が、ある日は1ステップ46秒、翌日は56秒。ComfyUIのメモリ管理が、そのとき常駐している他のものに合わせて動き続けていたからです。
そしてデコードが落ちます。
モデルが丸ごと常駐した状態だと、768x512で89フレームのVAEデコードがHIPのlaunch failureで死にます。20ステップのサンプリングが全部終わったあと、まったく同じ場所で2回とも落ちました。理由はログに出ています。video VAEを読もうとしたところで、ComfyUIはUnloaded partially: 5913.34 MB freed, 2374.14 MB remains loadedと報告します。この残り2374MBの上に、VAEの2770MBと計算バッファが乗る形になります。--disable-smart-memoryはこれを悪化させます。VRAMを手放さないことが、そもそもこのフラグの役目だからです。
逃げ道は3つあって、どれも同じクリップで測りました。
| 方法 | サンプリング | デコード | 合計 |
|---|---|---|---|
--reserve-vram 6のまま | 21:00 | 1:17 | 22:17 |
| 予約0、タイル分割デコード | 18:39 | 2:01 | 20:40 |
予約0、--disable-smart-memory、--cpu-vae | 15:19 | 3:16 | 18:35 |
タイル分割はGPUでの非分割デコードより44秒余分にかかりますが、2x2では継ぎ目が出ませんでした(タイル境界の両方を確認しています)。デコードをCPUに移すとGPUより2分ほど余計にかかって、それでも勝ちます。サンプリングを全速で走らせられるのがこれだけだからです。512x288ではこの問題自体が出ないので、GPUが48秒で普通にデコードします。上の比較でGPUを使っているのはそのためです。
効くと思っていたものが3つ、何もしませんでした。
--use-ck-attentionはComfy KitchenのAMD向けに調整されたカーネルを使い、同じオフロード状況で比べれば1ステップは確かに速くなります。ただ6GBほど多く余裕を確保するので、また部分読み込みに戻り、素のPyTorch attentionが全部載せで動く場合より遅くなります。GPUが469MBまで空いているのを確認してから2回走らせて、メモリの数字がバイト単位で一致したので、確保しっぱなしのメモリが原因という線は消えました。
--bf16-vaeはただで効きそうに見えました。VAEのファイル名は-bf16で、読み込み時にfp32へ変換されています。bf16を強制するとサンプリングが8%ほど遅くなりました。サンプリングのステップ中にVAEは動かないので、説明がつきません。
--force-non-blockingはNvidia以外のシステム向けと書かれていて、さらに数%を持っていきました。
最後に元の設定でもう一度走らせたら1ステップ46.2秒で、先ほどの46.1秒とほぼ一致しました。これがある分だけ、上の表に意味があります。46秒と56秒のばらつきについて、最初は熱によるドリフトを疑いました。iGPUで長時間ベンチマークを回すのは、それが紛れ込むには格好の状況です。実際はメモリ管理のほうで、--disable-smart-memoryで固定したら数字が動かなくなりました。
結果
エンコードは20分から37秒になり、そこに至るパイプラインは、同じモデルファイルに対して2つの推論エンジンを走らせて、それぞれ得意な側を担当させる形になりました。いちばん時間を食ったのは、片方のエンジンが実装していないモデルの段です。幅だけ正しくて中身のないテンソルを返して失敗するので、気づく方法はフレームをデコードして自分の目で見ることだけです。