psiddh's picture
Add ExecuTorch WebGPU program for gemma-3-1b-it
91864e5 verified
Raw History Blame Contribute Delete
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$")
@contextlib.contextmanager
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