Skip to content

LTX-2.3 video generation: clean-room implementation with GPU-poor support#16

Merged
TroyHernandez merged 49 commits into
mainfrom
feat/ltx23
Jul 7, 2026
Merged

LTX-2.3 video generation: clean-room implementation with GPU-poor support#16
TroyHernandez merged 49 commits into
mainfrom
feat/ltx23

Conversation

@TroyHernandez

Copy link
Copy Markdown
Contributor

What

Ground-up LTX-2.3 audio+video generation, written from the Apache-2.0 HuggingFace diffusers reference. Replaces the previous LTX-2.0 port entirely (removed in the first commits of this branch).

  • Full pipeline: txt2vid_ltx2() — 22B dual-stream DiT (48 blocks, split RoPE, gated attention, 2.3 prompt/cross-attn modulation), text connectors with learnable registers, video VAE with spatial+temporal tiled decode, audio VAE, BigVGAN-style vocoder with bandwidth extension to 48kHz, Gemma3-12B text encoder, single-stage distilled (8 steps, CFG=1) and two-stage HQ with the x2 latent upsampler.
  • Provenance: every technique documented idea-by-idea in inst/REFERENCES.md; attribution in inst/COPYRIGHTS and DESCRIPTION. Weights are user-downloaded from Lightricks under the LTX-2 Community License, never redistributed.
  • GPU-poor support, validated on an RTX 5060 Ti 16GB:
    • NF4 4-bit quantization (QLoRA scheme, pure torch ops) keeps the 22B transformer GPU-resident at ~11.4GB
    • fp8 e4m3fn quantization with CPU-pinned per-layer streaming (official cast policy, near-bf16 quality)
    • Phase-sequential offloading, persistent dequant/attention scratch buffers, adaptive attention query chunking, in-place feed-forward GELU, tiled VAE decode
  • Measured: 1280x704, 121 frames, with audio, in ~22 minutes at a 15.7GB peak (NF4). Real-prompt end-to-end render verified (Gemma3 CPU encode → NF4 denoise → tiled decode → vocoder → mux).

Tests / checks

  • 334 tinytest results, 0 failures — module-level parity vs diffusers fixtures at 1e-4/1e-5 tolerance, plus checkpoint key-census tests (0 unmapped / 0 unfilled on all 5 weight groups of the real 46GB checkpoint)
  • R CMD check: 0 errors, 0 warnings, 2 benign NOTEs (test-fixture size, one long usage line)
  • treesitR coverage report (tools/ltx23_compare.R): every diffusers LTX-2.3 entity ported or a documented skip

Version bumped to 0.1.0.

The LTX-2.0 port is superseded by a ground-up LTX-2.3 implementation
written from the Apache-2.0 diffusers reference. Generic VRAM utilities
and the SDXL memory profile move to R/vram.R and R/memory_sdxl.R;
encode_with_gemma3() now returns raw stacked hidden states for the
downstream connector modules. Kept: Gemma3 encoder, BPE tokenizer,
FlowMatch scheduler.
- DESCRIPTION: cph entries for cornball.ai, HuggingFace (diffusers ports),
  and Lightricks (constants/format facts); ORCID for maintainer
- inst/COPYRIGHTS: enumerates derivation sources and weight licensing
- ref/ and tasks/ gitignored + Rbuildignored; upstream reference checkouts
  live in ref/upstream/ and are never committed
- CLAUDE.md: LTX section rewritten for the 2.3 clean-room rewrite;
  stale 2.0 API docs removed, generic implementation facts kept
- Drop tracked pyrotechnics conversions in ref/ (superseded by the real
  diffusers checkout in ref/upstream/)
