Skip to content

Add FLUX.2-klein-4B text-to-image and image-editing sample (chunked DiT on CompiledModel GPU) - #227

Open
john-rocky wants to merge 3 commits into
google-ai-edge:mainfrom
john-rocky:flux2-klein-text-to-image-sample
Open

Add FLUX.2-klein-4B text-to-image and image-editing sample (chunked DiT on CompiledModel GPU)#227
john-rocky wants to merge 3 commits into
google-ai-edge:mainfrom
john-rocky:flux2-klein-text-to-image-sample

Conversation

@john-rocky

Copy link
Copy Markdown
Contributor

FLUX.2 [klein] 4B (Black Forest Labs, Apache-2.0) generating images end to end on the LiteRT CompiledModel GPU delegate. Nothing runs on the CPU: the Qwen3-4B text encoder, the 4B rectified-flow transformer and the VAE decoder are all Accelerator.GPU graphs.

The upstream model card describes klein as running "on consumer GPUs, with as little as 13 GB VRAM". This sample runs the same weights on a Pixel 8a's Mali-G610.

Device Pixel 8a, 306 s (encoder 35 s, ~70 s/step, VAE 7 s)
Quality PSNR 36.8 dB / corr 0.9987 vs the fp32 diffusers pipeline
Graphs 12 x INTEGER-int8, 6.2 GB total, largest 912 MB
Weights https://huggingface.co/litert-community/FLUX.2-klein-4B-LiteRT

What it demonstrates

Sequential residency. CompiledModel cannot load a graph over 2 GB, and the GPU budget is well under that anyway. The model is split into chunks resident one at a time, so peak footprint is a single ~912 MB graph. Chunks of roughly 500 MB - 1 GB are the sweet spot; a 1.8 GB chunk sends the shader compiler into a pathological blowup.

A distilled sampling loop. klein reports is_distilled, so the pipeline runs no classifier-free guidance: one transformer pass per step rather than two, and a plain flow-matching Euler update.

Host/GPU split. Tokenization, embed_tokens, the mask, both rotary tables, the scheduler and the two tail permutations are precomputed by conversion/gen_prep_klein.py. The tail (unpack by position id, batch-norm denorm, 2x2 unpatchify) is two pure permutations around one elementwise op, so the permutations ship as int32 gather maps recovered with an arange probe through the stock pipeline functions.

Two GPU-delegate constraints worth flagging

Neither is visible to a desktop op check: the graphs compile, report zero unsupported operations, and return wrong numbers.

  1. BROADCAST_TO is rejected outright, and Tensor.expand lowers to it. Grouped-query attention's repeat_kv hits this and the 4D tensor limit in one expression, so it is rewritten as a CONCATENATION of per-KV-head slices — same head order, exact.

  2. A broadcast ADD whose left operand is a BATCH_MATMUL result is silently miscomputed. That is softmax(q @ kᵀ * scale + mask[1,1,S,S]), i.e. every masked attention. There is no error and no NaN: the probabilities still sum to 1 and still honour the causal and padding masks, but the logits are wrong and the image comes out as structured garbage. The fix here is to hand the mask in already expanded to [1, num_heads, S, S] — zero extra ops, same formula.

    The diagnostic signature is that token 0 is bit-exact and every later token is wrong, since token 0 attends only to key 0. A broken RoPE looks identical, because RoPE is the identity at position 0. conversion/probe_enc_layer.py exports one encoder layer with a tap at every stage, which is how this was localized; materializing an intermediate as a graph output made the bug disappear, which is what identified the fused pattern.

I'm happy to file the second one as a separate delegate issue if that's useful — it affects any encoder-style transformer with a [1,1,S,S] mask.

Verification

conversion/gen_verify_klein.py runs the exact device loop on the host, driven only by the twelve exported .tflite graphs, and reports PSNR against the fp32 reference. The Kotlin is a transcription of that loop.

Note that the desktop int8 path is a pessimistic proxy: the same graphs score 36.4 dB through the host CPU int8 kernels and 44.1 dB on the GPU delegate.

