Skip to content

Gemma 4 E2B: GGUF INT4 direct conversion + QNN HTP lowering - #407

Merged
justinchuby merged 15 commits into
mainfrom
justinchuby/gemma4-gguf-qnn
Jul 25, 2026
Merged

Gemma 4 E2B: GGUF INT4 direct conversion + QNN HTP lowering#407
justinchuby merged 15 commits into
mainfrom
justinchuby/gemma4-gguf-qnn

Conversation

@justinchuby

@justinchuby justinchuby commented Jul 16, 2026

Copy link
Copy Markdown
Member

Summary

Enables converting the unsloth/gemma-4-E2B-it GGUF (Q4_K_M) directly into a working INT4 ONNX model that targets the QNN EP / Hexagon HTP — reusing the GGUF's existing INT4 weights instead of running expensive Olive quantization. Pairs with olive-recipes#432 (adds a GGUF-direct config_gguf.json).

What's here

1. Gemma 4 GGUF INT4 conversion fixes (make mobius build-gguf --keep-quantized work for E2B):

  • Collapse per-layer feed_forward_length array → scalar intermediate_size + use_double_wide_mlp.
  • Map the top-level per-layer-input tensors + strip the layer_scalar.weight artefact.
  • Thread the INT4 MatMulNBits linear class through Gemma4 (--keep-quantized was silently producing a float model) + tie the float LM head.
  • Fix KV-shared standard-Attention shape inference (opset-24 Attention present outputs are un-inferred → o_proj collapsed → model unloadable).
  • Fix the Gemma GGUF activation default (silugelu_pytorch_tanh; the wrong default produced garbage output).

