Skip to content

FLUX.2 Klein 4B text-to-image support#21

Merged
TroyHernandez merged 84 commits into
mainfrom
feature/flux2-klein
Jul 7, 2026
Merged

FLUX.2 Klein 4B text-to-image support#21
TroyHernandez merged 84 commits into
mainfrom
feature/flux2-klein

Conversation

@TroyHernandez

Copy link
Copy Markdown
Contributor

FLUX.2 klein-4B: step-distilled text-to-image (4 steps, no CFG) on consumer GPUs. Stacked on the FLUX.1 branch (#17); the diff here is FLUX.2-only.

What's new

  • FLUX.2 MMDiT (flux2_transformer): 5 double + 20 single blocks with model-level shared modulation, SwiGLU feed-forwards (gate fused into linear_in), ViT-22B-style parallel single blocks (fused QKV+MLP projections), 4-axis RoPE (theta 2000), all linears bias-free.
  • Qwen3 text encoder (qwen3_encoder): GQA with per-head q/k norms, causal + padding mask, mid-stack hidden states (9/18/27) concatenated per token; runs only as deep as the last requested layer.
  • Byte-level BPE tokenizer (qwen_bpe_tokenizer): GPT-2 byte table + GPT-4-style split regex + rank-based merges over integer ids (three atomic vectors, findInterval lookups — see perf note), with the Qwen3 chat template.
  • FLUX.2 VAE (flux2_vae_decoder): post_quant_conv + reused decoder body at 32 latent channels + BatchNorm latent statistics (FLUX.2 has no scalar scaling factor).
  • Pipeline (txt2img_flux2, flux2_load_pipeline, download_flux2_klein): empirical-mu dynamic shifting through the existing FlowMatch scheduler, GPU-resident fp8 (~3.9 GB artifact from the ungated repo), per-phase offloading; flux_quantize/flux_load_transformer now auto-dispatch FLUX.1 vs FLUX.2 on the config class.

Verification

  • 98 tinytest assertions across 8 new files, parity-tested against random-init diffusers/transformers fixtures; tokenizer exact-id parity (34 cases incl. emoji/CJK and chat-template padding); FLUX.1 and LTX fp8 regressions green.
  • Cast census exact: 104 weights (5x12 + 20x2 + 3 modulations + context_embedder).
  • AST-based translation audit extended to the FLUX.2/Qwen3 pairings: no drift.
  • R CMD check: 0 errors, 0 warnings.
  • Measured on an RTX 5060 Ti 16 GB: 1024x1024 in 48 s (peak 8.2 GB), 512x512 in ~40 s, pipeline load 31 s.

Performance note

Initial generations ran 130-200 s with the GPU at 2% utilization; Rprof(gc.profiling=TRUE) showed 93.8% of wall time in R gc. torch's allocator callbacks legitimately run gc hundreds of times per generation, and the tokenizer's 151k-binding vocab/merge environments made each gc 17x slower (12 -> 203 ms). The integer-id tokenizer rewrite plus buffered fp8 dequant and allocator tuning brought 1024px from 193 s to 48 s. Lesson recorded: big lookup tables as atomic vectors, never environments.

Provenance

All ported code traces to Apache-2.0 sources (diffusers transformer_flux2.py / pipeline_flux2_klein.py / autoencoder_kl_flux2.py, transformers models/qwen3/), recorded per-technique in inst/REFERENCES.md. Weights are user-downloaded from the ungated, Apache-2.0 black-forest-labs/FLUX.2-klein-4B repo and never redistributed.

Notes

  • Scope is txt2img; the reference-image (KV) pipeline variant and img2img are follow-ups.
  • Default 4 steps per the model card; guidance_scale has no effect (distilled, guidance_embeds=false).
  • Version bump deferred to the main-bound merge, same as FLUX.1-schnell text-to-image support #17.

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.
…r tuning

Generation was 93.8% R gc (Rprof): torch's allocator callbacks fire
hundreds of gcs per generation, and the tokenizer's 151k-binding vocab
and merge environments made each one 17x slower. The tokenizer now
keeps three atomic vectors and a byte table (BPE on integer ids via
findInterval); fp8 linears dequantize into persistent buffers; klein
runs backend:native with the footprint sized to the largest phase.
512px: 133.5s -> 39.3s; 1024px: 193s -> 48.1s (peak 8.22GB).
@TroyHernandez TroyHernandez merged commit 4d92007 into main Jul 7, 2026
0 of 2 checks passed
@TroyHernandez TroyHernandez deleted the feature/flux2-klein branch July 7, 2026 21:46
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