Skip to content

Commit 6ebe8ee

Browse files
justinchubyCopilotCopilot
authored
Gemma 4 E2B: GGUF INT4 direct conversion + QNN HTP lowering (#407)
## 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 (`silu` → `gelu_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=False` → `DecomposeAttentionPass` 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=False` → `com.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 --------- Signed-off-by: Justin Chu <justinchu@microsoft.com> Signed-off-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 5544f2c commit 6ebe8ee

23 files changed

Lines changed: 2125 additions & 61 deletions

src/mobius/__main__.py

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -428,22 +428,22 @@ def _cmd_build_gguf(args: argparse.Namespace) -> None:
428428
output_dir = args.output or os.path.splitext(gguf_path)[0] + "_onnx"
429429
os.makedirs(output_dir, exist_ok=True)
430430

431-
if mmproj_path is not None:
432-
print(f"Multimodal mode: fusing vision/audio encoder from mmproj {mmproj_path}...")
433-
pkg = build_from_gguf(
434-
gguf_path,
435-
mmproj=mmproj_path,
436-
dtype=args.dtype,
437-
execution_provider=args.execution_provider,
438-
keep_quantized=args.keep_quantized,
439-
)
440-
else:
441-
pkg = build_from_gguf(
442-
gguf_path,
443-
dtype=args.dtype,
444-
keep_quantized=args.keep_quantized,
445-
execution_provider=args.execution_provider,
446-
)
431+
if args.max_seq_len is not None and not args.static_cache:
432+
raise SystemExit("Error: --max-seq-len can only be used with --static-cache.")
433+
if args.max_seq_len is not None and args.max_seq_len <= 0:
434+
raise SystemExit("Error: --max-seq-len must be a positive integer.")
435+
if mmproj_path is not None and args.static_cache:
436+
raise SystemExit("Error: --static-cache cannot be used with --mmproj.")
437+
438+
pkg = build_from_gguf(
439+
gguf_path,
440+
mmproj=mmproj_path,
441+
dtype=args.dtype,
442+
keep_quantized=args.keep_quantized,
443+
execution_provider=args.execution_provider,
444+
static_cache=args.static_cache,
445+
max_seq_len=args.max_seq_len,
446+
)
447447

448448
pkg.save(
449449
output_dir,
@@ -770,6 +770,26 @@ def main(argv: list[str] | None = None) -> None:
770770
"Either way the quantized model runs directly in the target runtime."
771771
),
772772
)
773+
gguf_parser.add_argument(
774+
"--static-cache",
775+
action="store_true",
776+
help=(
777+
"Use a static KV cache (pre-allocated fixed-width buffers written "
778+
"in place via TensorScatter) instead of the dynamic concat-grow "
779+
"cache. Produces a fully static-shaped graph as required by "
780+
"fixed-shape runtimes such as the QNN HTP backend."
781+
),
782+
)
783+
gguf_parser.add_argument(
784+
"--max-seq-len",
785+
type=int,
786+
default=None,
787+
metavar="N",
788+
help=(
789+
"Maximum sequence length for static cache buffers. Only used with "
790+
"--static-cache. Defaults to max_position_embeddings from config."
791+
),
792+
)
773793
gguf_parser.set_defaults(func=_cmd_build_gguf)
774794

775795
# --- list ---

