Download export/_smolvlm_q8.py from executorch-community/SmolVLM-500M-Instruct-ExecuTorch-WebGPU: direct link, hf CLI and curl.
- Browser
- Download file 21.5 kB
-
https://huggingface.co/executorch-community/SmolVLM-500M-Instruct-ExecuTorch-WebGPU/resolve/main/export/_smolvlm_q8.py
- Command line
-
hf download hf://executorch-community/SmolVLM-500M-Instruct-ExecuTorch-WebGPU/export/_smolvlm_q8.py
-
curl -L -o _smolvlm_q8.py https://huggingface.co/executorch-community/SmolVLM-500M-Instruct-ExecuTorch-WebGPU/resolve/main/export/_smolvlm_q8.py
21.5 kB
| """Building blocks for the 8-bit SmolVLM WebGPU recipe (smolvlm-500m-instruct-q8.py). | |
| The one 8-bit linear that public ExecuTorch lowers for WebGPU and the public WebGPU runtime runs is | |
| et_vk.linear_q8ta_q8csw: int8 activations with one static per-tensor scale, times int8 weights with | |
| one scale per output channel. It is emitted for linear(dq(q(x, s, z)), dq_per_channel(W8, ws, 0)), | |
| which Q8Linear below writes out directly with quantized_decomposed ops (no PT2E prepare/convert: | |
| the scales come from torchao MinMaxObservers run over calibration data on the fp32 model). | |
| Two things make it accurate enough for this model (see VLM_FOLLOWUP.md for the measurements): | |
| - Two passes. y = W8.dq(q(x, s1)) + W8.dq(q(r, s2)), r = x - dq(q(x, s1)): the second pass quantizes | |
| the first pass's rounding error with its own static scale (s2 ~ s1/255) and reuses the same int8 | |
| weight, so the activation error drops from s1/2 to about s1/510. The residual is written with | |
| plain ops (round/clamp/mul): the lowering would fold an explicit dq(q(x)) back to x for a | |
| non-quantized consumer, which makes the residual zero. | |
| - A built-in attention sink. The text decoder's position 0 is always <|im_start|>, whose hidden | |
| state carries massive activations (|x| ~ 3000 at the input of layer 3's down projection, vs ~4 at | |
| every other position). Its K/V are the same in every conversation, so they are computed once in | |
| fp32 and written into the cache by the program itself whenever a call starts at position 0 | |
| (SinkKVCache); the calibration excludes that row, so it never sets a scale. | |
| """ | |
| import math | |
| import torch | |
| import torch.nn.functional as F | |
| qd = torch.ops.quantized_decomposed | |
| QMIN, QMAX = -128, 127 | |
| # ----------------------------------------------------------------------------- quantization | |
| def weight_int8(w): | |
| """VulkanQuantizer's weight spec: int8 [-128, 127], per-output-channel symmetric, scale from | |
| torchao PerChannelMinMaxObserver (amax / 127.5). Returns (int8 [N, K], fp32 scales [N]).""" | |
| from torchao.quantization.pt2e import PerChannelMinMaxObserver | |
| obs = PerChannelMinMaxObserver( | |
| ch_axis=0, | |
| dtype=torch.int8, | |
| quant_min=QMIN, | |
| quant_max=QMAX, | |
| qscheme=torch.per_channel_symmetric, | |
| ) | |
| obs(w) | |
| scale, zp = obs.calculate_qparams() | |
| assert int(zp.abs().max()) == 0 | |
| scale = scale.float() | |
| q = torch.clamp(torch.round(w / scale[:, None]), QMIN, QMAX).to(torch.int8) | |
| return q, scale | |
| def act_observer(): | |
| """Static per-tensor affine int8 activation observer (min/max of everything it saw).""" | |
| from torchao.quantization.pt2e import MinMaxObserver | |
| return MinMaxObserver( | |
| dtype=torch.int8, | |
| quant_min=QMIN, | |
| quant_max=QMAX, | |
| qscheme=torch.per_tensor_affine, | |
| ) | |
| def qparams(obs): | |
| s, z = obs.calculate_qparams() | |
| return float(s), int(z) | |
| def fake_quant(x, s, z): | |
| """dq(q(x)) exactly as quantized_decomposed computes it (round(x * (1/s)) + z, clamp).""" | |
| return (torch.clamp(torch.round(x * (1.0 / s)) + z, QMIN, QMAX) - z) * s | |
| class Q8Linear(torch.nn.Module): | |
| """et_vk.linear_q8ta_q8csw, one or two passes (see the module docstring).""" | |
| def __init__(self, lin, act, second=None): | |
| super().__init__() | |
| q, scale = weight_int8(lin.weight.detach().float()) | |
| self.register_buffer("weight_int8", q) | |
| self.register_buffer("weight_scale", scale) | |
| self.register_buffer("weight_zero", torch.zeros(q.shape[0], dtype=torch.int64)) | |
| self.bias = ( | |
| None | |
| if lin.bias is None | |
| else torch.nn.Parameter(lin.bias.detach().float(), requires_grad=False) | |
| ) | |
| self.act = (float(act[0]), int(act[1])) | |
| self.second = None if second is None else (float(second[0]), int(second[1])) | |
| def forward(self, x): | |
| s1, z1 = self.act | |
| w = qd.dequantize_per_channel( | |
| self.weight_int8, | |
| self.weight_scale, | |
| self.weight_zero, | |
| 0, | |
| QMIN, | |
| QMAX, | |
| torch.int8, | |
| ) | |
| x1 = qd.dequantize_per_tensor( | |
| qd.quantize_per_tensor(x, s1, z1, QMIN, QMAX, torch.int8), | |
| s1, | |
| z1, | |
| QMIN, | |
| QMAX, | |
| torch.int8, | |
| ) | |
| y = F.linear(x1, w, self.bias) | |
| if self.second is not None: | |
| s2, z2 = self.second | |
| r = x - fake_quant(x, s1, z1) | |
| r1 = qd.dequantize_per_tensor( | |
| qd.quantize_per_tensor(r, s2, z2, QMIN, QMAX, torch.int8), | |
| s2, | |
| z2, | |
| QMIN, | |
| QMAX, | |
| torch.int8, | |
| ) | |
| y = y + F.linear(r1, w) | |
| return y | |
| def linear_names(module): | |
| return [n for n, m in module.named_modules() if isinstance(m, torch.nn.Linear)] | |
| def set_module(root, name, new): | |
| parent, _, leaf = name.rpartition(".") | |
| setattr(root.get_submodule(parent) if parent else root, leaf, new) | |
| class Recorder: | |
| """Forward pre-hooks feeding one observer per linear. `skip_first_row` (set by the feeder while | |
| a call starts at position 0) drops row 0, the attention-sink position, from what is observed.""" | |
| def __init__(self, module, names, transform=None): | |
| self.obs = {n: act_observer() for n in names} | |
| self.skip_first_row = False | |
| self.hooks = [] | |
| for n in names: | |
| def hook(m, args, n=n): | |
| x = args[0].detach().float() | |
| if self.skip_first_row and x.dim() == 3: | |
| x = x[:, 1:] | |
| x = x.reshape(-1, x.shape[-1]) | |
| if x.numel(): | |
| self.obs[n](transform(n, x) if transform else x) | |
| self.hooks.append(module.get_submodule(n).register_forward_pre_hook(hook)) | |
| def close(self): | |
| for h in self.hooks: | |
| h.remove() | |
| return {n: qparams(o) for n, o in self.obs.items()} | |
| def calibrate(module, names, feed, two_pass): | |
| """Static activation qparams for every linear in `names`: {name: (first, second or None)}. | |
| feed(recorder) runs the fp32 module over the calibration data (twice for two passes: the | |
| second run observes each linear's first-pass rounding error).""" | |
| rec = Recorder(module, names) | |
| with torch.no_grad(): | |
| feed(rec) | |
| first = rec.close() | |
| second = {} | |
| if two_pass: | |
| rec = Recorder( | |
| module, names, transform=lambda n, x: x - fake_quant(x, *first[n]) | |
| ) | |
| with torch.no_grad(): | |
| feed(rec) | |
| second = rec.close() | |
| return {n: (first[n], second.get(n)) for n in names} | |
| def quantize(module, act): | |
| """Replace every linear named in `act` by Q8Linear (in place).""" | |
| for n, (first, second) in act.items(): | |
| set_module(module, n, Q8Linear(module.get_submodule(n), first, second)) | |
| return module | |
| _FAKE_FIXED = [] | |
| def fix_q8ta_fake(): | |
| """executorch 1.5.1's reference kernel for et_vk.linear_q8ta_q8csw computes linear(x_int8, w_fp32), | |
| so under FakeTensorMode (the Vulkan FusePatternsPass re-trace) its output comes out int8. Every | |
| consumer then inherits int8 metadata: llama::custom_sdpa's meta check fails on the query, and | |
| the serialized delegate would declare fp32 activations as int8 (the WebGPU kernel rejects a | |
| non-fp32 output). A fake kernel with the real output dtype (fp32, [..., N]) fixes the metadata; | |
| the serialized ops and constants are otherwise unchanged.""" | |
| if _FAKE_FIXED: | |
| return | |
| import executorch.backends.vulkan.custom_ops_lib # noqa: F401 (defines et_vk ops) | |
| def _fake(x, input_scale, input_zero_point, weights, weight_sums, weight_scales, bias=None): | |
| return x.new_empty((*x.shape[:-1], weights.shape[0]), dtype=torch.float32) | |
| _FAKE_FIXED.append(True) | |
| # ----------------------------------------------------------------------------- attention sink | |
| class SinkKVCache(torch.nn.Module): | |
| """executorch's CustomKVCache (llama::update_cache into a [1, C, H, D] cache) with the K/V of | |
| position 0 built in: when a call starts at position 0, its row 0 is replaced by the constants | |
| before the cache update. sink = relu(1 - input_pos) is 1 at position 0 and 0 everywhere else, | |
| so later calls are untouched (x + 0 * (c - x) = x).""" | |
| def __init__(self, base, k0, v0, rows): | |
| super().__init__() | |
| self.max_context_length = base.max_context_length | |
| self.max_batch_size, self.n_heads, self.head_dim = ( | |
| base.max_batch_size, | |
| base.n_heads, | |
| base.head_dim, | |
| ) | |
| self.register_buffer("k_cache", base.k_cache) | |
| self.register_buffer("v_cache", base.v_cache) | |
| # [B, S, H, D], the cache layout (update() receives [B, H, S, D] and transposes back first, | |
| # so the arithmetic below sees one dim order: the portable kernels require that). | |
| self.register_buffer("k0", k0.detach().float().contiguous()) | |
| self.register_buffer("v0", v0.detach().float().contiguous()) | |
| first_row = torch.zeros(1, rows, 1, 1) | |
| first_row[0, 0, 0, 0] = 1.0 | |
| self.register_buffer("first_row", first_row) | |
| def update(self, input_pos, k_val, v_val, indices=None): | |
| assert indices is None | |
| k_val = k_val.transpose(1, 2) | |
| v_val = v_val.transpose(1, 2) | |
| sink = torch.relu(1.0 - input_pos[:1].to(torch.float32)).reshape(1, 1, 1, 1) * self.first_row | |
| k_val = k_val + sink * (self.k0 - k_val) | |
| v_val = v_val + sink * (self.v0 - v_val) | |
| start_pos = input_pos[0].item() | |
| _ = torch.ops.llama.update_cache(k_val, self.k_cache, start_pos) | |
| _ = torch.ops.llama.update_cache(v_val, self.v_cache, start_pos) | |
| return self.k_cache.transpose(1, 2), self.v_cache.transpose(1, 2) | |
| def sink_kv(transformer, row0): | |
| """Per-layer K/V of position 0 for the fp32 transformer, given its input row [hidden].""" | |
| for name, buf in transformer.named_buffers(): | |
| if name.endswith("k_cache") or name.endswith("v_cache"): | |
| buf.zero_() | |
| with torch.no_grad(): | |
| transformer(None, {"input_pos": torch.tensor([0])}, row0[None, None]) | |
| out = [] | |
| for layer in transformer.layers: | |
| kv = layer.attention.kv_cache | |
| out.append((kv.k_cache[:, :1].clone(), kv.v_cache[:, :1].clone())) | |
| kv.k_cache.zero_() | |
| kv.v_cache.zero_() | |
| return out | |
| def install_sink(transformer, kvs, rows): | |
| for layer, (k0, v0) in zip(transformer.layers, kvs): | |
| layer.attention.kv_cache = SinkKVCache(layer.attention.kv_cache, k0, v0, rows) | |
| return transformer | |
| class RefSDPA(torch.nn.Module): | |
| """Causal attention over the KV cache in plain PyTorch, used only while collecting calibration | |
| statistics. llama::custom_sdpa's CPU kernel divides its work by the threads it gets, so its float | |
| sums (and with them a few activation ranges, in the last bits) vary from run to run; this keeps | |
| the calibration, and the exported files, byte-reproducible. Same inputs/outputs as SDPACustom.""" | |
| def __init__(self, dim, n_rep): | |
| super().__init__() | |
| self.dim, self.n_rep = dim, n_rep | |
| def forward(self, input_pos, q, k, v, bsz, seqlen, mask): | |
| # q [B, Hq, S, D]; k, v [B, Hkv, L, D] (the whole cache) | |
| end = int(input_pos[0]) + seqlen | |
| k = k[:, :, :end].repeat_interleave(self.n_rep, dim=1) | |
| v = v[:, :, :end].repeat_interleave(self.n_rep, dim=1) | |
| scores = (q @ k.transpose(-1, -2)) / math.sqrt(q.shape[-1]) | |
| rows = torch.arange(end - seqlen, end)[:, None] | |
| scores = scores.masked_fill(torch.arange(end)[None, :] > rows, float("-inf")) | |
| out = torch.softmax(scores, dim=-1) @ v | |
| return out.transpose(1, 2).reshape(bsz, seqlen, self.dim) | |
| class reference_attention: | |
| """Context manager: every layer's SDPA module replaced by RefSDPA, restored on exit.""" | |
| def __init__(self, transformer): | |
| self.t = transformer | |
| def __enter__(self): | |
| self.saved = [] | |
| for layer in self.t.layers: | |
| a = layer.attention | |
| self.saved.append(a.SDPA) | |
| a.SDPA = RefSDPA(a.SDPA.dim, a.n_heads // a.n_kv_heads) | |
| return self.t | |
| def __exit__(self, *exc): | |
| for layer, sdpa in zip(self.t.layers, self.saved): | |
| layer.attention.SDPA = sdpa | |
| # ----------------------------------------------------------------------------- windows | |
| def window_starts(start, end, rows): | |
| """First positions of the `rows`-row windows that feed positions [start, end): each window ends | |
| at most at `end`, the last exactly there (the same plan as windowStarts() in vlm.js).""" | |
| if start >= end: | |
| return [] | |
| assert end >= rows, (start, end, rows) | |
| out, stop = [], min(start + rows, end) | |
| while True: | |
| out.append(stop - rows) | |
| if stop == end: | |
| return out | |
| stop = min(stop + rows, end) | |
| # ----------------------------------------------------------------------------- inspection | |
| def q8_ops_vs_eager(pte_bytes, module): | |
| """Every et_vk.linear_q8ta_q8csw in the delegates: its int8 weight and weight scales must be | |
| bit-identical to one Q8Linear's buffers, and its activation scale / zero point one of that | |
| linear's static qparams (scale to 1e-7 relative: the flatc JSON dump prints about 8 significant | |
| digits). Returns counts.""" | |
| import hashlib | |
| import _smolvlm as S | |
| import numpy as np | |
| from executorch.backends.vulkan.serialization.vulkan_graph_serialize import ( | |
| VulkanDelegateHeader, | |
| ) | |
| from executorch.exir._serialize._program import deserialize_pte_binary | |
| eager = {} | |
| for m in module.modules(): | |
| if isinstance(m, Q8Linear): | |
| key = hashlib.sha256(m.weight_int8.numpy().tobytes()).hexdigest() | |
| eager[key] = ( | |
| m.weight_scale.numpy(), | |
| {m.act} | ({m.second} if m.second else set()), | |
| ) | |
| pte = deserialize_pte_binary(pte_bytes) | |
| prog = pte.program if hasattr(pte, "program") else pte | |
| nd = getattr(pte, "named_data", None) | |
| seg_base = int.from_bytes(pte_bytes[24:32], "little") | |
| total = weights_ok = scales_ok = act_ok = 0 | |
| for plan in prog.execution_plan: | |
| for d in plan.delegates: | |
| proc = d.processed | |
| if proc.location.name == "INLINE": | |
| blob = bytes(prog.backend_delegate_data[proc.index].data) | |
| else: | |
| seg = prog.segments[proc.index] | |
| blob = pte_bytes[ | |
| seg_base + seg.offset : seg_base + seg.offset + seg.size | |
| ] | |
| h = VulkanDelegateHeader.from_bytes( | |
| blob[: VulkanDelegateHeader.EXPECTED_LENGTH] | |
| ) | |
| raw = blob[h.bytes_offset : h.bytes_offset + h.bytes_size] | |
| g = S._vk_graph(blob) | |
| def const(vid): | |
| t = g.values[vid].value | |
| c = g.constants[t.constant_id] | |
| if c.named_key: | |
| return bytes( | |
| nd.buffers[nd.pte_data[c.named_key].buffer_index] | |
| ), list(t.dims) | |
| return raw[c.offset : c.offset + c.length], list(t.dims) | |
| for op in g.chain: | |
| if op.name != "et_vk.linear_q8ta_q8csw.default": | |
| continue | |
| total += 1 | |
| wb, (n, k) = const(op.args[3]) | |
| hit = eager.get(hashlib.sha256(wb[: n * k]).hexdigest()) | |
| if hit is None: | |
| continue | |
| weights_ok += 1 | |
| sb, _ = const(op.args[5]) | |
| scales = np.frombuffer(sb, dtype=np.float32)[:n] | |
| scales_ok += int(np.array_equal(scales, hit[0])) | |
| s = g.values[op.args[1]].value.double_val | |
| z = g.values[op.args[2]].value.int_val | |
| act_ok += int( | |
| any(abs(s - a) <= 1e-7 * a and z == b for a, b in hit[1]) | |
| ) | |
| return { | |
| "linear_q8ta_q8csw_ops": total, | |
| "weight_int8_bit_identical": weights_ok, | |
| "weight_scales_bit_identical": scales_ok, | |
| "activation_qparams_match": act_ok, | |
| "eager_q8_linears": len(eager), | |
| } | |
| # ----------------------------------------------------------------------------- GPTQ (optional) | |
| def gptq_int8(w, H, damp=0.01, block=128): | |
| """GPTQ (Frantar et al. 2022) for the same int8 grid as weight_int8 (per-output-channel symmetric, | |
| scale = amax / 127.5 of the original weight): columns are rounded in order and each rounding | |
| error is pushed onto the not-yet-rounded columns through the inverse Hessian H = sum x x^T of the | |
| layer's calibration inputs. Returns (int8 [N, K], fp32 scales [N]); float64 throughout.""" | |
| _, scale = weight_int8(w) | |
| s = scale.double()[:, None] | |
| w = w.detach().double().clone() | |
| H = H.detach().double().clone() | |
| K = w.shape[1] | |
| dead = torch.diag(H) == 0 | |
| H[dead, dead] = 1 | |
| w[:, dead] = 0 | |
| H += damp * torch.mean(torch.diag(H)) * torch.eye(K, dtype=H.dtype) | |
| hinv = torch.linalg.cholesky(torch.cholesky_inverse(torch.linalg.cholesky(H)), upper=True) | |
| q = torch.zeros_like(w) | |
| for i1 in range(0, K, block): | |
| i2 = min(i1 + block, K) | |
| w1 = w[:, i1:i2].clone() | |
| err1 = torch.zeros_like(w1) | |
| h1 = hinv[i1:i2, i1:i2] | |
| for i in range(i2 - i1): | |
| col = w1[:, i : i + 1] | |
| qc = torch.clamp(torch.round(col / s), QMIN, QMAX) | |
| q[:, i1 + i : i1 + i + 1] = qc | |
| e = (col - qc * s) / h1[i, i] | |
| w1[:, i:] -= e * h1[i : i + 1, i:] | |
| err1[:, i : i + 1] = e | |
| w[:, i2:] -= err1 @ hinv[i1:i2, i2:] | |
| return q.to(torch.int8), scale | |
| def hessians(module, names, feed): | |
| """sum x x^T (float64) of every named linear's input over feed(recorder-like ctx).""" | |
| hs, hooks = {}, [] | |
| class Ctx: | |
| skip_first_row = False | |
| ctx = Ctx() | |
| for n in names: | |
| def hook(m, args, n=n): | |
| x = args[0].detach().double() | |
| if ctx.skip_first_row and x.dim() == 3: | |
| x = x[:, 1:] | |
| x = x.reshape(-1, x.shape[-1]) | |
| hs[n] = hs[n] + x.T @ x if n in hs else x.T @ x | |
| rows[n] = rows.get(n, 0) + x.shape[0] | |
| hooks.append(module.get_submodule(n).register_forward_pre_hook(hook)) | |
| rows = {} | |
| with torch.no_grad(): | |
| feed(ctx) | |
| for h in hooks: | |
| h.remove() | |
| hessians.rows = rows | |
| return hs | |
| # ----------------------------------------------------------------------------- model builders | |
| def quantize_with(module, act, weights=None): | |
| """Like quantize(), with optional precomputed (int8, scale) weights per linear (GPTQ).""" | |
| for n, (first, second) in act.items(): | |
| lin = module.get_submodule(n) | |
| q = Q8Linear(lin, first, second) | |
| if weights and n in weights: | |
| q.weight_int8.copy_(weights[n][0]) | |
| q.weight_scale.copy_(weights[n][1]) | |
| set_module(module, n, q) | |
| return module | |
| def build_vision(vision_fp32, images, *, two_pass, gptq): | |
| """Quantize a (deep-copied) fp32 VisionEncoder: every linear becomes Q8Linear, scales from the | |
| calibration images (pixel tensors [1,3,S,S]).""" | |
| import copy | |
| v = copy.deepcopy(vision_fp32).eval() | |
| names = linear_names(v) | |
| def feed(rec): | |
| for px in images: | |
| v(px) | |
| act = calibrate(v, names, feed, two_pass) | |
| weights = None | |
| if gptq: | |
| # GPTQ only where the calibration rows outnumber the input features: the connector | |
| # (12288 inputs, 64 rows per image) keeps round-to-nearest. | |
| hs = hessians(v, names, feed) | |
| weights = {n: gptq_int8(v.get_submodule(n).weight, hs[n]) for n in names if hessians.rows[n] >= hs[n].shape[0]} | |
| del hs | |
| return quantize_with(v, act, weights), act | |
| def build_text(transformer, calib_rows, *, rows, two_pass, gptq, sink_row=None): | |
| """Quantize the fp32 executorch llama Transformer in place. | |
| calib_rows: list of [n, hidden] embedding sequences (prompt + continuation), fed in the same | |
| `rows`-row windows the browser uses; row 0 (the sink) is excluded from every statistic. | |
| sink_row: the input row of position 0 (the <|im_start|> embedding as the host feeds it), or None | |
| for no built-in sink. Returns (transformer, activation qparams, sink K/V).""" | |
| import _smolvlm as S | |
| names = linear_names(transformer) | |
| sink = sink_row is not None | |
| with reference_attention(transformer): | |
| kvs = sink_kv(transformer, sink_row) if sink else None | |
| def feed(rec): | |
| for seq in calib_rows: | |
| S.reset_kv(transformer) | |
| for start in window_starts(0, seq.shape[0], rows): | |
| rec.skip_first_row = sink and start == 0 | |
| transformer(None, {"input_pos": torch.tensor([start])}, seq[None, start : start + rows]) | |
| rec.skip_first_row = False | |
| S.reset_kv(transformer) | |
| with reference_attention(transformer): | |
| act = calibrate(transformer, names, feed, two_pass) | |
| hs = hessians(transformer, names, feed) if gptq else None | |
| weights = None | |
| if gptq: | |
| weights = {n: gptq_int8(transformer.get_submodule(n).weight, hs.pop(n)) for n in names} | |
| quantize_with(transformer, act, weights) | |
| if sink: | |
| install_sink(transformer, kvs, rows) | |
| return transformer, act, kvs | |