Add Gemma-4 E-series (E2B/E4B) support: PLE, KV-layer sharing, double-wide MLP, and a dense GGUF parser - #59
Conversation
…-wide MLP, dense GGUF Implements the three E-series architecture features the gemma4 module lacked, so the small dense Gemma-4 models (E2B/E4B) load and run: - Per-Layer Embeddings (PLE): auxiliary per-layer embedding + context projection injected into every decoder layer (math ported from transformers gemma4). The large per-layer-embedding table can stay in host RAM via a new model-declared cpu_offloaded_weight_keys() hook (the engine honors it in weight materialization), or load as a compact Q6_K GGUFEmbedding on GPU. - KV-layer sharing: the last num_kv_shared_layers layers carry q_proj only and reuse the K/V of the last non-shared layer of their attention type, via a CUDA-graph-safe per-type stash (verified correct with graphs on). - Double-wide MLP on the KV-shared layers. - Dense (non-MoE) Gemma-4 GGUF parser: parse_gguf_config detects dense (no expert metadata) and reads PLE / shared-kv / double-wide / per-layer ffn from GGUF metadata; iter_gguf_weights maps the PLE tensors and shared-layer q-only; convert_gemma4_to_gguf swaps the PLE table + shared q_proj to native GGUF ops. All new paths gate on per_layer_hidden_size>0 / num_kv_shared_layers>0 / moe_enabled, so the existing dense/MoE Gemma-4 line (12B/26B/31B) and every other model are unchanged. Verified: gemma-4-E2B-it generates correct output on an 8 GB RTX 5070 both as bf16 (PLE offloaded to host RAM) and as the Q4_0 GGUF (~168 tok/s with CUDA graphs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Triaged while assembling a merged deployment branch for a 2× RTX 6000 Ada / 2× Xeon Gold 6526Y Linux box serving DeepSeek-V4-Flash with offloaded experts, to benchmark the open PRs together. Gemma-4 E-series. Different model family, not merged or tested here. Flagging only so the absence of a report from me is not read as a problem found — I merged and benchmarked #30, #48, #56, #69, #70, #71 and #81, and left this one out deliberately. |
|
@gdevenyi Are you planning on trying to test support for that model? I made the change since it is the only way to get Gemma-4 fully running on my AMD Radeon 890M GPU and not spill into the CPU |
Loads standard dense Llama GGUF checkpoints alongside the existing gemma4 path. Two llama-GGUF-specific gotchas beyond the generic K-quant dequant: - Tokenizer: Llama-3 GGUFs declare tokenizer.ggml.model="gpt2" (byte-BPE), not SentencePiece. Route those to transformers' gpt2 converter; the llama (SentencePiece) converter drops spaces and mis-splits tokens. - Q/K rope permutation: llama.cpp permutes attn_q/attn_k output rows for its adjacent-pair rope. FreeToken uses NeoX (split-half) rope, so un-permute those rows (whole-row reorder of the packed K-quant bytes) at load, else q/k rotate on the wrong dim pairs and generation degenerates. Adds Q4_K/Q5_K dequant, mixed per-tensor quant types (Q4_K + Q6_K on some attn_v/ffn_down), a tied LM head over the GGUF embedding, and llama3 long-context rope recovery from the baked rope_freqs.weight tensor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New --kv-dtype auto|bfloat16|fp8_e4m3|fp8_e5m2 stores the paged MHA/SWA KV cache in fp8 while queries stay bf16 and the FlashInfer fp8 kernels dequantize on read (scale 1.0 -- post-rope k/v magnitudes sit inside e4m3 range). Halves KV bytes/token, ~doubling the token budget that fits in memory (Llama-3.2-3B on an 8GB card: 37,720 -> 75,440 tokens), so long prompts that overflowed the bf16 ceiling now fit; e4m3 preserves coherence in testing. Threaded via EngineConfig.kv_dtype/resolved_kv_dtype (the cost model in spec_kv_bytes_per_token prices the KV dtype's itemsize), MHAKVCache.store_kv (casts bf16->fp8 before the same-width store), and the fi backend (splits q_data_type/kv_data_type in plan() via a new Context.compute_dtype, dropping the deprecated data_type alias). Default (no flag) path is unchanged: KV stays the model dtype. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Points CUDA_HOME/PATH/LD_LIBRARY_PATH at the pip-installed nvidia/cu13 toolkit (nvcc/cicc/ptxas) that FlashInfer's runtime CUDA-JIT kernels need, then execs `ft`. Without it a bare `ft serve` fails fp8/decode kernel compilation with "Could not find nvcc". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@mkornreich I had a spare H100 box and It generates correctly with CUDA graphs off. ft serve --model <gemma-4-E2B-it snapshot> --served-model-name gemma4-e2b \
--host 127.0.0.1 --port 21941 --cuda-graph-max-bs 0boots to With graphs enabled (the default) it fails during capture, in the per-layer-embedding path:
Separately, the double-wide layer split matches what you implemented. On Tested on: H100 80GB HBM3 (sm_90), driver 580.126.16, Xeon Platinum 8462Y+, Linux 5.15.0-157-generic, nvcc 13.1, torch 2.11.0+cu130, Python 3.12.13, PR branch at 6486907 on top of Thanks for putting this together — happy to re-test if you push a fix for the capture path. |
…raph capture crash) @rakhimovv reported that with CUDA graphs on (the default) capture crashes in the per-layer-embedding path: CpuPerLayerEmbedding did a per-forward `input_ids.to("cpu")` host gather, which graph capture forbids ("Cannot copy between CPU and CUDA tensors during CUDA graph capture"). Pinning the staging buffer would only hide the capture error -- the host gather still cannot be replayed inside a graph. - Default: the per-layer-embedding table is now a normal on-GPU VocabParallelEmbedding (graph-safe), so the default graphs-on path captures. (The GGUF path already used an on-GPU Q6_K GGUFEmbedding.) - Keeping the ~4.7 GB table in host RAM to save VRAM is now opt-in via FREETOKEN_GEMMA4_PLE_CPU=1. Its host gather can't be captured, so the model reports supports_cuda_graph=False and the engine disables CUDA graphs for it (with a logged reason) instead of crashing on the default flags. Verified on an 8 GB RTX 5070: the opt-in offload path boots on default flags (graphs auto-disabled) with correct output; the GGUF path still captures graphs (~171 tok/s). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@rakhimovv thanks for the H100 run and the precise trace — that pinpointed it exactly. You're right that capture dies on the host round-trip in the per-layer-embedding path. One thing worth noting: pinning the Pushed
Verified on an 8 GB RTX 5070:
Would appreciate a re-test of the default (no-env) path on the H100. And thanks for independently confirming the double-wide split lines up with the 20 KV-shared layers. |
|
Re-tested Default path, no env, graphs on — captures and serves. Greedy output is byte-identical to the The per-layer inputs are live under replay, not frozen at capture. Since a stale PLE was the failure mode you were guarding against, I sent four unrelated greedy prompts through the captured decode graph rather than relying on the one:
All correct and all prompt-specific, which a capture-time-frozen table could not produce. Opt-in host path also behaves as described. Same environment as before: H100 80GB HBM3 (sm_90), driver 580.126.16, Xeon Platinum 8462Y+, Linux 5.15.0-157-generic, nvcc 13.1, torch 2.11.0+cu130, Python 3.12.13, checkpoint No blockers left from my side on NVIDIA hardware. |
Summary
Adds support for the Gemma-4 E-series (E2B / E4B) — the small dense models — which previously could not load on FreeToken. The
gemma4module targeted only the dense/MoE line (12B / 26B-A4B / 31B) and the GGUF parser was MoE-only, so E2B/E4B failed: the safetensors path silently ignored the E-series features (Per-Layer Embeddings, KV-layer sharing, double-wide MLP) and the GGUF path raisedKeyErroronexpert_count.This implements those three features (math ported from the
transformersgemma4 reference) plus a dense (non-MoE) GGUF parser.What's added
embed_tokens_per_layer+per_layer_model_projection+ a per-layergate → gelu → ⊙ per_layer_input → proj → normstep inserted after the feed-forward sandwich and beforelayer_scalar. The large per-layer table (~4.7 GB bf16) can be kept in host RAM via a new genericcpu_offloaded_weight_keys()model hook that_materialize_loaded_weight_state_dicthonors — so E2B fits an 8 GB GPU. In the GGUF path it loads as a compact Q6_KGGUFEmbeddingon-GPU instead.num_kv_shared_layerslayers carryq_projonly and reuse the K/V of the last non-shared layer of the same attention type (sliding vs full), via a CUDA-graph-safe per-type stash (the source layer publishes post-rope K / post-norm V into a persistent buffer; shared layers read it). Verified correct with CUDA graphs enabled.use_double_wide_mlp).parse_gguf_configdetects dense (no expert metadata) and reads PLE /shared_kv_layers/ per-layerfeed_forward_length(→ double-wide) /k_eq_v(viavalue_length);iter_gguf_weightsmaps the PLE tensors and drops shared-layer k/v;convert_gemma4_to_ggufswaps the PLE table and shared-layerq_projto native GGUF ops.ModelConfiggainsper_layer_hidden_size,per_layer_vocab_size,num_kv_shared_layers,use_double_wide_mlp, plusis_kv_shared_layer/is_kv_source_layerhelpers.Scope / safety
Every new code path gates on
per_layer_hidden_size > 0/num_kv_shared_layers > 0/moe_enabled, so the existing dense/MoE Gemma-4 checkpoints (12B / 26B / 31B) and all other models are unaffected.Verification
google/gemma-4-E2B-itgenerates coherent, correct output on an 8 GB RTX 5070, both ways:Samples: "capital of France" → "Paris"; "15 + 27" → "42"; three primes → 2, 3, 5.
Notes / follow-ups
🤖 Generated with Claude Code