src/mobius/_execution_providers.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,33 @@ class EpCapabilities:
7171
(query/key norm over the head dimension) to rank-3 and back via
7272
HtpRank4RMSNorm. ``True`` leaves it unchanged. Set ``False``
7373
only for the QNN HTP, which miscomputes rank-4 RMSNormalization.
74+
supports_attention: ``False`` decomposes the fused opset-24
75+
``Attention`` op into scaled-dot-product primitives
76+
(Reshape/Transpose/MatMul/Softmax/Add, plus Expand for GQA) via
77+
DecomposeAttention. ``True`` leaves the fused op unchanged. Set
78+
``False`` only for runtimes without an ``Attention`` kernel (QNN
79+
HTP), where the fused op would otherwise be forced onto CPU.
80+
supports_rotary_embedding: ``False`` decomposes the opset-24
81+
``RotaryEmbedding`` op into rotate-half primitives (Reshape/Slice/
82+
Mul/Sub/Add/Concat) via DecomposeRotaryEmbedding. ``True`` leaves
83+
the fused op unchanged. Set ``False`` only for runtimes without a
84+
``RotaryEmbedding`` kernel (QNN HTP), where it is forced onto CPU.
85+
supports_tensor_scatter: ``False`` rewrites the static-cache
86+
``TensorScatter`` in-place KV write into ``ScatterND`` (batch=1) via
87+
TensorScatterToScatterND. ``True`` leaves ``TensorScatter``
88+
unchanged. Set ``False`` only for runtimes without a
89+
``TensorScatter`` kernel (QNN HTP), where it is forced onto CPU.
90+
supports_range: ``False`` replaces the ONNX ``Range`` op in
91+
:func:`~mobius.components.create_static_cache_attention_bias` with a
92+
precomputed ``Constant(arange)`` + ``Slice``. ``True`` (the
93+
default) leaves ``Range`` unchanged. Set ``False`` only when
94+
static cache is used on runtimes that lack a ``Range`` kernel (QNN
95+
HTP), where the ``Range`` node would otherwise be forced onto CPU.
96+
supports_matmul_nbits: ``False`` converts ``com.microsoft::MatMulNBits``
97+
(blockwise-INT4 weight) into a standard ``DequantizeLinear`` +
98+
``MatMul`` (QDQ) pair via MatMulNBitsToQDQ. ``True`` leaves the
99+
contrib op unchanged. Set ``False`` only for runtimes that lack a
100+
``MatMulNBits`` kernel but can consume QDQ weights (QNN HTP).
74101
default_int4_accuracy_level: Default accuracy level for INT4
75102
quantization (0 = highest accuracy, 4 = fastest).
76103
provider_options: Default ORT GenAI provider options dict for this EP.
@@ -121,6 +148,11 @@ class EpCapabilities:
121148
supports_fused_moe: bool = True
122149
supports_packed_multi_head_attention: bool = False
123150
supports_rank4_rmsnorm: bool = True
151+
supports_attention: bool = True
152+
supports_matmul_nbits: bool = True
153+
supports_rotary_embedding: bool = True
154+
supports_tensor_scatter: bool = True
155+
supports_range: bool = True
124156
default_int4_accuracy_level: int = 0
125157
provider_options: dict[str, str] = dataclasses.field(default_factory=dict)
126158
enable_graph_capture: bool = False
@@ -344,6 +376,11 @@ def _register_builtins() -> None:
344376
},
345377
supports_past_present_share_buffer=False, # standard-Attention KV concat
346378
supports_rank4_rmsnorm=False, # HTP miscomputes rank-4 RMSNorm (q/k norm)
379+
supports_attention=False, # no HTP Attention kernel — decompose to SDPA
380+
supports_matmul_nbits=False, # no HTP MatMulNBits kernel — convert to QDQ
381+
supports_rotary_embedding=False, # no HTP RotaryEmbedding — rotate-half
382+
supports_tensor_scatter=False, # no HTP TensorScatter — ScatterND (batch=1)
383+
supports_range=False, # no HTP Range — Constant(arange)+Slice in static bias
347384
),
348385
# onnx-standard: ONNX-only runtime — emits zero custom-domain ops.
349386
# All com.microsoft ops (SkipLayerNorm, PackedMHA) are expanded via

src/mobius/_optimizations.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@
6161
)
6262
from mobius.functions import register_function_bodies
6363
from mobius.rewrite_rules import (
64+
decompose_attention_pass,
65+
decompose_rope_rules,
6466
gelu_fusion_rules,
6567
group_query_attention_rules,
6668
htp_rank4_rmsnorm_rules,
@@ -69,6 +71,7 @@
6971
skip_layer_norm_rules,
7072
skip_norm_rules,
7173
static_empty_kv_rules,
74+
tensor_scatter_to_scatternd_rules,
7275
unpack_qkv_rules,
7376
)
7477