Directory

Placed at compiled_model_api/text_to_image/flux2_klein_kotlin_gpu/ so it can sit alongside another text-to-image sample. Happy to rename if you'd prefer a different convention.

…l GPU)

FLUX.2 [klein] 4B (Apache-2.0) generating images end to end on the LiteRT
CompiledModel GPU delegate. The 4B rectified-flow transformer and its 4B Qwen3
text encoder are exported as twelve INTEGER-int8 graphs (6.2 GB total, largest
912 MB) and executed one at a time, so the peak footprint is a single graph.
klein is step-wise distilled: 4 steps, no classifier-free guidance, one
transformer pass per step.

Pixel 8a: 306 s, PSNR 36.8 dB / corr 0.9987 against the fp32 diffusers pipeline.
Graphs: huggingface.co/litert-community/FLUX.2-klein-4B-LiteRT

The sample encodes three GPU-delegate constraints, two of which are invisible to
a desktop op check -- the graphs compile, report zero unsupported operations, and
return wrong numbers:

- BROADCAST_TO is rejected outright, and Tensor.expand lowers to it. Grouped-query
  attention's repeat_kv hits this and the 4D tensor limit in one expression, so it
  is rewritten as a CONCATENATION of per-KV-head slices.

- A broadcast ADD whose left operand is a BATCH_MATMUL result is silently
  miscomputed, which is softmax(q @ k^T * scale + mask[1,1,S,S]) -- every masked
  attention. The probabilities still sum to 1 and still honour the causal and
  padding masks, but the logits are wrong. The mask is therefore handed in
  pre-expanded to [1, num_heads, S, S]: zero extra ops, same formula.

- Compute is forced to FP32; the modulated (adaLN) blocks return NaN in fp16.

conversion/ reproduces every graph, stages the host inputs, and runs the exact
device loop on the host driven only by the exported .tflite files.
conversion/probe_enc_layer.py taps one encoder layer at every stage; that is what
localized the masked-attention miscompile.
klein is natively an image-editing model: Flux2KleinPipeline takes `image` as
its first argument and text-to-image is the `image=None` case. Editing
VAE-encodes the reference and appends its latent tokens to the noise tokens
before every step, which grows the joint sequence from 768 to 1024. The weights
are unchanged, so the kce_* graphs are the same tensors re-exported at the
longer shape and the chunk sizes are byte-identical.

Device-verified on a Pixel 8a: every chunk fully delegates to LITERT_CL in one
partition, PSNR 44.3 dB / SSIM 0.9998 against the fp32 diffusers pipeline, and
editing costs about +7% wall-clock over generation.

The app adds one graph beyond the twelve: kv_vae_enc.tflite. Its mode() chunks
the 64-channel moments in half, which lowers to the banned SPLIT; slicing the
first 32 channels directly is bit-exact and GPU-clean.
@john-rocky john-rocky changed the title Add FLUX.2-klein-4B text-to-image sample (chunked DiT on CompiledModel GPU) Add FLUX.2-klein-4B text-to-image and image-editing sample (chunked DiT on CompiledModel GPU) Jul 10, 2026
The sample baked the prompt into the staged tensors, so a text-to-image demo
you could not type a prompt into. Only two staged tensors depend on the words -
inputs_embeds and enc_mask - so the app now carries a faithful Qwen2Tokenizer
port and looks the token rows up in a memory-mapped fp16 copy of the Qwen3
embedding table (a GATHER over 151936 rows is not a GPU op, and the row is the
graph's input anyway).

Device-verified on a Pixel 8a: a typed 'a blue ceramic teapot on a marble
counter, morning light' generates the teapot, not the baked apple. The tokenizer
matches Python byte-for-byte on a 20-case fixture (CJK, emoji, contractions,
zero-width space); the fp16 embedding table is within 3e-8 of fp32 because the
checkpoint is bf16.

When klein_tokenizer/ is staged the app shows editable prompt fields; otherwise
it falls back to the baked prompt. Adds export_tokenizer_klein.py and the 778 MB
embedding table.
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