2. QNN HTP lowering (two EP-capability-gated transforms; both default-on, off for qnn):

  • supports_attention=FalseDecomposeAttentionPass rewrites the fused opset-24 Attention op into SDPA primitives (Reshape/Transpose/MatMul/Softmax/Add, Tile for GQA). An onnx_ir InPlacePass so it can rewire the 3 outputs (present-KV are graph outputs on some layers, dead on shared-KV layers — mixed arity the pattern rewriter can't express).
  • supports_matmul_nbits=Falsecom.microsoft::MatMulNBits lowered to blocked-uint4 DequantizeLinear + MatMul (QDQ), implemented as a model-local ir.Function expanded by the existing InlinePass mechanism (like SkipLayerNormalization / PackedMultiHeadAttention). Supported EPs keep the native contrib op + kernel.

Verification

  • Dequantized GGUF weights match the gated google/gemma-4-E2B-it checkpoint (corr ≥ 0.98).
  • Both lowerings are numerically identical to the originals via ORT (maxdiff 0.0 for prefill/decode/GQA/mask; ~5e-7 with softcap due to fp rounding).
  • The INT4 model generates correct text on ORT ("capital of France → Paris", "capital of Japan → Tokyo", valid haiku).
  • A full qnn build lowers to 0 Attention / 35 Softmax and 0 MatMulNBits / 205 DequantizeLinear, all 30 present-KV graph outputs preserved; CPU/CUDA builds unchanged (still emit GQA + MatMulNBits).
  • Empirically confirmed on a Snapdragon X box (ORT 1.27, QNN SDK 2.48.40): HTP runs the primitive ops (MatMul/Softmax/RMSNormalization/Gelu) but not fused Attention/MatMulNBits — exactly what this lowering removes. (Plugin QNN EP is engaged via add_provider_for_devices, and HTP requires fully static shapes.)

Remaining / follow-up

  • Unit tests for DecomposeAttentionPass and the MatMulNBits function body (in progress).
  • End-to-end HTP EPContext compile validation (olive-recipes config_gguf.json path) not yet hardware-validated.

🤖 Generated with GitHub Copilot CLI

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@justinchuby justinchuby changed the title Fix gemma 4 gguf issues Gemma 4 E2B: GGUF INT4 direct conversion + QNN HTP lowering Jul 17, 2026
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

Performance Comparison

Comparing 5544f2c17d49ba

Model Metric Baseline Current Delta
bert (feature-extraction) model_size_bytes 359 KB 359 KB +0.0%
bert (feature-extraction) num_nodes 60 60 +0.0%
falcon model_size_bytes 364 KB 364 KB +0.0%
falcon num_nodes 68 68 +0.0%
gemma2 model_size_bytes 428 KB 428 KB +0.0%
gemma2 num_nodes 107 107 +0.0%
gpt2 model_size_bytes 388 KB 388 KB +0.0%
gpt2 num_nodes 54 54 +0.0%
llama model_size_bytes 425 KB 425 KB +0.0%
llama num_nodes 62 62 +0.0%
llama (static-cache) model_size_bytes 425 KB 425 KB +0.0%
llama (static-cache) num_nodes 58 58 +0.0%
mamba (ssm-text-generation) model_size_bytes 296 KB 296 KB +0.0%
mamba (ssm-text-generation) num_nodes 98 98 +0.0%
phi3 model_size_bytes 421 KB 421 KB +0.0%
phi3 num_nodes 60 60 +0.0%
phi3 (static-cache) model_size_bytes 421 KB 421 KB +0.0%
phi3 (static-cache) num_nodes 56 56 +0.0%
qwen2 model_size_bytes 425 KB 425 KB +0.0%
qwen2 num_nodes 62 62 +0.0%
qwen2 (static-cache) model_size_bytes 425 KB 425 KB +0.0%
qwen2 (static-cache) num_nodes 58 58 +0.0%
qwen3_5_moe (hybrid-text-generation) model_size_bytes 506 KB 506 KB +0.0%
qwen3_5_moe (hybrid-text-generation) num_nodes 275 275 +0.0%
qwen3_5_text (hybrid-text-generation) model_size_bytes 458 KB 458 KB +0.0%
qwen3_5_text (hybrid-text-generation) num_nodes 129 129 +0.0%
qwen3_5_vl (hybrid-qwen-vl) model_size_bytes 977 KB 977 KB +0.0%
qwen3_5_vl (hybrid-qwen-vl) num_nodes 413 413 +0.0%
t5 (seq2seq) model_size_bytes 836 KB 836 KB +0.0%
t5 (seq2seq) num_nodes 166 166 +0.0%
whisper (speech-to-text) model_size_bytes 1008 KB 1008 KB +0.0%
whisper (speech-to-text) num_nodes 128 128 +0.0%

No performance regressions.

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

🏗️ Architecture Diff

Comparing 5544f2c17d49ba

Model Sub-model Changes Status
bert (feature-extraction) model 0
falcon model 0
gemma2 model 0
gemma4 (gemma4) decoder 0
gemma4 (gemma4) embedding 0
gemma4 (gemma4) vision_encoder 0
gemma4_text model 0
gpt2 model 0
llama model 0
llama (static-cache) model 0
mamba (ssm-text-generation) model 0
phi3 model 0
phi3 (static-cache) model 0
qwen model 0
qwen (static-cache) model 0
qwen2 model 0
qwen2 (static-cache) model 0
qwen2_moe model 0
qwen2_moe (static-cache) model 0
qwen3 model 0
qwen3 (static-cache) model 0
qwen3_5_moe (hybrid-text-generation) model 0
qwen3_5_text (hybrid-text-generation) model 0
qwen3_5_vl (hybrid-qwen-vl) decoder 0
qwen3_5_vl (hybrid-qwen-vl) embedding 0
qwen3_5_vl (hybrid-qwen-vl) vision_encoder 0
qwen3_moe model 0
qwen3_moe (static-cache) model 0
qwen3_next (hybrid-text-generation) model 0
t5 (seq2seq) decoder 0
t5 (seq2seq) encoder 0
whisper (speech-to-text) decoder 0
whisper (speech-to-text) encoder 0

No architecture changes detected.


Legend: ⚪ No change · 🔵 Minor (attrs/inits) · 🟡 Moderate (nodes added/removed) · 🔴 Major (interface changed)

@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

@justinchuby

Copy link
Copy Markdown
Member Author

Update: unit tests added + HTP execution validated on-device

Tests (commit f74b017):

  • _decompose_attention_test.py — numerical parity vs the fused opset-24 Attention op across prefill / decode-with-past / MHA / GQA / softcap / mask-only, KV-cache graph-output rewiring, rank-4 left-untouched, and EP gating.
  • matmul_nbits_test.py — the inlined QDQ function body matches the native MatMulNBits op bit-for-bit (incl. odd n_blocks), plus EP gating.
  • 303 tests pass across functions / rewrite_rules / gguf suites; ruff clean.

On-device HTP validation (Snapdragon X, ORT 1.27, onnxruntime-qnn 2.4.0 / QNN SDK 2.48.40):

  • Built a tiny gemma4_text for qnn (f16), pinned static input shapes, engaged the plugin QNN EP via register_execution_provider_library + SessionOptions.add_provider_for_devices(npu).
  • The lowered model compiles onto the Hexagon HTP (QNN graph-preparation stages run) and produces finite logits — confirming the decomposed attention + QDQ actually offload to the NPU. Standalone probes confirmed the fused Attention op and MatMulNBits do not run on HTP (they're what this lowering removes); MatMul / Softmax / RMSNormalization / Gelu / DequantizeLinear all do.
  • Filed the upstream gap as QNN EP does not support the standard ONNX Attention op (opset 23/24) onnxruntime-qnn#646 (QNN EP lacks a standard ONNX Attention kernel).

A few index/control ops (CumSum / Range / int64 gather) still fall to CPU under fallback — a follow-up, not a blocker for the attention/matmul compute offload.

@justinchuby

Copy link
Copy Markdown
Member Author

Session update: build-gguf --static-cache + QNN HTP findings

New this branch (commit 84fcf06): build-gguf now accepts --static-cache / --max-seq-len, threading a static KV cache (pre-allocated fixed-width buffers written in place via TensorScatter) through build_from_ggufCausalLMTask. This produces a fully static-shaped graph as required by fixed-shape runtimes such as the QNN HTP backend. Added TestBuildGgufStaticCache (fixed-width key_cache/value_cache I/O, no dynamic past_key_values.*; explicit-task rejection). All gguf-builder + CLI tests pass; ruff clean; no new mypy errors.

QNN HTP experiment findings (Snapdragon X, ORT 1.27, onnxruntime-qnn 2.4.0):

  • --ep qnn lowering verified: the GGUF INT4 E2B build decomposes the fused opset-24 Attention and inlines MatMulNBits → QDQ (0 Attention/MatMulNBits nodes remain; 205 DequantizeLinear + nibble-unpack).
  • Full 35-layer / 4.65B E2B fails single-graph HTP compose on size (EP_FAIL: Failed to compose Qnn graph). Smaller subsets compose fine → needs model splitting + weight-shared EPContext binaries.
  • Olive StaticLLM QNN path assumes the ORT-GenAI static-KV contract (past_seq_len/total_seq_len + sliding-window KV); mobius's decomposed standard-attention output doesn't match.

Top remaining prerequisite: gemma4 does not implement the StaticCacheState dispatch (KV-shared layers + sliding/full alternation + dual local/global RoPE), so --static-cache currently raises TypeError for gemma4. This is correctness-critical surgery on the most complex model in mobius and needs HF numerical-parity validation — intentionally not rushed unsupervised.

CPU tok/s baselines to beat: llama.cpp 43.7, mobius ONNX (ORT CPU) 18.2, HF fp32 0.04.

Recipe docs (olive-recipes PR #432) updated to match.

@justinchuby

Copy link
Copy Markdown
Member Author

gemma4 static KV cache (commit 5d804b7) + HTP findings

Implemented & verifiedGemma4TextAttention/Gemma4DecoderLayer now support CausalLMTask(static_cache=True) (previously rejected). Handles gemma4's specifics: KV-shared layers borrow the source layer's static buffer (no own cache), dual head_dim (sliding 256 / full 512) via Gemma4TextModel.static_kv_cache_specs(), and a static-cache attention bias for both layer types.

Correctness: static-cache decode logits match the dynamic reference at every position (~1e-6 fp32) across sliding-only, dual-head_dim, and KV-shared configs; and the full E2B static model generates correct text on ORT CPU: build-gguf "...gemma-4-E2B-it-Q4_K_M.gguf" --keep-quantized --ep qnn --dtype f16 --static-cache --max-seq-len 512"The capital of France is Paris." The graph is fully static + decomposed + QDQ (0 fused Attention, 0 MatMulNBits, 30 TensorScatter, 15 cache layers with dual head_dim). Added 3 build-graph tests; all gemma4/task/static tests pass; ruff clean; no new mypy errors.

⚠️ Note (debugging gotcha, now in repo memory): mobius emits the attention mask value dtype.min (−3.4e38) as a graph FLOAT initializer. Test harnesses that randomly fill all float initializers corrupt the mask and report spurious per-position mismatches — skip |value|>1e30 initializers.

HTP status (Snapdragon X, onnxruntime-qnn 2.4.0): the E2B still needs splitting for HTP:

  1. the 4.7 GB per-layer embedding overflows QNN's 32-bit static-tensor size → keep embeddings on CPU (splitting after the embedding Gathers gives a 2.15 GB transformer that composes on HTP);
  2. the 35-layer transformer fails single-graph HTP finalization (~20 min) — a 4-layer chunk composes+runs on HTP in ~21 s → needs weight-shared EPContext chunking;
  3. perf: the decompose+inline-QDQ form runs slowly on HTP (~0.2 tok/s, ops fall to CPU) — a reasonable NPU number needs Olive MatMulNBitsToQDQ (clean int4, no runtime nibble-unpack) + OnnxStaticQuantization (int activations) + chunked EPContext, now unblocked by this static-cache support.

Recipe docs updated in olive-recipes PR #432.

@justinchuby

Copy link
Copy Markdown
Member Author

Full HTP op offload via mobius qnn lowering (commit be98116)

Changed the mobius QNN lowering so a static-cache decoder runs fully on the Hexagon HTP (no CPU fallback). Empirically probed (Snapdragon X, onnxruntime-qnn 2.4.0, session.disable_cpu_ep_fallback=1) which ops the HTP has no kernel for — each forced its node to CPU — and lowered them all:

CPU-forced op Lowered to
fused Attention SDPA primitives (already)
RotaryEmbedding (opset-24) rotate-half (Reshape/Slice/Mul/Sub/Add/Concat) — new DecomposeRotaryEmbedding
TensorScatter (static cache) ScatterND (batch=1) — new TensorScatterToScatterND
Tile (GQA head-repeat) Expand
Range (bias) Constant(arange) + Slice
MatMulNBits QDQ (already); nibble-unpack BitwiseAnd/BitShift constant-folds away

Two new EP capability flags (supports_rotary_embedding, supports_tensor_scatter, both False for qnn) gate the new rules. All replacements are numerically identical (qnn-lowered gemma4 static model matches the fused reference at maxdiff 0.0). Added parity + op-replacement unit tests for both rules and a gemma4 build test asserting the qnn graph has no RotaryEmbedding/TensorScatter/Tile/Range.

Result: a non-quantized (fp16) gemma4 static model loads on QNN HTP with disable_cpu_ep_fallback=1 and composes + runs entirely on the NPU.

Remaining INT4 constraint (not a mobius rule): weight-only DequantizeLinearMatMul (float activations) runs on HTP only for per-tensor weights; per-channel/blocked int8/int4 weight DQ is CPU-forced unless the activations are also quantized (full int QDQ: DQ(act)+DQ(weight per-channel)→MatMul→Q, verified HTP-OK). So GGUF INT4 weights need Olive OnnxStaticQuantization (activation calibration) to keep their matmuls on HTP. All checks pass; ruff clean; no new mypy errors. Recipe docs updated (olive-recipes PR #432).

Comment thread src/mobius/components/_common.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 25 changed files in this pull request and generated 5 comments.

Comment on lines +143 to +150
x5 = tape.op("Unsqueeze", [x, c_ints([2])])
# Broadcast the size-1 group axis to ``group`` via Expand.
x5 = tape.op("Expand", [x5, c_ints([1, 1, group, 1, 1])])
shp = tape.op("Shape", [x]) # (4,) == [B, Nkv, S, H]
batch = tape.op("Slice", [shp, c_ints([0]), c_ints([1])])
s_h = tape.op("Slice", [shp, c_ints([2]), c_ints([4])])
target = tape.op("Concat", [batch, c_ints([q_heads]), s_h], {"axis": 0})
return tape.op("Reshape", [x5, target])
Comment on lines +219 to +223
zero_s = tape.op("Constant", [], {"value_int": 0})
one_s = tape.op("Constant", [], {"value_int": 1})
rows = tape.op("Range", [zero_s, sq, one_s]) # (Sq,)
cols = tape.op("Range", [zero_s, sk, one_s]) # (Sk,)
offset = tape.op("Sub", [sk, sq]) # scalar Sk - Sq
Comment on lines +98 to +103
inputs = node.inputs
q, k, v = inputs[0], inputs[1], inputs[2]
attn_mask = inputs[3] if len(inputs) > 3 else None
past_key = inputs[4] if len(inputs) > 4 else None
past_value = inputs[5] if len(inputs) > 5 else None

Comment on lines +64 to +69
# The cache axis being scattered must be a concrete size (static cache).
if cache.shape is None or len(cache.shape) != 3:
return result.fail("expected rank-3 cache (B, max, D)")
if not isinstance(cache.shape[1], int):
return result.fail("cache axis 1 (max_seq_len) must be a concrete int")
return result
Comment on lines +76 to +79
# x must be rank-3 (B, S, N*H) as mobius emits.
if x.shape is None or len(x.shape) != 3:
return result.fail("expected rank-3 x (B, S, N*H)")
return result
justinchuby and others added 5 commits July 25, 2026 00:04
`mobius build-gguf --keep-quantized` silently produced a fully
dequantized (float) model for Gemma 4: `Gemma4TextModel` built its
attention/MLP projections with the default float `Linear`, never wiring
the quantization descriptor from the config. This fixes that and the
config/weight-mapping gaps that blocked E2B GGUF conversion entirely.

- gemma4.py: add `_linear_class_from_config` and thread a quantized
  `linear_class` (MatMulNBits factory) through Gemma4TextModel ->
  Gemma4DecoderLayer -> Gemma4TextAttention + GatedMLP. Q/K/V/O and the
  gate/up/down MLP now emit INT4 MatMulNBits; per-layer input gates and
  norms stay float (they are F32/BF16 in GGUF). Tie the (always-float)
  LM head to the float scaled embedding so tied E2B checkpoints resolve.
- _config_mapping.py: collapse Gemma 4's per-layer `feed_forward_length`
  array into a scalar `intermediate_size` + `use_double_wide_mlp` flag
  (E2B: 15 layers @ 6144, tail 20 KV-shared @ 12288).
- _tensor_mapping.py: map the top-level per-layer-input tensors
  (per_layer_token_embd / per_layer_model_proj / per_layer_proj_norm).
- _builder.py: strip the `.weight` artefact from the `layer_scalar`
  nn.Parameter during GGUF normalization.

Verified: `build-gguf --keep-quantized` on unsloth/gemma-4-E2B-it-GGUF
Q4_K_M now emits 205 MatMulNBits (INT4) for both cpu (GQA) and qnn
(standard Attention) EPs; mixed Q6_K tensors are requantized to 4/32 by
the existing loader path. All gguf + gemma4 unit tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
…tion (QNN) path

When Gemma 4 is built for an EP without a GroupQueryAttention builder
(e.g. `qnn`, which advertises empty `gqa_dtypes`), KV-shared decoder
layers borrow K,V from a source layer's opset-24 `Attention`
`present_key`/`present_value` outputs. ORT does not run shape inference
on those `present` outputs, so they arrive rank-unknown; the downstream
Transpose/Reshape then lose the head dimension and the KV-shared layer's
`Attention` infers a zero-width output. That made `o_proj`'s MatMul fail
shape inference at model-load time (`Incompatible dimensions for matrix
multiplication`), so the E2B QNN model could not be loaded by ORT at all.

Pin the known 4D BNSH shape (`[batch, kv_heads, kv_seq, head_dim]`) on
the borrowed K,V when the source did not supply one. The KV-shared layer
shares the source's KV-head/head-dim configuration, so this is exact and
lets inference propagate through to `o_proj`.

Verified: a tiny E2B-shaped `gemma4_text` model built for the `qnn` EP
(standard Attention + per-layer inputs + 2 KV-shared layers) now loads
and runs on both CPU and QNN sessions and produces finite logits; the
GQA (`cpu`) path is unaffected. Full build_graph + gemma4 + gguf suites
pass (1412 passed).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
Gemma GGUF files omit the feed-forward activation metadata key, so the
GGUF config extractor fell back to `_default_activation`, which returned
the generic `silu`. Every Gemma generation uses the approximate-tanh
GELU (GeGLU) in its MLP, so building a Gemma model from GGUF silently
produced a wrong MLP and near-garbage output (e.g. Gemma 4 E2B echoed /
looped the prompt instead of answering).

Return `gelu_pytorch_tanh` for any `gemma*` model type.

Verified end-to-end: `mobius build-gguf --keep-quantized` on
unsloth/gemma-4-E2B-it-GGUF (Q4_K_M) now generates correct text via ORT
CPU — "What is the capital of France?" -> "The capital of France is
**Paris**.", "Name a primary color." -> "Red". The dequantized GGUF
weights were confirmed to match the HF `google/gemma-4-E2B-it`
checkpoint (all projection/norm/embedding correlations >= 0.98), so the
activation default was the sole remaining correctness gap.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
- _default_activation returns gelu_pytorch_tanh for gemma* model types
- map_gguf_to_hf_names covers the top-level per-layer-input tensors
  (per_layer_token_embd / per_layer_model_proj / per_layer_proj_norm)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
The Qualcomm Hexagon HTP (QNN EP) has no kernel for the fused opset-24
`Attention` op nor for `com.microsoft::MatMulNBits`, so a mobius decoder
built for `qnn` had both forced onto the CPU EP (verified empirically on
a Snapdragon X box: only primitives — MatMul/Softmax/RMSNormalization/
Gelu — run on HTP; fused Attention and MatMulNBits do not). This wires
two EP-capability-gated lowerings so the decoder can be claimed by the
QNN partitioner.

Two new EpCapabilities flags (default True; both False for `qnn`):
- `supports_attention`: when False, `DecomposeAttentionPass` rewrites the
  fused Attention op into SDPA primitives (Reshape/Transpose/MatMul/
  Softmax/Add, plus Tile for GQA), faithfully reproducing scale, softcap,
  additive mask, bottom-right causal masking, GQA head repeat, and the
  past→present KV concat. Implemented as an onnx_ir InPlacePass (not a
  pattern rewrite rule) so it can rewire the op's 3 outputs, whose
  present KV tensors are graph outputs on cache layers but dead on
  shared-KV layers (mixed arity the pattern rewriter can't express).
- `supports_matmul_nbits`: when False, the MatMulNBits contrib op is
  lowered to a standard blocked-uint4 `DequantizeLinear` + `MatMul` (QDQ)
  pair. Implemented as a model-local `com.microsoft::MatMulNBits`
  ir.Function body expanded by the existing InlinePass mechanism (like
  SkipLayerNormalization / PackedMultiHeadAttention) — supported EPs keep
  the compact contrib op and its native kernel; the registered-but-
  uninlined function body is harmless (kernels take precedence).

Both transforms are verified numerically identical to the originals via
ORT (maxdiff 0.0 for prefill/decode/GQA/mask; ~5e-7 with softcap due to
fp rounding). A full gemma-4-E2B GGUF INT4 build for `qnn` now lowers to
0 Attention / 35 Softmax and 0 MatMulNBits / 205 DequantizeLinear, with
all 30 present-KV graph outputs preserved; CPU/CUDA/etc. builds are
unchanged (still emit GQA + MatMulNBits). functions + rewrite_rules unit
suites pass (116).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
justinchuby and others added 7 commits July 25, 2026 00:06
- _decompose_attention_test.py: numerical parity vs the fused opset-24
  Attention op across prefill / decode-with-past / MHA / GQA / softcap /
  mask-only (atol 0 except ~1e-5 with softcap); KV-cache graph-output
  rewiring; rank-4 left untouched; and EP gating (qnn decomposes to
  Softmax SDPA, cpu keeps fused GQA).
- matmul_nbits_test.py: the inlined QDQ function body matches the native
  MatMulNBits op bit-for-bit (incl. odd n_blocks zero-point slice); EP
  gating (qnn inlines to DequantizeLinear, cpu keeps the contrib op).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
Thread static KV cache support through build_from_gguf and the
build-gguf CLI, mirroring the existing 'build' subcommand. When
--static-cache is set, the GGUF path constructs a CausalLMTask with
pre-allocated fixed-width KV buffers (TensorScatter in-place writes),
producing a fully static-shaped graph as required by fixed-shape
runtimes such as the QNN HTP backend.

- build_from_gguf gains static_cache/max_seq_len kwargs; rejects
  combining static_cache with an explicit task override.
- CLI validates --max-seq-len requires --static-cache and is positive.
- Add TestBuildGgufStaticCache: verifies fixed-width key_cache/value_cache
  I/O (KV axis == max_seq_len, no dynamic past_key_values.* inputs) and
  the explicit-task rejection.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
Add StaticCacheState dispatch to Gemma4TextAttention/Gemma4DecoderLayer so
CausalLMTask(static_cache=True) works for gemma4 (previously rejected). This
produces a fully static-shaped graph (pre-allocated fixed-width KV buffers
written in place via TensorScatter), required by fixed-shape runtimes such as
the QNN HTP backend.

Handles gemma4's architectural specifics:
- KV-shared layers (trailing num_kv_shared_layers) own no cache and read the
  source layer's full static buffer directly via op.Attention (is_causal=0,
  static-cache float bias);
- dual head_dim (sliding uses head_dim, full uses global_head_dim) via a new
  Gemma4TextModel.static_kv_cache_specs() returning per-cache-layer
  (num_kv_heads, head_dim); the generic CausalLMTask consumes it so only the
  cache-owning layers get buffers, each sized correctly;
- static-cache attention bias for both sliding and full layer types
  (create_static_cache_attention_bias), replacing the attention_mask-based
  create_attention_bias when attention_mask is None.

Task changes:
- _validate_static_cache_support accepts layers with _supports_static_cache=True
  (set on Gemma4DecoderLayer) in addition to DecoderLayer/MoEDecoderLayer;
- _make_static_cache_inputs accepts optional per-layer cache_specs;
- build() queries module.static_kv_cache_specs() in static mode.

Verified: static-cache decode logits match the dynamic-cache reference at every
position (~1e-6 fp32) across sliding-only, sliding+full dual-head_dim, and
KV-shared configs. Adds 3 build-graph tests (cache-owning-layer count, dual
head_dim buffer sizes, TensorScatter presence).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
Change the mobius QNN lowering so a static-cache decoder runs fully on the
Qualcomm Hexagon HTP (no CPU fallback). Empirically, the QNN HTP backend has
no kernel for these ops (each forces its node onto the CPU EP):
Tile, TensorScatter, Range, opset-24 RotaryEmbedding (plus the already-handled
fused Attention and MatMulNBits). Replace them with HTP-supported equivalents:

- DecomposeAttention: GQA head-repeat Tile -> Expand (broadcast).
- create_static_cache_attention_bias: Range -> Constant(arange) + Slice
  (max_seq_len is a concrete int; q_offsets = full_range[:S_q]).
- New DecomposeRotaryEmbedding rule: opset-24 RotaryEmbedding -> rotate-half
  (Reshape/Slice/Mul/Sub/Add/Concat), non-interleaved / full-rotation form,
  gated by new Ep capability supports_rotary_embedding (False for qnn).
- New TensorScatterToScatterND rule: static-cache TensorScatter (axis=1) ->
  ScatterND (batch=1 on-device inference), gated by new Ep capability
  supports_tensor_scatter (False for qnn).

All replacements are numerically identical to the originals (verified: a
qnn-lowered gemma4 static model matches the fused reference at maxdiff 0.0).
With these changes a non-quantized (fp16) gemma4 static model loads on QNN HTP
with session.disable_cpu_ep_fallback=1 and composes/runs entirely on the NPU.

Note: INT4 (GGUF) weights additionally require quantized activations (full int
QDQ) for HTP — QNN HTP claims per-channel/blocked DequantizeLinear only inside
an int-activation QDQ MatMul group, not weight-only DQ->MatMul with float
activations. That activation quantization needs calibration (Olive
OnnxStaticQuantization) and is out of scope for a pure mobius lowering.

Adds parity + op-replacement tests for both new rules and a gemma4 build test
asserting the qnn lowering emits no RotaryEmbedding/TensorScatter/Tile/Range.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
…apability

The Range→Constant(arange)+Slice replacement in create_static_cache_attention_bias
was applied unconditionally, even for CPU/CUDA EPs where Range works fine and is
more efficient (1 dynamic node vs a large constant initializer).

Add a new `supports_range: bool = True` capability to EpCapabilities (False for
qnn). In create_static_cache_attention_bias, use Range for EPs that support it
and Constant+Slice only when supports_range=False (QNN HTP), matching the pattern
used for supports_rotary_embedding / supports_tensor_scatter.

Signed-off-by: Copilot <223556219+Copilot@users.noreply.github.com>
Apply Ruff formatting to the QNN lowering additions and remap GPT-NeoX lm_head weights to embed_out so the unittest synthetic parity case uses the exported head weights.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 25, 2026 00:15
@justinchuby
justinchuby force-pushed the justinchuby/gemma4-gguf-qnn branch from 11c637b to d66b21c Compare July 25, 2026 00:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/mobius/rewrite_rules/_decompose_attention.py:146

  • In the GQA path, repeat_kv() uses Expand with a hard-coded output shape [1, 1, group, 1, 1]. This is incompatible with inputs where B != 1 and/or Nkv != 1 (and will fail at runtime even for common Nkv>1 cases), so the decomposition will not be shape-correct for typical GQA configs.
        x5 = tape.op("Unsqueeze", [x, c_ints([2])])
        # Broadcast the size-1 group axis to ``group`` via Expand.
        x5 = tape.op("Expand", [x5, c_ints([1, 1, group, 1, 1])])
        shp = tape.op("Shape", [x])  # (4,) == [B, Nkv, S, H]

src/mobius/rewrite_rules/_decompose_attention.py:203

  • _causal_bias() builds the causal mask using the ONNX Range op. But the PR also declares supports_range=False for QNN HTP (and the stated goal is an HTP-friendly graph). As-is, decomposed Attention with is_causal=1 will still contain Range and will be CPU-forced on HTP (e.g., static-cache models that rely on is_causal rather than a precomputed bias).
def _causal_bias(tape, c_ints, c_floats, q_bnsh, k_full, scores):
    """Bottom-right-aligned ``(1, 1, Sq, Sk)`` causal additive bias.

    Query row ``i`` (within the current chunk of ``Sq``) may attend to key
    columns ``j <= i + (Sk - Sq)``; later columns get ``_MASK_NEG``.  Uses
    ``Range`` over the dynamic Sq/Sk so it is correct for both prefill
    (Sk == Sq) and decode (Sk == Sq + past).

Comment thread src/mobius/rewrite_rules/_decompose_attention.py
Comment thread src/mobius/_execution_providers.py
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 25, 2026 01:02
@justinchuby

Copy link
Copy Markdown
Member Author

Addressed the Copilot review in c5a1db2:

  • Build GQA Expand targets from runtime B/Nkv/S/H dimensions.
  • Preserve nonpad_kv_seqlen via valid-prefix score masking.
  • Use constant aranges sliced to runtime lengths for statically bounded KV caches, so QNN static-cache lowering emits no Range.
  • Reject TensorScatter lowering for statically known batch > 1.
  • Require rank-3 cos/sin before RotaryEmbedding decomposition.
  • Corrected Tile references to Expand.
  • The earlier EP-gated static-cache bias comment was already resolved in 7c49b8b.

Validation: lint clean; targeted lowering tests 22 passed; full suite 4718 passed, 334 skipped, 62 xfailed, with only the 11 known NeMo failures (#428). The same suite with NeMo ignored passes completely.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

src/mobius/rewrite_rules/_scatternd.py:70

  • TensorScatterToScatterND rewrites by squeezing the batch axis, so it is only correct when runtime batch==1. However, the current check allows a symbolic batch dimension (it only rejects int batch sizes != 1), which means the rule can silently rewrite a model that is later executed with batch>1 and fail at runtime. Consider either (1) ensuring QNN/static-cache graphs pin batch=1 before applying this rule, or (2) tightening the rule to require a statically-known batch dimension of 1 (and leaving TensorScatter untouched otherwise).
        if cache.shape is None or len(cache.shape) != 3:
            return result.fail("expected rank-3 cache (B, max, D)")
        if isinstance(cache.shape[0], int) and cache.shape[0] != 1:
            return result.fail("only batch=1 static-cache scatter is rewritten")
        if not isinstance(cache.shape[1], int):
            return result.fail("cache axis 1 (max_seq_len) must be a concrete int")

Comment on lines +59 to +63
# Large negative additive-bias value used for masked-out positions. Matches the
# magnitude ORT's Attention kernel uses for float masks; after softmax these
# positions contribute ~0. Kept finite (not -inf) so ``0 * -inf = NaN`` cannot
# arise for fully-masked rows.
_MASK_NEG = -3.0e38
Copilot AI review requested due to automatic review settings July 25, 2026 01:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

src/mobius/rewrite_rules/_scatternd.py:70

  • TensorScatterToScatterND assumes batch=1 (it squeezes axis 0), but check() currently allows a symbolic batch dimension (only rejects when batch is an int != 1). That means the rewrite can silently produce a model that will fail at runtime for batch>1 (Squeeze on a non-1 dim). Either (a) make qnn/static-cache builds pin the batch dim to 1, and require cache.shape[0] == 1 here, or (b) extend the rewrite to handle batch>1 without squeezing.
        # The cache axis being scattered must be a concrete size (static cache).
        if cache.shape is None or len(cache.shape) != 3:
            return result.fail("expected rank-3 cache (B, max, D)")
        if isinstance(cache.shape[0], int) and cache.shape[0] != 1:
            return result.fail("only batch=1 static-cache scatter is rewritten")
        if not isinstance(cache.shape[1], int):
            return result.fail("cache axis 1 (max_seq_len) must be a concrete int")

src/mobius/rewrite_rules/_decompose_attention.py:63

  • _MASK_NEG is intended to stay finite after CastLike into the compute dtype, but -3.0e38 cast to float16 becomes -inf, which reintroduces the fully-masked-row NaN risk the comment is trying to avoid. Use a value that is representable in float16 (e.g. float16 min) so it remains finite across dtypes.
# Large negative additive-bias value used for masked-out positions. Matches the
# magnitude ORT's Attention kernel uses for float masks; after softmax these
# positions contribute ~0. Kept finite (not -inf) so ``0 * -inf = NaN`` cannot
# arise for fully-masked rows.
_MASK_NEG = -3.0e38

Reconcile native sub-4-bit GGUF modules with static-cache task selection and preserve Gemma 4 QNN static KV handling.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 25, 2026 02:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

src/mobius/rewrite_rules/_scatternd.py:70

  • TensorScatterToScatterND rewrites even when the batch dimension is symbolic (only rejects when it's an int != 1). The rewrite then unconditionally Squeezes axis 0, which will raise at runtime if the model is ever executed with batch>1. Either make the rewrite strictly require a statically-known batch==1, or ensure qnn static-cache graphs pin the batch dimension to 1 before this rewrite runs.
        if cache.shape is None or len(cache.shape) != 3:
            return result.fail("expected rank-3 cache (B, max, D)")
        if isinstance(cache.shape[0], int) and cache.shape[0] != 1:
            return result.fail("only batch=1 static-cache scatter is rewritten")
        if not isinstance(cache.shape[1], int):
            return result.fail("cache axis 1 (max_seq_len) must be a concrete int")

Comment on lines +45 to +50
shape=ir.Shape([batch, "sq", dim]),
type=ir.TensorType(ir.DataType.FLOAT),
)
widx = ir.Value(name="widx", shape=ir.Shape([1]), type=ir.TensorType(ir.DataType.INT64))
y = ir.Value(name="y")
node = ir.Node(
@justinchuby
justinchuby merged commit 6ebe8ee into main Jul 25, 2026
19 of 22 checks passed
@justinchuby
justinchuby deleted the justinchuby/gemma4-gguf-qnn branch July 25, 2026 02:35
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.

4 participants