@@ -318,6 +321,29 @@ def _get_optimization_passes(
318321
if not caps.supports_rank4_rmsnorm:
319322
lower.append(("HtpRank4RMSNorm", list(htp_rank4_rmsnorm_rules())))
320323

324+
# Decompose the opset-24 RotaryEmbedding op into rotate-half primitives for
325+
# EPs without a RotaryEmbedding kernel (QNN HTP), where the op falls to CPU.
326+
if not caps.supports_rotary_embedding:
327+
lower.append(("DecomposeRotaryEmbedding", list(decompose_rope_rules())))
328+
329+
# Rewrite the static-cache TensorScatter KV write into ScatterND for EPs
330+
# without a TensorScatter kernel (QNN HTP), where it falls to CPU.
331+
if not caps.supports_tensor_scatter:
332+
lower.append(("TensorScatterToScatterND", list(tensor_scatter_to_scatternd_rules())))
333+
334+
# Decompose the fused opset-24 Attention op into SDPA primitives for EPs
335+
# without an Attention kernel (QNN HTP), where the fused op falls to CPU.
336+
# An IR pass (not a rewrite rule) so it can rewire the op's 3 outputs, whose
337+
# KV-cache present tensors are graph outputs on some layers and dead on
338+
# shared-KV layers. Runs after the batched rewrite rules (lower_ir_passes).
339+
if not caps.supports_attention:
340+
lower.append(("DecomposeAttention", decompose_attention_pass()))
341+
342+
# NOTE: MatMulNBits → QDQ lowering is handled by the InlinePass mechanism
343+
# (a registered com.microsoft::MatMulNBits function body), not a rewrite
344+
# rule — see optimize_model()'s _should_inline and mobius.functions. It is
345+
# gated by ``supports_matmul_nbits`` there.
346+
321347
# --- Graph-capture rewrite ---
322348
# Supports graph capture for shared-KV layer models on WebGPU EP
323349
# (currently Gemma4). Pattern-based: only fires on models that emit the
@@ -419,6 +445,12 @@ def _should_inline(func: ir.Function) -> bool:
419445
return not caps.supports_skip_layer_norm
420446
if func.domain == "com.microsoft" and func.name == "PackedMultiHeadAttention":
421447
return not caps.supports_packed_multi_head_attention
448+
# MatMulNBits (blockwise-INT4) → QDQ (DequantizeLinear + MatMul) for EPs
449+
# without a MatMulNBits kernel (QNN HTP). Supported EPs (CPU/CUDA/…) keep
450+
# the compact contrib op and its native kernel; the function body stays
451+
# registered but uninlined (kernels take precedence over local functions).
452+
if func.domain == "com.microsoft" and func.name == "MatMulNBits":
453+
return not caps.supports_matmul_nbits
422454
return False
423455

424456
inline_pass = common_passes.InlinePass(criteria=_should_inline)

src/mobius/components/_common.py

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -335,23 +335,45 @@ def create_static_cache_attention_bias(
335335
Additive bias of shape ``(B, 1, S_q, max_seq_len)``: ``0`` where the
336336
query may attend the slot, ``dtype.min`` where masked.
337337
"""
338-
zero_scalar = op.Constant(value_int=0)
339-
one_scalar = op.Constant(value_int=1)
340-
341-
# Q absolute positions: write_indices[b] + arange(S_q) -> (B, S_q, 1).
342-
seq_scalar = op.Squeeze(seq_len, op.Constant(value_ints=[0]))
343-
q_offsets = op.Range(zero_scalar, seq_scalar, one_scalar) # (S_q,)
338+
# KV positions: dense cache slot ids 0 .. max_seq_len - 1 -> (max_seq_len,).
339+
# For EPs that support Range (CPU, CUDA, etc.) emit a single dynamic Range
340+
# node. For EPs without a Range kernel (QNN HTP, supports_range=False),
341+
# emit a precomputed Constant — max_seq_len is always a concrete int in
342+
# static-cache mode — and derive q_offsets via Slice, which both Constant
343+
# and Slice run on HTP without CPU fallback.
344+
from mobius._build_context import ep_capabilities
345+
346+
if ep_capabilities().supports_range:
347+
zero_scalar = op.Constant(value_int=0)
348+
one_scalar = op.Constant(value_int=1)
349+
kv_slots = op.Range(
350+
zero_scalar, op.Constant(value_int=max_seq_len), one_scalar
351+
) # (max_seq_len,)
352+
353+
# Q absolute positions: write_indices[b] + arange(S_q) -> (B, S_q, 1).
354+
seq_scalar = op.Squeeze(seq_len, op.Constant(value_ints=[0]))
355+
q_offsets = op.Range(zero_scalar, seq_scalar, one_scalar) # (S_q,)
356+
else:
357+
# QNN HTP has no Range kernel — use a precomputed constant for the
358+
# full kv_slots array and Slice it to S_q for q_offsets; both ops
359+
# run on HTP.
360+
kv_slots = op.Constant(
361+
value=ir.tensor(np.arange(max_seq_len, dtype=np.int64))
362+
) # (max_seq_len,)
363+
364+
# arange(S_q) = kv_slots[:S_q] via Slice.
365+
q_offsets = op.Slice(
366+
kv_slots,
367+
op.Constant(value_ints=[0]), # start
368+
seq_len, # end == [S_q]
369+
op.Constant(value_ints=[0]), # axis
370+
) # (S_q,)
344371
q_abs_2d = op.Add(
345372
op.Unsqueeze(write_indices, [1]), # (B, 1)
346373
op.Unsqueeze(q_offsets, [0]), # (1, S_q)
347374
) # (B, S_q)
348375
q_abs = op.Unsqueeze(q_abs_2d, [2]) # (B, S_q, 1)
349376

350-
# KV positions: dense cache slot ids 0 .. max_seq_len - 1 -> (max_seq_len,).
351-
kv_slots = op.Range(
352-
zero_scalar, op.Constant(value_int=max_seq_len), one_scalar
353-
) # (max_seq_len,)
354-
355377
# Causal: a query at absolute position q may attend slot k iff q >= k.
356378
# Broadcasting (B, S_q, 1) >= (max_seq_len,) -> (B, S_q, max_seq_len).
357379
full_mask = op.GreaterOrEqual(q_abs, kv_slots)

src/mobius/functions/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
from mobius.functions.linear_attention import (
3434
linear_attention,
3535
)
36+
from mobius.functions.matmul_nbits import (
37+
matmul_nbits,
38+
)
3639
from mobius.functions.packed_multi_head_attention import (
3740
packed_multi_head_attention,
3841
)
@@ -53,6 +56,7 @@
5356
# (kernel_size, channels, num_heads are baked into the function body) and are
5457
# registered per-model by ``tasks._base._register_linear_attention_functions``.
5558
_FUNCTION_BUILDERS: dict[ir.OperatorIdentifier, Callable[[], ir.Function]] = {
59+
(_DOMAIN, "MatMulNBits", ""): matmul_nbits,
5660
(_DOMAIN, "PackedMultiHeadAttention", ""): packed_multi_head_attention,
5761
(_DOMAIN, "SkipLayerNormalization", ""): skip_layer_normalization,
5862
(_DOMAIN, "SkipSimplifiedLayerNormalization", ""): skip_simplified_layer_normalization,
@@ -100,6 +104,7 @@ def register_function_bodies(model: ir.Model) -> None:
100104
"causal_conv_nd_with_state",
101105
"get_function",
102106
"linear_attention",
107+
"matmul_nbits",
103108
"packed_multi_head_attention",
104109
"register_function_bodies",
105110
"skip_layer_normalization",

0 commit comments

Comments
 (0)