ltx23_open_checkpoint() validates model_version metadata and parses the
embedded component config; ltx23_split_keys() routes the flat key space
to component groups (dit, connectors, vae, audio_vae, vocoder);
ltx23_load_group() streams tensors one at a time into module params with
two-sided coverage reporting. Tested against a tiny fake checkpoint with
official-style keys.
Split and interleaved RoPE application plus the audio/video coordinate
and frequency embedder (float64 frequencies per checkpoint config).
Parity-tested against fixtures generated from the diffusers reference
via tools/gen_fixtures.sh (uv-run, CPU torch; not used at test time).
Dual-stream transformer with gated attention, 9-param cross-attention
modulation, prompt AdaLN, split RoPE, a2v/v2a cross attention with
global+per-block modulation, STG perturbation, and optional query
chunking for the manual SDPA. Includes the official-checkpoint key
mapper. Parity-tested against a tiny reference model at 1e-4.
Per-token RMS norm over raw stacked Gemma3 states, per-modality
sqrt-ratio scaling and projections, learnable-register padding
replacement with stable front-alignment, per-connector 1D split RoPE,
and 8-layer gated connector transformers. Parity-tested at 1e-4.
Per-channel RMS norm, causal 3D convs, pixel-shuffle up/downsamplers,
2.3 block structure (encoder last block 1024; 4-up-block decoder with
mixed spatiotemporal/temporal/spatial upsampling, no residuals, zeros
padding), latent statistics buffers, and the flat-to-nested official
key mapper. Parity-tested at 1e-5.
Audio VAE: causal conv2d (height axis), pixel norm, nin_shortcut
resnets, nearest-upsample stages, 4F-3 frame crop. Vocoder: BigVGAN
style with snakebeta + anti-aliased kaiser-sinc resampling (base-R
besselI window), 3-branch-mean resnets, checkpoint-loaded STFT/mel
bases, BWE residual over a hann-resampled skip to 48kHz. Encoder
deliberately not ported (t2v never encodes audio).
txt2vid_ltx2(): Gemma3 + connectors text encoding, joint audio/video
denoising over the official 8-step distilled schedule (CFG-free, Euler
velocity steps in float32, hoisted RoPE coords), packed-latent audio
statistics handling, video/audio decoding, base-R WAV writer, and av
muxing. ltx23_load_pipeline() streams all components from the single
checkpoint with strict key coverage. Smoke-tested end-to-end with tiny
random-weight components.
ltx23_quantize_fp8() streams the 46GB checkpoint into fp8 shards using
the official cast policy (DiT attention/FFN linears, absmax/448 scales);
ltx23_load_transformer_fp8() swaps those linears for CPU-resident
(optionally pinned) fp8 modules that dequantize on the compute device.
ltx23_memory_profile() + ltx23_tune_gc() port the whisper allocator
tuning; download_ltx2() adds consent-gated hfhub downloads.

