Skip to content

Quantize Gemma4 per-layer embedding tables for WebGPU (INT4/INT8 configurable) - #403

Draft
feich-ms wants to merge 6 commits into
mainfrom
user/feich/quantize_per_layer_embedding_table
Draft

Quantize Gemma4 per-layer embedding tables for WebGPU (INT4/INT8 configurable)#403
feich-ms wants to merge 6 commits into
mainfrom
user/feich/quantize_per_layer_embedding_table

Conversation

@feich-ms

@feich-ms feich-ms commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Quantize the 35 per-layer embedding tables (embed_tokens_per_layer_split) from FP16 to INT4 using GatherBlockQuantized, reducing decoder size from 5.8 GB to 2.7 GB for WebGPU
  • Add embedding_bits parameter to mobius.build() so callers can choose INT4 (default) or INT8 without editing source code
  • Add quantize_embedding_rtn() utility for block-wise RTN quantization of embedding tables (supports 4-bit and 8-bit, symmetric and asymmetric)
  • Add QuantizedScaledWordEmbedding class that wraps GatherBlockQuantized with the Gemma4 embed scaling

Details

The Gemma4 E2B multimodal model splits a fused [262144, 35×256] embedding table into 35 separate [262144, 256] tables for WebGPU's 256 MiB buffer limit. At FP16, these tables alone take 4.48 GB (77% of the decoder). This PR quantizes them to INT4 (560 MB) or INT8 (1.12 GB) using the GatherBlockQuantized custom op.

The embedding_bits parameter can be set via:

  • mobius.build(..., embedding_bits=4) (Python API)
  • "embedding_bits": 4 in the MobiusBuilder Olive pass config JSON

When omitted, defaults to INT4 for backward compatibility.

Test plan

  • Build INT4 model → decoder 2.7 GB, generates clean 890+ tokens
  • Build INT8 model → decoder 3.8 GB, generates clean 1024+ tokens
  • Omitting embedding_bits preserves existing INT4 default behavior
  • CI passes (no regressions to non-WebGPU paths)

@feich-ms feich-ms changed the title Quantize per-layer embedding tables for WebGPU (INT4/INT8 configurable) Quantize Gemma4per-layer embedding tables for WebGPU (INT4/INT8 configurable) Jul 14, 2026
@feich-ms feich-ms changed the title Quantize Gemma4per-layer embedding tables for WebGPU (INT4/INT8 configurable) Quantize Gemma4 per-layer embedding tables for WebGPU (INT4/INT8 configurable) Jul 14, 2026
@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

Performance Comparison

Comparing 3791080839ccf3

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.

@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

The author of this PR, feich-ms, is not an activated member of this organization on Codecov.
Please activate this user on Codecov to display this PR comment.
Coverage data is still being uploaded to Codecov.io for purposes of overall coverage calculations.
Please don't hesitate to email us at support@codecov.io with any questions.

feich-ms and others added 2 commits July 14, 2026 10:25
The 35 per-layer embedding tables ([262144, 256] FP16 each) take 4.48 GB
unquantized — 77% of the decoder.  Quantize them to INT4 using
GatherBlockQuantized, reducing the decoder from 5.8 GB to 2.7 GB and
total model from 6.9 GB to 3.8 GB.

Co-Authored-By: Claude <noreply@anthropic.com>
Add embedding_bits parameter to mobius.build() that threads through to
the Gemma4 task's per-layer embedding quantization. When set, the
specified bit-width (4 or 8) is used for GatherBlockQuantized embedding
tables instead of the previous hardcoded INT4. When omitted, the
default INT4 behavior is preserved.

Co-Authored-By: Claude <noreply@anthropic.com>
@feich-ms
feich-ms force-pushed the user/feich/quantize_per_layer_embedding_table branch from 6b105f6 to ded37e9 Compare July 14, 2026 02:26
@feich-ms
feich-ms requested a review from Copilot July 14, 2026 02:28
@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

🏗️ Architecture Diff

Comparing 3791080839ccf3

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)

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

This PR adds a WebGPU-focused path to reduce Gemma4 decoder size by block-quantizing the split per-layer embedding tables (INT4 by default, INT8 optional), and introduces a new embedding_bits knob plus an RTN embedding-quantization utility.

