Download export/_gemma3.py from executorch-community/Gemma-3-1B-IT-ExecuTorch-WebGPU: direct link, hf CLI and curl.
- Browser
- Download file 17.2 kB
-
https://huggingface.co/executorch-community/Gemma-3-1B-IT-ExecuTorch-WebGPU/resolve/main/export/_gemma3.py
- Command line
-
hf download hf://executorch-community/Gemma-3-1B-IT-ExecuTorch-WebGPU/export/_gemma3.py
-
curl -L -o _gemma3.py https://huggingface.co/executorch-community/Gemma-3-1B-IT-ExecuTorch-WebGPU/resolve/main/export/_gemma3.py
17.2 kB
| """Gemma 3 (text) for the ExecuTorch WebGPU playground: model, export and CPU validation. | |
| ExecuTorch 1.5.1's export_llm has no Gemma 3 model class, and its llama_transformer lacks the | |
| pieces Gemma 3 needs (post-attention/post-FFN norms, embedding scaling, GELU-tanh MLP, per-layer | |
| RoPE base, sliding-window layers). This module re-implements the Gemma 3 decoder with the ops the | |
| public WebGPU runtime registers and lowers it through the same LLMEdgeManager pipeline export_llm | |
| uses (same quantization transforms, edge config, to_executorch config), with WebGPUPartitioner. | |
| Program contract (same as the other playground LLMs): | |
| forward(tokens int64 [1, 1..prefill_max], input_pos int64 [1]) -> logits float32 [1, vocab] | |
| (last position), KV caches inside the program as fp32 mutable buffers. | |
| Attention: | |
| * global layers: llama.sdpa_with_kv_cache (writes K/V at input_pos, causal over [0, pos+S)). | |
| * sliding-window layers: the public runtime has no masked/windowed KV-cache op and its | |
| stand-alone update_cache does not accept a dynamic position. So each local layer keeps a | |
| position-indexed cache with PAD leading rows (position x lives at row x + PAD) and | |
| 1. writes K/V with sdpa_with_kv_cache(k, k, v, cache, pos + PAD) (1-head dummy query; | |
| its output is discarded, it only performs the cache write), | |
| 2. reads a fixed-length window of L = window + prefill_max - 1 rows ending at the last | |
| token of the chunk, and attends with bmm -> + mask -> softmax -> bmm. | |
| The additive mask is band[prefill_max - S : prefill_max] (relative positions, constant) plus | |
| padbias[pos + S - 1 : ... + L] (masks rows that hold no position yet). This reproduces | |
| transformers' sliding mask exactly: key visible iff 0 <= q_pos - k_pos < sliding_window. | |
| """ | |
| import contextlib | |
| import json | |
| import re | |
| import sys | |
| from pathlib import Path | |
| import torch | |
| import torch.nn.functional as F | |
| from torch import nn | |
| NEG = -1.0e30 # finite: WGSL does not guarantee IEEE infinities; NEG + NEG stays finite | |
| def _load_custom_ops(): | |
| import executorch.extension.llm.custom_ops.custom_ops # noqa: F401 (llama::sdpa_with_kv_cache) | |
| class RMSNorm(nn.Module): | |
| """Gemma RMSNorm with the (1 + w) offset folded into `weight` (fp32), in the op order the | |
| Vulkan rms_norm pattern matches: x * rsqrt(mean(x^2) + eps) * weight.""" | |
| def __init__(self, dim, eps): | |
| super().__init__() | |
| self.eps = eps | |
| self.weight = nn.Parameter(torch.ones(dim), requires_grad=False) | |
| def forward(self, x): | |
| x = x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps) | |
| return x * self.weight | |
| def _rope(q, k, cos, sin): | |
| # HF rotate_half RoPE through the exact module the Vulkan/WebGPU hf_rope pattern matches | |
| # (fused into et_vk.apply_rotary_emb_hf); in fp32 it computes q*cos + rotate_half(q)*sin. | |
| from executorch.backends.vulkan.patterns.rope_hf import HfRotaryEmbeddingPattern | |
| return HfRotaryEmbeddingPattern()(q, k, cos, sin) | |
| class Layer(nn.Module): | |
| def __init__(self, cfg, sliding, ctx, pad): | |
| super().__init__() | |
| d, hd = cfg["hidden_size"], cfg["head_dim"] | |
| self.nh, self.nkv, self.hd = ( | |
| cfg["num_attention_heads"], | |
| cfg["num_key_value_heads"], | |
| hd, | |
| ) | |
| self.sliding = sliding | |
| eps = cfg["rms_norm_eps"] | |
| self.wq = nn.Linear(d, self.nh * hd, bias=False) | |
| self.wk = nn.Linear(d, self.nkv * hd, bias=False) | |
| self.wv = nn.Linear(d, self.nkv * hd, bias=False) | |
| self.wo = nn.Linear(self.nh * hd, d, bias=False) | |
| self.q_norm = RMSNorm(hd, eps) | |
| self.k_norm = RMSNorm(hd, eps) | |
| self.attention_norm = RMSNorm(d, eps) | |
| self.post_attention_norm = RMSNorm(d, eps) | |
| self.ffn_norm = RMSNorm(d, eps) | |
| self.post_ffn_norm = RMSNorm(d, eps) | |
| self.w1 = nn.Linear(d, cfg["intermediate_size"], bias=False) # gate_proj | |
| self.w3 = nn.Linear(d, cfg["intermediate_size"], bias=False) # up_proj | |
| self.w2 = nn.Linear(cfg["intermediate_size"], d, bias=False) # down_proj | |
| self.rows = ctx + (pad if sliding else 0) | |
| self.make_buffers() | |
| def make_buffers(self): | |
| shape = (1, self.rows, self.nkv, self.hd) | |
| with torch.device("cpu"): | |
| self.register_buffer("k_cache", torch.zeros(shape), persistent=False) | |
| self.register_buffer("v_cache", torch.zeros(shape), persistent=False) | |
| def attention(self, x, p, S, cos, sin, mask, pad, win): | |
| q = self.wq(x).view(1, S, self.nh, self.hd) | |
| k = self.wk(x).view(1, S, self.nkv, self.hd) | |
| v = self.wv(x).view(1, S, self.nkv, self.hd) | |
| q, k = _rope(self.q_norm(q), self.k_norm(k), cos, sin) | |
| if not self.sliding: | |
| out = torch.ops.llama.sdpa_with_kv_cache( | |
| q, k, v, self.k_cache, self.v_cache, p, S, None, 0.0, True, None | |
| ) | |
| return out.view(1, S, self.nh * self.hd) | |
| # cache write only (output unused); query = k keeps it to a single head | |
| torch.ops.llama.sdpa_with_kv_cache( | |
| k, k, v, self.k_cache, self.v_cache, p + pad, S, None, 0.0, True, None | |
| ) | |
| start = p + S - 1 | |
| kw = self.k_cache.narrow(1, start, win).view(1, win, self.hd).transpose(1, 2) | |
| vw = self.v_cache.narrow(1, start, win).view(1, win, self.hd) | |
| qf = q.reshape(1, S * self.nh, self.hd) # row = s * nh + h (single KV head) | |
| scores = torch.bmm(qf, kw) * (self.hd**-0.5) | |
| scores = scores.view(1, S, self.nh, win) + mask | |
| probs = torch.softmax(scores, dim=-1).view(1, S * self.nh, win) | |
| return torch.bmm(probs, vw).view(1, S, self.nh * self.hd) | |
| def forward(self, x, p, S, cos, sin, mask, pad, win): | |
| h = x + self.post_attention_norm( | |
| self.wo(self.attention(self.attention_norm(x), p, S, cos, sin, mask, pad, win)) | |
| ) | |
| x = self.ffn_norm(h) | |
| ff = self.w2(F.gelu(self.w1(x), approximate="tanh") * self.w3(x)) | |
| return h + self.post_ffn_norm(ff) | |
| class Gemma3Text(nn.Module): | |
| def __init__(self, cfg, max_context, prefill_max): | |
| super().__init__() | |
| assert cfg["num_key_value_heads"] == 1, ( | |
| "window attention folds heads; needs 1 KV head" | |
| ) | |
| assert cfg["query_pre_attn_scalar"] == cfg["head_dim"], ( | |
| "sdpa uses 1/sqrt(head_dim)" | |
| ) | |
| assert cfg.get("attn_logit_softcapping") is None | |
| assert cfg.get("final_logit_softcapping") is None | |
| assert cfg["hidden_activation"] == "gelu_pytorch_tanh" | |
| self.cfg = cfg | |
| self.ctx, self.prefill_max = max_context, prefill_max | |
| self.window = cfg["sliding_window"] | |
| self.win = self.window + prefill_max - 1 # rows read per local layer | |
| self.pad = self.win - 1 | |
| self.layer_types = layer_types(cfg) | |
| d, V = cfg["hidden_size"], cfg["vocab_size"] | |
| self.tok_embeddings = nn.Embedding(V, d) | |
| # HF: embed(ids) * tensor(hidden_size ** 0.5) in the weight dtype (fp32 here) | |
| self.embed_scale = float(torch.tensor(d**0.5, dtype=torch.float32, device="cpu")) | |
| self.layers = nn.ModuleList( | |
| Layer(cfg, t == "sliding_attention", max_context, self.pad) | |
| for t in self.layer_types | |
| ) | |
| self.norm = RMSNorm(d, cfg["rms_norm_eps"]) | |
| self.output = nn.Linear(d, V, bias=False) | |
| self.make_buffers() | |
| def make_buffers(self): | |
| """Constant/state buffers (rope tables, window masks, KV caches), always built on CPU.""" | |
| with torch.device("cpu"): | |
| cos_g, sin_g, cos_l, sin_l = rope_tables(self.cfg, self.ctx) | |
| self.register_buffer("cos_global", cos_g, persistent=False) | |
| self.register_buffer("sin_global", sin_g, persistent=False) | |
| self.register_buffer("cos_local", cos_l, persistent=False) | |
| self.register_buffer("sin_local", sin_l, persistent=False) | |
| r = torch.arange(self.prefill_max)[:, None] | |
| j = torch.arange(self.win)[None, :] | |
| band = torch.where((j >= r) & (j <= r + self.window - 1), 0.0, NEG) | |
| self.register_buffer("band", band.float(), persistent=False) | |
| padbias = torch.zeros(self.pad + self.ctx) | |
| padbias[: self.pad] = NEG | |
| self.register_buffer("padbias", padbias, persistent=False) | |
| for layer in self.layers: | |
| layer.make_buffers() | |
| def forward(self, tokens, input_pos): | |
| S = tokens.shape[1] | |
| p = input_pos[0].item() | |
| torch._check_is_size(p) | |
| torch._check(p + S <= self.ctx) | |
| h = self.tok_embeddings(tokens) * self.embed_scale | |
| cg, sg = self.cos_global.narrow(0, p, S), self.sin_global.narrow(0, p, S) | |
| cl, sl = self.cos_local.narrow(0, p, S), self.sin_local.narrow(0, p, S) | |
| mask = self.band.narrow(0, self.prefill_max - S, S) + self.padbias.narrow( | |
| 0, p + S - 1, self.win | |
| ) | |
| mask = mask.view(1, S, 1, self.win) | |
| for layer in self.layers: | |
| if layer.sliding: | |
| h = layer(h, p, S, cl, sl, mask, self.pad, self.win) | |
| else: | |
| h = layer(h, p, S, cg, sg, None, self.pad, self.win) | |
| h = self.norm(h[:, -1, :]) | |
| return self.output(h) | |
| def layer_types(cfg): | |
| if cfg.get("layer_types"): | |
| return list(cfg["layer_types"]) | |
| pat = cfg.get("sliding_window_pattern", 6) | |
| return [ | |
| "sliding_attention" if (i + 1) % pat else "full_attention" | |
| for i in range(cfg["num_hidden_layers"]) | |
| ] | |
| def rope_tables(cfg, ctx): | |
| """cos/sin tables computed by transformers' own Gemma3RotaryEmbedding (bit-identical to HF).""" | |
| from transformers import Gemma3TextConfig | |
| from transformers.models.gemma3.modeling_gemma3 import Gemma3RotaryEmbedding | |
| hf_cfg = Gemma3TextConfig(**{k: v for k, v in cfg.items() if not k.startswith("_")}) | |
| rope = Gemma3RotaryEmbedding(hf_cfg) | |
| pos = torch.arange(ctx)[None] | |
| x = torch.zeros(1, dtype=torch.float32) | |
| cg, sg = rope(x, pos, "full_attention") | |
| cl, sl = rope(x, pos, "sliding_attention") | |
| return ( | |
| cg[0].contiguous(), | |
| sg[0].contiguous(), | |
| cl[0].contiguous(), | |
| sl[0].contiguous(), | |
| ) | |
| HF_TO_ET = { | |
| "input_layernorm": "attention_norm", | |
| "post_attention_layernorm": "post_attention_norm", | |
| "pre_feedforward_layernorm": "ffn_norm", | |
| "post_feedforward_layernorm": "post_ffn_norm", | |
| "self_attn.q_proj": "wq", | |
| "self_attn.k_proj": "wk", | |
| "self_attn.v_proj": "wv", | |
| "self_attn.o_proj": "wo", | |
| "self_attn.q_norm": "q_norm", | |
| "self_attn.k_norm": "k_norm", | |
| "mlp.gate_proj": "w1", | |
| "mlp.up_proj": "w3", | |
| "mlp.down_proj": "w2", | |
| } | |
| def build(snapshot_dir, max_context, prefill_max, linear_dtype=torch.bfloat16): | |
| """Gemma3Text with checkpoint weights. Linear/embedding weights stay in `linear_dtype` | |
| (the checkpoint's bf16, so quantization sees the stored values, as export_llm does); norms | |
| are (1 + w) in fp32 exactly as transformers computes them; output is tied to the embedding.""" | |
| from safetensors.torch import load_file | |
| snap = Path(snapshot_dir) | |
| cfg = json.loads((snap / "config.json").read_text()) | |
| sd = load_file(str(snap / "model.safetensors")) | |
| with torch.device("meta"): | |
| model = Gemma3Text(cfg, max_context, prefill_max) | |
| model = model.to_empty(device="cpu") | |
| model.make_buffers() | |
| used = set() | |
| def take(key): | |
| used.add(key) | |
| return sd[key] | |
| def norm(dst, key): | |
| dst.weight = nn.Parameter(1.0 + take(key).float(), requires_grad=False) | |
| def lin(dst, key): | |
| dst.weight = nn.Parameter(take(key).to(linear_dtype), requires_grad=False) | |
| pre = "model." if "model.embed_tokens.weight" in sd else "" | |
| emb = take(f"{pre}embed_tokens.weight").to(linear_dtype) | |
| model.tok_embeddings.weight = nn.Parameter(emb, requires_grad=False) | |
| model.output.weight = nn.Parameter(emb.clone(), requires_grad=False) # tied head | |
| norm(model.norm, f"{pre}norm.weight") | |
| for i, layer in enumerate(model.layers): | |
| for hf, et in HF_TO_ET.items(): | |
| key = f"{pre}layers.{i}.{hf}.weight" | |
| dst = layer.get_submodule(et) | |
| (norm if isinstance(dst, RMSNorm) else lin)(dst, key) | |
| unused = set(sd) - used - {"lm_head.weight"} | |
| assert not unused, f"unused checkpoint tensors: {sorted(unused)[:5]}" | |
| return model.eval(), cfg | |
| def reset_caches(model): | |
| for layer in model.layers: | |
| layer.k_cache.zero_() | |
| layer.v_cache.zero_() | |
| def quantize(model, group_size, embedding_group_size): | |
| """Same transforms and order as export_llm (embedding first, then linears), quantizing the | |
| checkpoint-dtype weights, then casting everything else to fp32.""" | |
| from executorch.examples.models.llama.source_transformation.quantize import ( | |
| get_quant_embedding_transform, | |
| get_quant_weight_transform, | |
| ) | |
| from executorch.extension.llm.export.builder import DType | |
| model = get_quant_embedding_transform( | |
| f"4,{embedding_group_size}", False, quantize_with_hqq=True | |
| )(model) | |
| model = get_quant_weight_transform( | |
| quantization_mode="4w", | |
| group_size=group_size, | |
| computation_dtype=DType.fp32, | |
| checkpoint_dtype=DType.bf16, | |
| quantize_with_hqq=True, | |
| )(model) | |
| return model.to(dtype=torch.float32) | |
| def edge_manager(model, prefill_max, max_context, cfg, metadata_extra=None): | |
| from executorch.extension.llm.export.builder import DType, LLMEdgeManager | |
| metadata = { | |
| "get_max_seq_len": prefill_max + 1, | |
| "get_max_context_len": max_context, | |
| "get_n_layers": cfg["num_hidden_layers"], | |
| "get_vocab_size": cfg["vocab_size"], | |
| "use_kv_cache": True, | |
| "use_sdpa_with_kv_cache": True, | |
| "enable_dynamic_shape": True, | |
| "get_bos_id": cfg["bos_token_id"], | |
| "get_eos_ids": list(cfg["eos_token_id"]), | |
| **(metadata_extra or {}), | |
| } | |
| return LLMEdgeManager( | |
| model=model, | |
| modelname="gemma3", | |
| max_seq_len=prefill_max + 1, | |
| dtype=DType.fp32, | |
| use_kv_cache=True, | |
| example_inputs=( | |
| torch.tensor([[2, 3, 4]], dtype=torch.long), | |
| torch.tensor([0], dtype=torch.long), | |
| ), | |
| dynamic_shapes=({1: torch.export.Dim("token_dim", max=prefill_max)}, None), | |
| enable_dynamic_shape=True, | |
| metadata=metadata, | |
| ) | |
| def webgpu_partitioner(): | |
| from executorch.backends.webgpu.partitioner import WebGPUPartitioner | |
| # Same compile options export_llm passes to VulkanPartitioner for LLMs (dynamic shapes, fp32). | |
| return WebGPUPartitioner( | |
| compile_options={"require_dynamic_shapes": True, "force_fp16": False} | |
| ) | |
| _KV_CACHE = re.compile(r"(^|\.)(k|v)_cache$") | |
| def live_kv_cache_reads(): | |
| """Lowering-time fix so sliding-window layers read the live KV cache. | |
| The Vulkan/WebGPU lowering serializes mutable buffers (KV caches) as delegate constants. | |
| llama.sdpa_with_kv_cache handles its own prepacking, so it binds the cache tensor itself and | |
| its in-place writes persist across calls. Every other reader (here: the slice_copy that cuts | |
| the attention window) gets an et_vk.prepack node, i.e. a separate GPU copy made once at | |
| load, so it would only ever see the initial zeros. This wraps the backend's | |
| insert_prepack_nodes pass and, for buffers named *.k_cache / *.v_cache only, removes those | |
| prepack nodes and points their users at the cache tensor itself, so the slices bind the | |
| same GPU buffer sdpa_with_kv_cache writes. The runtime is unchanged. | |
| """ | |
| import executorch.backends.vulkan.vulkan_preprocess as vp | |
| from executorch.exir.dialects._ops import ops as exir_ops | |
| orig = vp.insert_prepack_nodes | |
| rewired = [] | |
| def patched(program): | |
| program = orig(program) | |
| buffers = program.graph_signature.inputs_to_buffers | |
| graph = program.graph_module.graph | |
| for node in list(graph.nodes): | |
| if node.target != exir_ops.edge.et_vk.prepack.default: | |
| continue | |
| src = node.args[0] | |
| if src.op == "placeholder" and _KV_CACHE.search(buffers.get(src.name, "")): | |
| node.replace_all_uses_with(src) | |
| graph.erase_node(node) | |
| rewired.append(buffers[src.name]) | |
| program.graph_module.recompile() | |
| return program | |
| vp.insert_prepack_nodes = patched | |
| try: | |
| yield rewired | |
| finally: | |
| vp.insert_prepack_nodes = orig | |
| def lower(model, prefill_max, max_context, cfg): | |
| _load_custom_ops() | |
| em = edge_manager(model, prefill_max, max_context, cfg).export() | |
| em.run_canonical_optimizations() | |
| em = em.export_to_edge() | |
| with live_kv_cache_reads() as rewired: | |
| em = em.to_backend([webgpu_partitioner()]) | |
| n_local = sum(t == "sliding_attention" for t in layer_types(cfg)[: len(model.layers)]) | |
| assert len(rewired) == 2 * n_local, f"rewired {len(rewired)} cache reads, expected {2 * n_local}: {rewired}" | |
| em = em.to_executorch() | |
| em.rewired_kv_caches = sorted(rewired) | |
| return em | |