|
| 1 | +# LTX-Video 2.3 support — conditional text-to-video works end-to-end |
| 2 | + |
| 3 | +Branch: `feat/ltx-video` in |
| 4 | +<https://github.com/mudler/stable-diffusion.cpp>. Ports Lightricks' LTX-2.3 |
| 5 | +22B audio-video foundation model (`Lightricks/LTX-2.3`) to |
| 6 | +stable-diffusion.cpp, video-only path. **Text conditioning wired via a |
| 7 | +native Gemma-3-12B port** so prompts actually steer the output. |
| 8 | + |
| 9 | +## Status — prompts generate the thing you asked for |
| 10 | + |
| 11 | +Validated on an NVIDIA GB10 (Grace Blackwell, CUDA 13, 119 GB unified memory) |
| 12 | +with `ltx-2.3-22b-distilled.safetensors` (46 GB BF16) + Gemma-3-12B-it |
| 13 | +(24 GB BF16) as text encoder: |
| 14 | + |
| 15 | +| Stage | Result | |
| 16 | +|---|---| |
| 17 | +| LTX version detection (`model.cpp`) | `VERSION_LTXV2` detected on `audio_scale_shift_table` / `audio_patchify_proj` / `audio_adaln_single` / `av_ca_video_scale_shift_adaln_single` / `video_embeddings_connector` | |
| 18 | +| Weight registration | 4444 transformer + 170 VAE + 4 text_embedding_projection tensors registered — **zero missing, zero shape mismatches** vs. the 22B checkpoint | |
| 19 | +| Checkpoint load | 46 GB BF16 loads in ~9 s; audio_vae / vocoder ignored (video-only pipeline) | |
| 20 | +| Gemma-3-12B text encoder | Loads + runs in 5 s on GB10; 49-layer hidden states match HuggingFace to bf16 precision; `text_embedding_projection.video_aggregate_embed` output: std=6.828 (HF: 6.830) | |
| 21 | +| Transformer forward | 48 layers × 32 heads × 128 head-dim (inner_dim 4096), 8 distilled steps in 123 s on GB10 | |
| 22 | +| VAE decode | 9-block decoder with per-channel RMS norm + proper 3-D depth-to-space; 16-frame latent → 121-frame video in 16 s | |
| 23 | +| End-to-end | 704×480×9 WebP in ~14 s; 768×512×121 WebP in ~140 s on GB10; **prompts generate the described subject** (cat → cat, dragon → dragon, etc.) | |
| 24 | +| Quantization | BF16 46 GB → q8_0 28.3 GB via `sd-cli -M convert --type q8_0` in 9.6 s; q8_0 GGUF runs end-to-end | |
| 25 | + |
| 26 | +## What's in the code |
| 27 | + |
| 28 | +**Transformer (`src/ltxv.hpp`)** |
| 29 | +- `LTX2VideoTransformer3DModel` — 48 layers; inner 4096 (32×128), cross-attn dim 4096, caption 4096 |
| 30 | +- `LTXAttention` — qk_norm_across_heads, always-on gated attention (`to_gate_logits` + 2·σ), interleaved and split RoPE variants |
| 31 | +- `LTX2VideoTransformerBlock` — per-block `scale_shift_table` (9, dim), `prompt_scale_shift_table` (2, dim), `scale_shift_table_a2v_ca_video/audio` (5, dim/audio_dim), `audio_scale_shift_table` (9, audio_dim), `audio_prompt_scale_shift_table` (2, audio_dim). Forward path runs **only** video self-attn + prompt cross-attn + FF; audio self-attn, a2v/v2a cross-attn and audio FFN are loaded but skipped (isolate_modalities=True). |
| 32 | +- `AdaLayerNormSingle` with configurable `num_mod_params` |
| 33 | +- `EmbeddingsConnector` — 128 learnable registers + 8 transformer_1d_blocks (gated self-attn + FF) for both video and audio |
| 34 | +- Split 3-D RoPE (video-axis F/H/W, dim/6 freqs per axis, vae_scale_factors (8, 32, 32), `causal_offset=1`, fps scaling, pair-swap rotation) |
| 35 | +- Stub `LTXV2Conditioner` returning zero embeddings of shape `[1, 128, 4096]` |
| 36 | + |
| 37 | +**VAE (`src/ltxv.hpp`)** |
| 38 | +- 9-block encoder: res×4 @128, spatial↓(1,2,2) 128→256, res×6 @256, temporal↓(2,1,1) 256→512, res×4 @512, st↓(2,2,2) 512→1024, res×2 @1024, st↓(2,2,2) 1024→1024, res×2 @1024 |
| 39 | +- Decoder is the exact mirror |
| 40 | +- `VAEResBlock` is the LTX-2.3 simplified shape (two `CausalConv3d` with silu gates, no norms, no timestep modulation) |
| 41 | +- `CausalConv3d` uses `conv.weight` / `conv.bias` names, hardcoded F16 dtype so it stays within the CUDA `ggml_cuda_op_im2col_3d` accepted types |
| 42 | +- `VAEUpsampler` pixel-shuffle drops the first `st_t − 1` frames after each temporal upsample so `f_out = (f_in − 1) × st_t + 1` composes across all upsamples |
| 43 | + |
| 44 | +**Pipeline wiring (`src/stable-diffusion.cpp` etc.)** |
| 45 | +- `VERSION_LTXV2` / `sd_version_is_ltxv2` / `sd_version_is_dit` entry |
| 46 | +- VAE factory arm builds `LTXV::LTXVVAERunner` |
| 47 | +- FLOW_PRED with `default_flow_shift = 3.0` |
| 48 | +- Latent channels 128, VAE scale factor 32, temporal compression 8 |
| 49 | +- Frame count padded to 8k+1 (LTX-2.3 I/O spec) |
| 50 | +- Ignore prefixes: `audio_vae.`, `vocoder.`, `text_embedding_projection.` |
| 51 | + |
| 52 | +## Numerical correctness — resolved |
| 53 | + |
| 54 | +Nine bugs were diagnosed and fixed by working backwards from the VAE output |
| 55 | +(and later the text-conditioning path) using graph-level probes. Each one is |
| 56 | +noted here because the same mistake is easy to make again porting future |
| 57 | +video VAE/DiT stacks: |
| 58 | + |
| 59 | +1. **EmbeddingsConnector pre-norm.** Reference |
| 60 | + `_BasicTransformerBlock1D.forward` does `rms_norm(hidden_states)` before |
| 61 | + both attn1 and ff (and a final `rms_norm` after the stack). We had |
| 62 | + bare `x = x + attn(x); x = x + ff(x)` — residuals compounded across 8 |
| 63 | + blocks and drove the connector output to std≈1e12, exploding cross-attn |
| 64 | + in every transformer block. |
| 65 | + |
| 66 | +2. **Final `norm_out` before the scale/shift + `proj_out`.** Reference |
| 67 | + `LTXModel._process_output` is |
| 68 | + `x = norm_out(x); x = x * (1 + scale) + shift; x = proj_out(x)`. |
| 69 | + Without the LayerNorm the post-block activation (std≈285 after 48 |
| 70 | + layers) leaked into the predicted velocity and the sampler diverged. |
| 71 | + Transformer output std went from 57 → 1.0 after adding `ggml_norm`. |
| 72 | + |
| 73 | +3. **VAE `conv_norm_out` + SiLU before `conv_out`.** The reference decoder |
| 74 | + ends with `sample = conv_norm_out(sample); sample = silu(sample); |
| 75 | + sample = conv_out(sample)`. We were skipping the PixelNorm+SiLU, so |
| 76 | + output pixels were O(1000) instead of O(1). |
| 77 | + |
| 78 | +4. **Latent per-channel normalisation.** `vae.per_channel_statistics.*` |
| 79 | + is now materialised to CPU and applied in `diffusion_to_vae_latents` |
| 80 | + (`x * std + mean`) / `vae_to_diffusion_latents` (`(x - mean) / std`). |
| 81 | + |
| 82 | +5. **VAE depth-to-space ordering.** `ggml_reshape_4d` alone doesn't |
| 83 | + implement einops `b (c p1 p2 p3) f h w -> b c (f p1) (h p2) (w p3)` — |
| 84 | + the sub-indices come out in the wrong order. Replaced with a proper |
| 85 | + `depth_to_space_3d` helper that decomposes the channel axis through |
| 86 | + permute+cont passes so p3 lands inner-of-W, p2 inner-of-H, p1 |
| 87 | + inner-of-F. Eliminated the visible banding. |
| 88 | + |
| 89 | +6. **Gemma-3 49-layer concat layout.** `ggml_concat(hidden_all[i], |
| 90 | + axis=0)` produces a flat axis with layer-slow / hidden-fast ordering, |
| 91 | + but HF's `reshape(B, T, D*L)` produces hidden-slow / layer-fast. |
| 92 | + `text_embedding_projection.video_aggregate_embed` was trained for the |
| 93 | + HF layout — a transposed input made the projection output essentially |
| 94 | + noise and all prompts generated the same scene. Fixed by stacking |
| 95 | + along axis 2 → permute(2, 0, 1, 3) → reshape to [D*L, T, 1]. |
| 96 | + |
| 97 | +7. **EmbeddingsConnector register layout.** Reference |
| 98 | + `_replace_padded_with_learnable_registers` produces a **fixed |
| 99 | + 128-token** output with real text at positions [0..L-1] and |
| 100 | + `learnable_registers[L..127]` at [L..127]. We were concatenating |
| 101 | + registers+text to 128+L tokens in the wrong order. Rewrote the |
| 102 | + connector's register path. |
| 103 | + |
| 104 | +8. **Double attention scaling in Gemma-3.** Gemma-3 uses |
| 105 | + `scale = 1/sqrt(query_pre_attn_scalar) = 1/sqrt(head_dim)` for the |
| 106 | + 12B variant — and `ggml_ext_attention_ext` applies the same |
| 107 | + `1/sqrt(d_head)` internally. Applying both multiplied the softmax |
| 108 | + temperature by 1/16, collapsing attention to near-uniform and |
| 109 | + producing a persistent ~sqrt(D) "attention sink" outlier at the same |
| 110 | + hidden dim for every layer. Dropping the explicit Q scale made the |
| 111 | + Gemma forward match HF to bf16 precision. |
| 112 | + |
| 113 | +9. **Two different patchify conventions in `ops.py` vs `sampling.py`.** |
| 114 | + `DepthToSpaceUpsample` (intermediate upsamplers) uses |
| 115 | + `b (c p1 p2 p3) d h w -> b c (d p1) (h p2) (w p3)` — p3 (w-stride) |
| 116 | + innermost in the channel axis. `ops.py::unpatchify` (the decoder's |
| 117 | + final 4×4 un-patch) uses |
| 118 | + `b (c p r q) f h w -> b c (f p) (h q) (w r)` — q (h_patch) innermost. |
| 119 | + We were reusing the upsampler helper for the final unpatchify, which |
| 120 | + silently transposed every 4×4 output block and left a visible fine- |
| 121 | + scale hatching artefact that survived every diffusion step. Added a |
| 122 | + dedicated `depth_to_space_3d_patch` that swaps the inner (p_w, p_h) |
| 123 | + pair of the channel axis before delegating, matching the reference |
| 124 | + layout exactly. |
| 125 | + |
| 126 | +Cross-checked against the 22B checkpoint's embedded config |
| 127 | +(`safetensors __metadata__["config"]["vae"]`): `norm_layer=pixel_norm`, |
| 128 | +`spatial_padding_mode=zeros`, `timestep_conditioning=false`, |
| 129 | +`causal_decoder=false`, patch_size=4, and none of the `compress_all` |
| 130 | +decoder blocks sets `residual=True` — so the residual skip from |
| 131 | +`DepthToSpaceUpsample` is correctly absent here. |
| 132 | + |
| 133 | +End-to-end result: prompts now actually generate the described content. |
| 134 | +Seed 42 with *"a cat walking across a grassy field"* produces exactly |
| 135 | +that. Per-layer Gemma hidden states match HF to bf16 noise; the |
| 136 | +projected cross-attention features match HF (min/max/std 0.0%/0.2%/0.03% |
| 137 | +different). |
| 138 | + |
| 139 | +## Remaining items (future sessions) |
| 140 | + |
| 141 | +1. **Audio branch.** Roughly half of the LTX transformer buffer is |
| 142 | + audio-related (`audio_attn1/2`, `audio_to_video_attn`, |
| 143 | + `video_to_audio_attn`, `audio_embeddings_connector`, |
| 144 | + `audio_scale_shift_table`, etc.). Adding joint audio+video generation |
| 145 | + also needs the `audio_vae` (102 tensors), the HiFi-GAN-style |
| 146 | + `vocoder` (1227 tensors), and the BWE upsampler. Non-trivial. |
| 147 | + |
| 148 | +2. **Schedule for non-distilled variants.** The 22B non-distilled model |
| 149 | + uses LTX2Scheduler (token-count-dependent shift, stretched to a |
| 150 | + terminal value). Only the distilled 8-step table is wired up today. |
| 151 | + |
| 152 | +3. **Quantised Gemma.** Gemma-3-12B is 24 GB in BF16. A q8_0 or q4_k |
| 153 | + conversion would drop it to ~12 GB / ~7 GB — useful for smaller |
| 154 | + hardware. The existing sd-cli `-M convert` path should handle it. |
| 155 | + |
| 156 | +## How to run the e2e test |
| 157 | + |
| 158 | +First, grab the two model artefacts: |
| 159 | + |
| 160 | +```bash |
| 161 | +# LTX-2.3 distilled 22B (46 GB BF16 safetensors): |
| 162 | +hf download Lightricks/LTX-2.3 ltx-2.3-22b-distilled.safetensors \ |
| 163 | + --local-dir ltxv-models |
| 164 | + |
| 165 | +# Gemma-3-12B-it (tokenizer.model + 5x safetensors shards, ~24 GB BF16): |
| 166 | +hf download google/gemma-3-12b-it --local-dir gemma-3-12b-it |
| 167 | +``` |
| 168 | + |
| 169 | +Then run with the distilled 8-step schedule (auto-selected when |
| 170 | +`--steps 8` is passed on an ltxv2 model): |
| 171 | + |
| 172 | +```bash |
| 173 | +./sd-cli -M vid_gen \ |
| 174 | + -m ltxv-models/ltx-2.3-22b-distilled.safetensors \ |
| 175 | + --text-encoder gemma-3-12b-it \ |
| 176 | + -p "a cat walking across a grassy field" \ |
| 177 | + -W 704 -H 480 --video-frames 9 \ |
| 178 | + --steps 8 --cfg-scale 1 \ |
| 179 | + -o /tmp/ltx23.webp --seed 42 |
| 180 | + |
| 181 | +# Official distilled shape (768x512, 121 frames, ~140 s on GB10): |
| 182 | +./sd-cli -M vid_gen \ |
| 183 | + -m ltxv-models/ltx-2.3-22b-distilled.safetensors \ |
| 184 | + --text-encoder gemma-3-12b-it \ |
| 185 | + -p "a cat walking across a grassy field" \ |
| 186 | + -W 768 -H 512 --video-frames 121 \ |
| 187 | + --steps 8 --cfg-scale 1 \ |
| 188 | + -o /tmp/ltx23.webp --seed 42 |
| 189 | + |
| 190 | +# Without --text-encoder: LTX runs unconditionally (zero embeddings), |
| 191 | +# pipeline still produces valid frames but ignores the prompt. |
| 192 | + |
| 193 | +# Quantise the LTX DiT to q8_0 GGUF (46 GB -> 28 GB): |
| 194 | +./sd-cli -M convert \ |
| 195 | + -m ltxv-models/ltx-2.3-22b-distilled.safetensors \ |
| 196 | + -o ltxv-models/ltx-2.3-22b-distilled-q8_0.gguf \ |
| 197 | + --type q8_0 |
| 198 | +``` |
| 199 | + |
| 200 | +## References |
| 201 | + |
| 202 | +- LTX-2.3 model card: https://huggingface.co/Lightricks/LTX-2.3 |
| 203 | +- Diffusers LTX-2.0 reference (not an exact match for 2.3): |
| 204 | + https://github.com/huggingface/diffusers/blob/main/src/diffusers/models/transformers/transformer_ltx2.py |
| 205 | +- Upstream ltx-pipelines (Lightricks): |
| 206 | + https://github.com/Lightricks/LTX-2/tree/main/packages/ltx-pipelines |
0 commit comments