psiddh's picture
Add ExecuTorch WebGPU program for smolvlm-500m-instruct
86276a1 verified
Raw History Blame Contribute Delete
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)
@torch.library.register_fake("et_vk::linear_q8ta_q8csw")
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