Changes:

  • Add embedding_bits parameter to mobius.build() and plumb it into QuantizationConfig.
  • Introduce QuantizedScaledWordEmbedding (GatherBlockQuantized + Gemma scaling) and use it for Gemma4 per-layer split embeddings when enabled.
  • Add quantize_embedding_rtn() to generate packed qweight/scales/zero_points tensors for GatherBlockQuantized.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.

File Description
src/mobius/tasks/_gemma4.py Enables embedding quantization when per-layer embeddings must be split (WebGPU max buffer constraint) and swaps in quantized embedding modules.
src/mobius/models/gemma4.py Adds a quantized scaled embedding module and updates Gemma4 weight preprocessing to emit qweight/scales/zero_points for per-layer embeddings.
src/mobius/_weight_utils.py Adds RTN embedding-table quantization utility producing GatherBlockQuantized-compatible packed outputs.
src/mobius/_builder.py Adds public API flag embedding_bits and maps it onto config quantization settings.

Comment thread src/mobius/_builder.py Outdated
Comment thread src/mobius/tasks/_gemma4.py Outdated
Comment thread src/mobius/models/gemma4.py
Comment thread src/mobius/models/gemma4.py
Comment thread src/mobius/_weight_utils.py Outdated
Comment thread src/mobius/_weight_utils.py
Comment thread src/mobius/_builder.py
feich-ms and others added 3 commits July 14, 2026 16:31
- Add embedding_bits to build() docstring with description and valid values
- Validate embedding_bits is 4 or 8 in build(), raise ValueError otherwise
- Replace assert-based validation in quantize_embedding_rtn with ValueError
- Add .detach().cpu() before .numpy() to handle non-CPU tensors
- Add bits validation (4/8) in both gemma4.py preprocess_weights paths
- Fix _gemma4.py to explicitly set bits/group_size/sym when enabling
  quantize_embeddings on an existing QuantizationConfig
- Add 9 unit tests for quantize_embedding_rtn covering shapes, dtypes,
  error cases, and roundtrip accuracy

Co-Authored-By: Claude <noreply@anthropic.com>
Restrict embedding_bits to Gemma4 per-layer embedding tables so ordinary text models are not opted into quantized embeddings or linears. Defer per-layer quantized table construction until splitting is active and use a valid embedding block size for smaller configs. Add regression coverage for embedding_bits validation and no-op behavior on regular text models.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Signed-off-by: justinchuby <justinchuby@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 29, 2026 17:25
@CLAassistant

CLAassistant commented Jul 29, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ justinchuby
❌ feich-ms
You have signed the CLA already but the status is still pending? Let us recheck it.

@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

assert scales.shape == (64, 1)

def test_invalid_bits_raises(self):
"""bits not in (4, 8) raises ValueError."""
"""Handles non-CPU tensors gracefully (detach().cpu() path)."""
weight = torch.randn(32, 32, dtype=torch.float16)
# Just verify it doesn't crash — the .detach().cpu() handles it
qweight, scales, zp = self.quantize(weight, bits=4, block_size=32)
"""Handles non-CPU tensors gracefully (detach().cpu() path)."""
weight = torch.randn(32, 32, dtype=torch.float16)
# Just verify it doesn't crash — the .detach().cpu() handles it
qweight, scales, zp = self.quantize(weight, bits=4, block_size=32)

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 6 out of 6 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (3)

src/mobius/_builder.py:617

  • When config.quantization already exists (e.g., from a quantized HF/GGUF/Olive config), this code unconditionally overwrites qc.bits to embedding_bits. That can make the build configuration inconsistent with the checkpoint’s actual quantized weights and break weight loading. Consider rejecting overrides when an existing weight-quantization config is present, or only updating bits when quant_method == "none".
            else:
                qc = dataclasses.replace(
                    config.quantization,
                    bits=embedding_bits,
                    quantize_embeddings=True,
                )
                config = dataclasses.replace(config, quantization=qc)

src/mobius/tasks/_gemma4.py:482

  • embed_scale is computed via float(np.float16(...)), which rounds the scale to FP16 and then back to Python float. That changes the constant compared to the existing embedding scaling logic (which uses full-precision float), and can introduce avoidable numerical differences.
            import numpy as np

            embed_scale = float(np.float16(per_layer_dim**0.5))

src/mobius/models/gemma4.py:221

  • QuantizedScaledWordEmbedding.forward is missing an explicit return type annotation (other embedding forwards in this file return ir.Value). This can trip strict type-checking and is inconsistent with surrounding code.
    def forward(self, op: OpBuilder, input_ids: ir.Value):