Also fixes R-scalar dtype promotion throughout the LTX modules (infix
scalar arithmetic promotes bf16 to fp32; switched to tensor methods),
which broke bf16 inference.
ltx23_latent_upsampler ports the 2.3 upscaler (Conv3d ResBlock stages
around a per-frame 2x pixel shuffle; no rational resampler) with AdaIN
and tone-map latent filters from the Apache diffusers reference.
txt2vid_ltx2(two_stage = TRUE) denoises at half resolution, upsamples
the unnormalized latents, re-noises BOTH modalities at the stage-2
entry sigma, and refines over the stage-2 schedule.
ltx23_load_pipeline() now accepts the fp8 artifact directory and loads
the transformer with CPU-resident streaming fp8 weights.
Parses the diffusers reference and the R port with tree-sitter and
checks every top-level reference entity against a curated mapping that
also documents deliberate omissions. Fix the Gemma3 text encoder shard
names in download_ltx2().
Repair the corrupted roxygen blocks in CLIPTokenizer.R and
filename_from_prompt.R (stray backtick comment lines), switch Blackwell
detection to torch::cuda_get_device_capability (0-based device index),
and complete missing @param entries. Check is now clean apart from the
CRAN-incoming and Rd-width NOTEs.
Connectors, VAEs, vocoder, and upsampler now move to the compute device
only for their phase and return to the CPU afterwards, leaving the
denoise phase as the sole GPU tenant (frees several GB of resident
weights during the transformer steps).
QLoRA-style 4-bit NormalFloat: per-64-block absmax against the 16-level
quantile code, packed nibble pairs, pure-torch bucketize/index_select
quantize and dequantize (no custom kernels). At ~4.5 bits/param the 22B
transformer stays resident on a 16GB card, removing the ~21GB/step PCIe
streaming of the fp8 path (~9% weight round-trip error vs fp8's ~1%).
ltx23_load_pipeline dispatches on the artifact manifest format.
high = NF4-resident transformer + phase offloading (measured baseline:
fp8 streaming peaked 11.6GB at 512x320x49 without offloading);
medium/low = fp8 CPU-streaming with pinned memory and tighter caps.
With ~11.4GB of resident NF4 weights, the per-linear dequantized bf16
weights (up to 128MB each, 28 per block) piled up faster than R's GC
collected them and OOM'd the first step. Each distinct weight shape now
dequantizes into one long-lived buffer, released at the denoise/decode
boundary.
The nibble/index/value temporaries (up to ~300MB per linear, 28 linears
per block) were R garbage between block-level gc calls and still OOM'd
with 11.4GB resident. Unpack, index lookup (torch_index_select_out with
an allocating fallback), scaling, and the output write now all run
in-place against per-device scratch buffers.
The text connectors are 3.3B params (6.35GB bf16) and cannot share a
16GB card with the resident NF4 transformer (11.4GB): the earlier OOMs
happened during connector onload, not denoising (a bare NF4 forward
runs flat at 12.0GB across all 48 blocks). Each phase is now truly the
sole GPU tenant: text encode alone, then the transformer onloads for
denoising and offloads before decode (~3s of transfers per generation).
The 768x512x121 target hit the unchunked [1,32,6144,6144] attention
matrix (2.25GB). Query chunks are now sized from a memory budget
(diffuseR.attn_budget, default 1GB) against the actual key length, with
any explicit attn_chunk acting as an upper bound.
At 768x512x121 (S=6144) the chunked attention temporaries were multi-GB
R garbage per block and fragmented the allocator (1.7GB reserved but
unallocated at OOM). The score matrix and context output now compute
in-place into reusable per-shape buffers via torch's internal
matmul_out/softmax_out (with an allocating fallback), released together
with the dequant buffers at the denoise/decode boundary.
ltx23_load_pipeline defaults PYTORCH_CUDA_ALLOC_CONF to
expandable_segments before the first CUDA allocation; the high profile
caps at the hardware-validated 768x512x121 (peak 15.7GB on a 16GB card)
with adaptive attention chunking handling the budget.
Port the diffusers spatial + temporal tiled decode (overlapping tiles,
crossfaded seams; reference defaults 512px/448 stride, 16f/8 stride) --
required at 1280x704x121 where full-decode activations run ~7GB per
tensor. ltx23_tune_gc now follows the documented allocator semantics:
raising cuda_allocator_reserved_rate disables collection for
large-resident workloads, so it instead lowers allocated_rate and
defaults expandable segments.
Idea-by-idea references for the LTX-2.3 implementation, all independent
of Wan2GP: diffusers/transformers module ports, QLoRA NF4, official
Lightricks fp8 policy facts, diffusers offloading/tiling/slicing, the
mlverse allocator docs, and our own measured engineering. Also: tune_gc
follows the storm-stopper recipe (A/B measured inert here: 91s vs 106s
per step with R-gc ~86-89% either way -- the cost is our explicit
per-block gc syncs, not the allocator callback; JIT-compiling the block
per the torch skill is the roadmap), and per-block gc is now the
opt-out diffuseR.block_gc option (required for both quantized paths at
high resolution).
nnf_gelu allocates a second copy of its input, which at 1280x704
(14080 video tokens) doubles the 440MB FFN intermediate -- that
allocation was the OOM point on the 16GB card. Above a size threshold
(diffuseR.gelu_inplace_min, default 1e8 elements) the proj output is
now mutated in place via the internal torch_gelu_ kernel, chunked so
internal temporaries stay small, with the allocating path as fallback.
Parity vs nnf_gelu is exact (tested).
Validated on the RTX 5060 Ti 16GB: 1280x704x121f with audio in 1354s
at a 15.7GB peak. The 5e8 default put ~1GB of score scratch on the
card at 14080 video tokens (one ~500MB buffer per attention shape);
1.5e8 keeps the pair under ~400MB and is a no-op below ~2000 tokens
where the sequence fits in one chunk anyway.
The forward collected the state after every layer AND the post-norm
output -- 50 entries for the 48-layer model. HF transformers (the
reference and what the LTX connectors were trained against) records
the state BEFORE each layer plus the post-final-norm output: 49
entries, with the un-normed last-layer output never included. The
extra state made the connector projection fail its first matmul
(1024x192000 vs 188160x4096) on the first real-prompt encode.
Regression test with a tiny config pins the count and the exact
identity of the last collected state.
The venv rode in with the original LTX-2.0 commit and its lib64
symlink dangles once uv relinks the interpreter, which breaks the
R CMD build copy step. Venvs are regenerable; ignore them everywhere.
The describe-block '9\%' double escape truncated
ltx23_memory_profile.Rd mid-item (reworded); the index_select_out
resolver now returns the function into a local before calling so
codetools sees a callable binding. Check: 0 errors, 0 warnings,
2 benign NOTEs.
The 48-block step now runs as one torch::jit_compile'd TorchScript
call (the JIT-decode pattern from the torch skill, proven in whisper
and chatterbox): weights passed per call as a flat List[Tensor] with
a fixed 114-slot per-block layout, NF4 dequant chunked in-script,
attention through fused scaled_dot_product_attention instead of a
materialized score matrix, and no R-side intermediates -- which
removes the per-block gc() syncs that profiling showed were ~86% of
eager step time. Gated on diffuseR.jit_blocks (default TRUE) and
NF4-quantized 2.3 blocks with no STG/self-mask/isolation extras;
anything else falls back to the eager loop. Parity tests: single
block and 2-block stack vs eager at 1e-4 (fp32 CPU), mask
effectiveness, NULL masks.
Two integration bugs the tiny-config test missed: the split-rope
freqs from the model are per-head 4D [B, H, T, r] (the script now
mirrors both branches of the eager apply; tests cover 4D and 3D), and
nn_module_list children are named, which made the packed weight list
marshal as Dict[str, Tensor] instead of List[Tensor] (unnamed now).
Measured on the RTX 5060 Ti at the 768x512x121 shape (S=6144):
eager 99.9s/step at 88% R-gc; compiled 8.8s/step at 1% R-gc.
@TroyHernandez

Copy link
Copy Markdown
Contributor Author

Roadmap follow-ups pushed to this branch:

TorchScript-compiled block stack (R/jit_ltx23.R): the 48-block NF4 step now runs as a single torch::jit_compile call — weights passed as a flat List[Tensor], NF4 dequant in-script, fused SDPA, no per-block gc. Measured on the RTX 5060 Ti 16GB:

  • Block-stack A/B at the 768x512 shape: eager 99.9s/step (88% R-gc) → compiled 8.8s/step (1% R-gc), 11.4x
  • End-to-end 1280x704x121f with audio: 389s vs 1331s (denoise 17.4s/step steady); same-seed output scene-identical to the eager path
  • Gated on diffuseR.jit_blocks (default TRUE) with eager fallback for STG/self-mask/isolation/non-NF4; parity-tested vs eager at 1e-4 (12 tests)

NF4 vs fp8 quality A/B (same seed, 1280x704, files in the render dir): fp8 is crisper on fine geometry, NF4 slightly softer with minor composition drift, both prompt-faithful and artifact-free. fp8 streaming was also faster than eager NF4 at this resolution (882s vs 1331s), but NF4+JIT now leads at 389s.

Full suite: 346 tests, 0 failures.

The video decoder, audio decoder, and vocoder are static feed-forward
graphs, so torch::jit_trace converts them wholesale: one crossing per
tile/forward, libtorch frees intermediates eagerly. Traces are
shape-specialized (runtime sizes bake in as constants; wrong shapes
error), so they cache per instance/shape/dtype/device/tag and
re-trace on miss -- at 1280x704 the tiled decode has ~4 distinct tile
shapes. A trace pins the weight tensors it captured, so the pipeline
drops all traces on every phase offload. diffuseR.jit_vae (default
TRUE) with the eager modules as fallback; parity exact on tiny
configs (10 tests: direct, tiled, retrace-on-new-shape, cache reuse,
release, audio decoder, BWE vocoder).
Profiling the 1280x704 decode phase: 32.5s eager with 86% of it in R
gc -- and the storm survives every allocator option because it is
freeing work, not trigger frequency (each gc releases a tile's worth
of CUDA blocks; expandable_segments makes each free a page-unmap).
PYTORCH_CUDA_ALLOC_CONF=backend:native alone cuts decode to 21.0s /
51% gc.

jit_trace of the decoders works and is guarded three ways after
root-causing a lantern tracer hazard: R gc firing mid-recording (via
the allocator callback under memory pressure) corrupts captured
argument values -- observed as garbage/swapped narrow starts on the
full-size decoder, 5/5 clean re-traces once gc cannot fire mid-trace.
The traced path now gcs before every trace, tryCatches, and validates
each fresh trace against eager output (mismatch = permanent per-shape
blacklist), so silent corruption is impossible. It still re-pays
trace+validation every render (traces release on phase offload, they
pin captured GPU weights), which measured slower than eager for a
single decode (39.8s vs 32.6s), so it ships opt-in via
options(diffuseR.jit_vae = TRUE).

Also: ltx23_load_pipeline now applies ltx23_tune_gc; .detect_vram
falls back to nvidia-smi instead of guessing 8GB when gpuctl is
absent (the guess silently skewed the tuned rate); per-tile gc in the
tiled decode is gated to the eager path; verbose renders print
per-phase timings (video decode / array conversion / audio chain /
encode+mux). 356 tests, 0 failures.
A/B on the 1280x704 tiled decode (fresh process per condition, per
the mlverse memory-management article): the two callback gates we had
never set -- torch.cuda_allocator_allocated_rate and
torch.cuda_allocator_allocated_reserved_rate (defaults 0.8) -- take
the decode from 32.7s to 21.5s under expandable segments and cut the
R-gc share from 50% to 32% on the native backend, with no wall-time
downside anywhere. ltx23_tune_gc now sets both to 0.95 (only-if-unset).

The loader's PYTORCH_CUDA_ALLOC_CONF default is now per-format: nf4
gets backend:native (the compiled block stack keeps intermediates out
of R, native frees are cheap, and the 1280x704 fit is validated --
full corgi render 327s vs 389s under expandable); fp8/eager keep
expandable_segments, the only configuration their max-resolution fit
was validated under.
The ~110s of per-render 'phase traffic' was never PCIe transfer: it
was allocator pool regrowth. clear_vram()'s cuda_empty_cache between
phases returned every block to the driver, so each phase re-grew the
pool one cudaMalloc per tensor (~15ms x 5530 = 83.5s measured for the
NF4 transformer's onload alone; 4.6s after a pre-warm; back to 83.3s
after one empty_cache). The loader now pre-warms the pool with a
single footprint-sized allocation freed into the cache, and the
pipeline's phase offloads gc without emptying the cache so the next
phase carves from cached blocks. Corgi 1280x704x121: 327s -> 221s
(video decode phase 35.6s -> 24.9s from the same fix).

Pinned staging is implemented and parity-tested (pin once at load,
non-blocking DMA onload, offload as a pointer swap since inference
never mutates weights) but ships opt-in (diffuseR.pin_staging):
page-locking ~20GB adds ~30s to load and saves only ~2.7s per render
once transfers are no longer the bottleneck -- breaks even around 11
renders per pipeline.
Port of the frame-conditioning mechanics from the Apache-2.0 diffusers
reference (pipeline_ltx2_image2video.py, pipeline_ltx2_condition.py),
restricted to prefix conditioning at latent index 0 with strength 1:
txt2vid_ltx2(image=) seeds the first frame from a PNG/JPEG or array;
txt2vid_ltx2(condition_video=, conditioning_frames=) freezes the tail
of a previous clip as the new clip's opening frames (the continuation
primitive behind chained talking-head chunks). Conditioned tokens are
VAE-encoded (argmax + per-channel normalization), see a per-token
video timestep of zero, and are frozen through the Euler updates;
audio_timestep stays scalar. The JIT block stack handles the per-token
modulation unchanged (parity-tested).

