Skip to content

Gemma3 NF4 on GPU: quantized encoder, batch encoding, byte-LUT eager dequant - #39

Merged
TroyHernandez merged 5 commits into
mainfrom
perf/gemma3-gpu-nf4
Jul 21, 2026
Merged

Gemma3 NF4 on GPU: quantized encoder, batch encoding, byte-LUT eager dequant#39
TroyHernandez merged 5 commits into
mainfrom
perf/gemma3-gpu-nf4

Conversation

@TroyHernandez

Copy link
Copy Markdown
Contributor

Summary

Puts the Gemma3 12B text encoder on the GPU via NF4 quantization, replacing the 45 GB fp32 CPU path for LTX prompt encoding.

  • gemma3_quantize_nf4() streams the HF snapshot into a ~8 GB artifact (one-time 52 s; 336 projection weights cast, vision tower dropped, resident embeddings/norms at bf16).
  • load_gemma3_nf4() builds the skeleton at compute dtype, swaps projections for NF4 modules, hard-errors on unfilled params; load_gemma3_text_encoder() dispatches automatically when pointed at an artifact.
  • gemma3_encode_batch() encodes a prompt vector in sub-batches with optional per-prompt disk caching (resumable, returns paths) — encode a whole batch of prompts once, swap the encoder off, render from cache.
  • The eager NF4 dequant now shares the byte-LUT with the jit block stack, speeding every eager NF4 forward (Gemma3, FLUX.1, LTX fallbacks).
  • Two bug fixes found along the way: txt2vid_ltx2(decode_audio = FALSE, filename =) crashed via R partial matching feeding raw audio latents to the WAV writer; and torch_save of a storage-offset view serializes from the base storage (upstream torch bug — narrow()$contiguous() doesn't help because the view is already contiguous; the batch helper clones).

Measurements (RTX 5060 Ti, gemma-3-12b-it)

fp32 CPU (before) NF4 GPU
Encoder load 29–32 s 12–23 s
Encode (1024 tokens) 24–26 s 7.1 s
Host RAM 45 GB ~8 GB
Phase swap n/a pin 3.3 s once; 0.3 s on / 0.0 s off

Embedding drift vs fp32: cosine 0.989, rel RMS 0.19 — same-seed 768x512x49 renders come out scene-equivalent (same creature, stage, crowd, lighting; framing nudge comparable to sampler jitter). Fresh-prompt render cost drops from ~70 s to ~54 s.

Validation

  • 36 new tinytest results across three files: quantize/load round-trip with hermetic fp32 residents, NF4 module swap, forward parity (cosine > 0.99), loader dispatch, unfilled-param hard error, batch-vs-solo parity, cache resume, and cache round-trip content (the test that caught the torch_save offset-view bug).
  • Full local suite green before the batch helper (902 results); CI runs the complete branch.

gemma3_quantize_nf4() streams the HF snapshot into an NF4 artifact:
the 7 projection weights per layer (~11B of 12B params) pack to NF4,
embeddings and norms stay at the resident dtype, and vision-tower /
projector weights are dropped (the text encoder never uses them).
~8 GB artifact vs 45 GB of host RAM for the fp32 CPU path, small
enough to sit on a 16 GB card during the encode phase.

load_gemma3_nf4() builds the skeleton at the compute dtype, swaps the
projections for ltx23_nf4_linear modules, fills residents, and
hard-errors on unfilled parameters; load_gemma3_text_encoder()
dispatches to it when pointed at an artifact directory.

The eager NF4 dequant (ltx23_nf4_dequantize) now uses the byte-LUT
shared with the jit block stack: one 1-based int64 index per packed
byte and a single gather straight into the output buffer, replacing
the nibble-unpack / per-value-index / float32-staging chain. Scratch
shrinks to one int64 buffer. This speeds every eager NF4 forward
(Gemma3, FLUX.1, and the LTX fallback paths).

Tests: quantize/load round-trip through a tiny HF-layout snapshot
(hermetic fp32 residents), NF4 module swap, forward parity within
quantization tolerance (cosine > 0.99), loader dispatch, and the
unfilled-parameter hard error.
txt2vid with decode_video = TRUE, decode_audio = FALSE, and a filename
crashed in write_wav: result$audio partial-matched result$audio_latents
(no audio element exists on that path), passing the raw latents tensor.
Exact [[ ]] indexing restores the intended NULL -> no-audio mux.
Encodes a character vector of prompts in batch_size chunks (bounding
activation VRAM) and, with cache_dir, writes each prompt's result to
gemma3-<md5>.pt and returns the paths - already-cached prompts are
skipped, so interrupted batches resume. Without cache_dir it returns
the per-prompt embedding list. Budget note in the docs: each result
is the full hidden-state stack the LTX connectors consume (~0.4 GB at
1024 tokens).

Includes a workaround for an R torch serialization bug found while
testing: torch_save of a storage-offset view (e.g. narrow of a
contiguous batch) serializes from the BASE storage, so every row
saves as row 1; narrow()$contiguous() does not help because the view
is already contiguous. clone() forces fresh storage. Minimal repro:
torch_save(x$narrow(1,2,1)$contiguous()) loads x[1]'s data.

Tests: batched-vs-solo parity through a namespace-stubbed tokenizer,
cache path stability, resume without re-encode, cache round-trip
content (would have caught the offset-view bug), and incremental
cache growth.
@TroyHernandez
TroyHernandez merged commit 3af7bee into main Jul 21, 2026
2 checks passed
@TroyHernandez
TroyHernandez deleted the perf/gemma3-gpu-nf4 branch July 21, 2026 03:44
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