Comment thread src/mobius/tasks/_gemma4.py Outdated
Comment on lines +446 to +468
qc = config.quantization
if qc is not None and qc.quantize_embeddings:
# embedding_bits was set by the caller — use its bits value
if qc.bits not in (4, 8):
raise ValueError(
f"quantize_embeddings requires bits=4 or bits=8, got {qc.bits}"
)
elif qc is not None:
# Existing quantization config without embedding quantization —
# enable it with INT4 (the existing bits may be for MatMul
# quantization which is unrelated, so override for embeddings)
config.quantization.quantize_embeddings = True
config.quantization.bits = 4
config.quantization.group_size = 32
config.quantization.sym = False
else:
config.quantization = QuantizationConfig(
bits=4,
group_size=32,
quant_method="mobius",
sym=False,
quantize_embeddings=True,
)
Keep embedding_bits and split per-layer embedding quantization separate from the shared text QuantizationConfig so decoder Linear, LM head, and token embedding quantization are not changed incidentally. Add dedicated Gemma4 config fields for per-layer embedding quantization and a regression test that split defaults do not mutate an existing quantization config.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Signed-off-by: justinchuby <justinchuby@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 29, 2026 17:38
"""
from mobius._configs import per_model # noqa: F401 - imported for registration side effect
from mobius._configs import (
per_model, # ruff: ignore[unused-import] - imported for registration side effect
"""
from mobius._configs import per_model # noqa: F401 - imported for registration side effect
from mobius._configs import (
per_model, # ruff: ignore[unused-import] - imported for registration side effect
"""
from mobius._configs import per_model # noqa: F401 - imported for registration side effect
from mobius._configs import (
per_model, # ruff: ignore[unused-import] - imported for registration side effect
"""
from mobius._configs import per_model # noqa: F401 - imported for registration side effect
from mobius._configs import (
per_model, # ruff: ignore[unused-import] - imported for registration side effect
def test_split_defaults_do_not_mutate_existing_quantization_config(self, monkeypatch):
import dataclasses

import mobius.tasks._gemma4 as gemma4_task_module

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 8 out of 8 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (5)

src/mobius/_configs/_base.py:344

  • Same as above: # ruff: ignore[unused-import] is not the suppression style used in this repo and is likely to fail linting. Prefer the existing # noqa: F401 pattern for side-effect imports.
    from mobius._configs import (
        per_model,  # ruff: ignore[unused-import] - imported for registration side effect
    )

src/mobius/tasks/_gemma4.py:481

  • embed_scale is computed with float(np.float16(...)) here, but Gemma4’s constructor uses float(self._per_layer_dim**0.5) when it creates the same embedding module. That means the scale constant depends on whether split/quantization is decided before vs during task.build(), which can change numerics. Also, NumPy is only imported for this cast.
            per_layer_dim = getattr(config, "hidden_size_per_layer_input", 0)
            vocab_per_layer = getattr(config, "vocab_size_per_layer_input", 0)
            import numpy as np

            embed_scale = float(np.float16(per_layer_dim**0.5))

src/mobius/models/gemma4.py:205

  • This docstring hard-codes “INT4”, but the class is used with both 4-bit and 8-bit embedding quantization (bits=4/8). Updating the docstring avoids misleading API/docs readers.
class QuantizedScaledWordEmbedding(QuantizedEmbedding):
    """Quantized embedding with scaling — INT4 GatherBlockQuantized + scale multiply."""

src/mobius/models/gemma4.py:221

  • QuantizedScaledWordEmbedding.forward is missing a return type annotation (-> ir.Value), unlike the surrounding modules. This can trip mypy/typing checks and reduces API clarity.
    def forward(self, op: OpBuilder, input_ids: ir.Value):

src/mobius/_weight_utils.py:742

  • For bits==4, packing assumes embedding_dim is even (pairs of nibbles). If embedding_dim is odd, this will throw a NumPy broadcasting error at pack time. Add an explicit validation so callers get a clear ValueError.
    num_embeddings, embedding_dim = weight.shape
    if embedding_dim % block_size != 0:
        raise ValueError(
            f"embedding_dim ({embedding_dim}) must be divisible by block_size ({block_size})"
        )

Comment on lines +327 to +329
from mobius._configs import (
per_model, # ruff: ignore[unused-import] - imported for registration side effect
)
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.

5 participants