Real-weight validation: encoder round trip on a 1280x704 frame at
38.8 dB PSNR. 20 helper unit tests (preprocess geometry, mask
placement, partial-noise blend, argmax encode), 6 pipeline smoke
tests, 2 JIT per-token parity tests.
Lip-sync support: txt2vid_ltx2(audio=) encodes user audio (MP3/WAV via
av, or a [2, samples] matrix) into clean audio latents that the video
attends to while denoising -- the audio stream sees timestep/sigma
zero and skips its Euler updates (the audio-side mirror of the Apache
i2v conditioning pattern), and the original samples are muxed into the
output.

New pieces: the LTX2AudioEncoder port (Apache diffusers reference;
the checkpoint's 44 encoder tensors now load, audio_vae census
102/102, 0 unmapped / 0 unfilled) and a 16 kHz log-mel frontend built
from the checkpoint's preprocessing spec (STFT 1024/160 causal, 64
slaney-normed mel bins to 8 kHz). Both basis constructors were
verified against the checkpoint's stored vocoder bases (identical up
to bf16 rounding), and the full chain validated on real speech:
4.5s TTS through encode -> decode -> vocoder returns 48 kHz audio
with 0.91 envelope correlation to the input.

Tests: 13 encoder structure/shape tests, 11 frontend/geometry tests,
4 pipeline smoke tests (audio-conditioned run, original audio carried
through).
@TroyHernandez TroyHernandez merged commit ae915f7 into main Jul 7, 2026
0 of 2 checks passed
@TroyHernandez TroyHernandez deleted the feat/ltx23 branch July 7, 2026 18:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant