Files
vllm-omni/vllm_omni/edge/spark_export.py
T
tzhouamandClaude Opus 5 9a4f295625 [Edge][Mobile] Ring cache keeps the fused softmax so it survives w4a16
The score-space join removed the concat as intended and is numerically
identical (140.3 dB), but it cannot be quantized: replacing torch.softmax
with an explicit max/exp/sum/div made QNN's context-binary converter exit 14
under w4a16, while every fp16 build compiled. Two earlier shapes failed the
same way -- a broadcast multiply plus ReduceSum for the new token's score,
and a two-axis broadcast multiply for its value contribution.

The device profile says the win was never the concat anyway. Of a sliding
layer, Output was 9.9%, its quantise conversion 3.1% and the window Slice
7.4% -- all of it the cost of handing a 512-entry window back across the
graph boundary once per token. So keep roll's attention exactly as it was,
one fused Softmax over concatenated keys, and change only what the layer
returns: the single new K/V entry, which the runtime drops into the ring
slot. Ring order needs no fixing up, because softmax over keys is
order-independent and each cached key already carries its own rotary phase.

Measured on a real Galaxy S25, sliding layer, ms:

  w4a16   0.917 -> 0.838   (-8.6%)   <- now compiles
  fp16    1.689 -> 1.486   (-12.0%)

Full-attention layers keep roll: ring gains them nothing (1.567 vs 1.559 at
fp16, the concat saved being spent on the extra score matmul), so the
default layout is now per layer type.

Parity against the reference is 140.3 dB for the mixed default with
identical top-1/top-5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L1ax5wYLA6XbqEL1i8L9gZ
2026-09-11 17:41:29 +08:00

495 lines
20 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Export a Spark-X2.5 decode step for phone accelerators (QNN / TFLite / ORT).
The graph is one autoregressive step with the KV cache passed in and out as
explicit tensors, which is the shape every mobile NPU runtime wants: static
shapes, no control flow, no in-place cache mutation.
What makes Spark worth exporting this way is its hybrid attention. Three of
every four layers attend to a 512-token sliding window, so their cache is a
fixed 512 entries *no matter how long the conversation is*; only the seven
full-attention layers grow with context. At a 4k context that is 81 MiB of
KV instead of the 235 MiB a uniform-attention model of the same shape would
carry, and the gap widens linearly from there. The two layer types are
therefore exported with separately sized caches, masks and rotary angles --
sliding layers rotate all 256 head dims with theta 1e4, full layers only the
first 64 with theta 5e6.
``python -m vllm_omni.edge.spark_export --help`` exports one layer of each
type, or the whole 28-layer step, to ONNX.
"""
from __future__ import annotations
import argparse
import json
import math
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import torch
from torch import nn
from vllm_omni.edge.qnn_export import sanitize_onnx
__all__ = [
"SparkStepConfig",
"SparkDecodeStep",
"config_from_spark",
"example_inputs",
"input_names",
"output_names",
"export_onnx",
"load_spark_weights",
"kv_cache_bytes",
]
SLIDING = "sliding_attention"
FULL = "full_attention"
@dataclass
class SparkStepConfig:
"""Shape of one Spark-X2.5 decode step."""
hidden_size: int
num_attention_heads: int
num_key_value_heads: int
head_dim: int
intermediate_size: int
vocab_size: int
sliding_window: int
layer_types: tuple[str, ...]
rope_theta: dict[str, float]
partial_rotary_factor: dict[str, float]
rms_norm_eps: float = 1e-6
include_lm_head: bool = True
gelu_mode: str = "exact"
"""``exact`` is the erf GELU the model was trained with (and the only one
its own config accepts). ``tanh`` and ``sigmoid`` are the usual cheaper
approximations, exposed because the erf form is 11-13% of every layer on
the Hexagon NPU -- adopt one only if its fidelity is measured."""
cache_layout: str = "auto"
"""``ring``: the cache is a fixed ring buffer the runtime writes into and
the graph returns only the new K/V entry. ``roll``: the graph concatenates
the new entry, re-slices the window and hands the whole window back.
``auto`` (the default) picks per layer type, which is what the device
measurements say to do. Ring takes 12.2% off a sliding layer, because roll
made it hand a 512-entry window back every token. A full-attention layer
gains nothing (1.567 vs 1.559 ms: the concat ring saves is spent on its
extra score matmul) and ring additionally fails QNN's context-binary
converter at w4a16 there, so full layers keep roll."""
first_layer: int = 0
"""Index of the first exported layer, so a subset can be profiled on its own."""
def __post_init__(self) -> None:
if self.num_attention_heads % self.num_key_value_heads:
raise ValueError(
f"num_attention_heads {self.num_attention_heads} is not a multiple "
f"of num_key_value_heads {self.num_key_value_heads}"
)
bad = set(self.layer_types) - {SLIDING, FULL}
if bad:
raise ValueError(f"unknown layer types: {sorted(bad)}")
if self.cache_layout not in ("auto", "ring", "roll"):
raise ValueError(f"unknown cache_layout: {self.cache_layout}")
if self.gelu_mode not in ("exact", "tanh", "sigmoid"):
raise ValueError(f"unknown gelu_mode: {self.gelu_mode}")
@property
def group_size(self) -> int:
return self.num_attention_heads // self.num_key_value_heads
@property
def num_layers(self) -> int:
return len(self.layer_types)
def rotary_dim(self, layer_type: str) -> int:
return int(self.head_dim * self.partial_rotary_factor[layer_type])
def layout_for(self, layer_type: str) -> str:
"""Effective cache layout for a layer of this type."""
if self.cache_layout != "auto":
return self.cache_layout
return "ring" if layer_type == SLIDING else "roll"
def cache_len(self, layer_type: str, context: int) -> int:
"""KV entries a layer of this type holds at ``context`` tokens.
A 512-token window counts the current token, so a ring buffer holds
511 previous entries and the graph scores those plus the new one.
The roll layout instead keeps 512 and drops the oldest after
appending. (transformers' own sliding cache holds 511, same reason.)
"""
if layer_type != SLIDING:
return context
ring = self.layout_for(SLIDING) == "ring"
return min(context, self.sliding_window - 1 if ring else self.sliding_window)
def _gelu(x: torch.Tensor, mode: str) -> torch.Tensor:
if mode == "exact":
return torch.nn.functional.gelu(x)
if mode == "tanh":
return torch.nn.functional.gelu(x, approximate="tanh")
return x * torch.sigmoid(1.702 * x)
def _rms(x: torch.Tensor, weight: torch.Tensor, eps: float) -> torch.Tensor:
return weight * (x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + eps))
def _rope(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
"""Neox-style rotation of the leading ``cos.shape[-1]`` dims; the rest pass through."""
rot = cos.shape[-1]
if rot < x.shape[-1]:
x_rot, x_pass = x[..., :rot], x[..., rot:]
else:
x_rot, x_pass = x, None
half = rot // 2
x1, x2 = x_rot[..., :half], x_rot[..., half:]
out = x_rot * cos + torch.cat([-x2, x1], dim=-1) * sin
return out if x_pass is None else torch.cat([out, x_pass], dim=-1)
class _Layer(nn.Module):
def __init__(self, cfg: SparkStepConfig, layer_type: str):
super().__init__()
self.cfg = cfg
self.layer_type = layer_type
self.cache_layout = cfg.layout_for(layer_type)
h, d = cfg.hidden_size, cfg.head_dim
q_dim = cfg.num_attention_heads * d
kv_dim = cfg.num_key_value_heads * d
self.input_layernorm = nn.Parameter(torch.ones(h))
self.post_attention_layernorm = nn.Parameter(torch.ones(h))
self.q_k_v_proj = nn.Linear(h, q_dim + 2 * kv_dim, bias=False)
self.g_proj = nn.Linear(h, cfg.num_attention_heads, bias=False)
self.out_proj = nn.Linear(q_dim, h, bias=False)
self.gate_proj = nn.Linear(h, cfg.intermediate_size, bias=False)
self.up_proj = nn.Linear(h, cfg.intermediate_size, bias=False)
self.down_proj = nn.Linear(cfg.intermediate_size, h, bias=False)
def forward(self, x, cos, sin, k_cache, v_cache, mask):
cfg = self.cfg
heads, kv, d, g = (
cfg.num_attention_heads,
cfg.num_key_value_heads,
cfg.head_dim,
cfg.group_size,
)
h = _rms(x, self.input_layernorm, cfg.rms_norm_eps)
qkv = self.q_k_v_proj(h)
q = qkv[..., : heads * d].view(1, heads, 1, d)
k = qkv[..., heads * d : (heads + kv) * d].view(1, kv, 1, d)
v = qkv[..., (heads + kv) * d :].view(1, kv, 1, d)
gate = torch.sigmoid(self.g_proj(h)).view(1, heads, 1, 1)
q, k = _rope(q, cos, sin), _rope(k, cos, sin)
# GQA without materialising expanded K/V: fold the query heads that
# share a kv head into their own axis.
scale = 1.0 / math.sqrt(d)
qg = q.view(1, kv, g, d)
if self.cache_layout == "ring":
# The cache is a ring buffer the runtime writes into, so the graph
# never has to hand a window back: it returns the one new entry and
# the runtime drops it into the slot for this position, evicting
# the oldest. That removes both the cache-sized Output (13% of the
# layer on device) and the window Slice (7.4%).
#
# Ring order is arbitrary, which is fine: softmax over keys is
# order-independent and each cached key already carries its own
# rotary phase. Only slots not yet written need masking.
#
# Attention itself stays exactly as roll computes it -- concat the
# keys, one fused Softmax. Splitting it into an online softmax over
# two key sets removes the concat too, and is numerically identical
# (140.3 dB), but the explicit exp/max/div it needs cannot be
# quantized: QNN's context-binary converter exits 14 at w4a16,
# while the fused Softmax form compiles.
keys = torch.cat([k_cache, k], dim=2)
vals = torch.cat([v_cache, v], dim=2)
scores = torch.matmul(qg, keys.transpose(2, 3)) * scale + mask
attn = torch.matmul(torch.softmax(scores, dim=-1), vals)
k_out, v_out = k, v
else:
# Roll the window: the oldest entry falls off the front.
keys = torch.cat([k_cache, k], dim=2)
vals = torch.cat([v_cache, v], dim=2)
if self.layer_type == SLIDING:
keys, vals = keys[:, :, 1:], vals[:, :, 1:]
scores = torch.matmul(qg, keys.transpose(2, 3)) * scale + mask
attn = torch.matmul(torch.softmax(scores, dim=-1), vals)
k_out = keys if self.layer_type == SLIDING else k
v_out = vals if self.layer_type == SLIDING else v
attn = (attn.view(1, heads, 1, d) * gate).view(1, 1, heads * d)
x = x + self.out_proj(attn)
h = _rms(x, self.post_attention_layernorm, cfg.rms_norm_eps)
x = x + self.down_proj(_gelu(self.gate_proj(h), cfg.gelu_mode) * self.up_proj(h))
return x, k_out, v_out
class SparkDecodeStep(nn.Module):
"""One decode step.
Under the default ``ring`` cache layout every layer returns just the new
K/V entry and the runtime drops it into the ring slot for this position.
Under ``roll`` a sliding layer hands back its whole recomputed window,
which costs a fifth of the layer on device.
"""
def __init__(self, cfg: SparkStepConfig):
super().__init__()
self.cfg = cfg
self.layers = nn.ModuleList(_Layer(cfg, t) for t in cfg.layer_types)
if cfg.include_lm_head:
self.norm = nn.Parameter(torch.ones(cfg.hidden_size))
self.lm_head = nn.Linear(cfg.hidden_size, cfg.vocab_size, bias=False)
def forward(self, x, cos_sw, sin_sw, cos_full, sin_full, mask_sw, mask_full, *caches):
cfg = self.cfg
if len(caches) != 2 * cfg.num_layers:
raise ValueError(
f"expected {2 * cfg.num_layers} cache tensors, got {len(caches)}"
)
new: list[torch.Tensor] = []
for i, layer in enumerate(self.layers):
sliding = cfg.layer_types[i] == SLIDING
cos, sin = (cos_sw, sin_sw) if sliding else (cos_full, sin_full)
mask = mask_sw if sliding else mask_full
x, k, v = layer(x, cos, sin, caches[2 * i], caches[2 * i + 1], mask)
new += [k, v]
if not cfg.include_lm_head:
return (x, *new)
logits = self.lm_head(_rms(x, self.norm, cfg.rms_norm_eps))[:, 0]
return (logits, *new)
def config_from_spark(
hf_config: dict[str, Any],
layer_slice: slice | None = None,
include_lm_head: bool = True,
cache_layout: str = "auto",
gelu_mode: str = "exact",
) -> SparkStepConfig:
"""Build a step config from a Spark ``config.json``.
``layer_slice`` selects a contiguous subset of layers, which is how a
single sliding or full layer gets profiled on its own.
"""
types = list(hf_config["layer_types"])
first = 0
if layer_slice is not None:
first = layer_slice.start or 0
types = types[layer_slice]
rope = hf_config["rope_parameters"]
return SparkStepConfig(
hidden_size=hf_config["hidden_size"],
num_attention_heads=hf_config["num_attention_heads"],
num_key_value_heads=hf_config["num_key_value_heads"],
head_dim=hf_config["head_dim"],
intermediate_size=hf_config["intermediate_size"],
vocab_size=hf_config["vocab_size"],
sliding_window=hf_config["sliding_window"],
layer_types=tuple(types),
rope_theta={k: float(v.get("rope_theta", 10000)) for k, v in rope.items()},
partial_rotary_factor={
k: float(v.get("partial_rotary_factor", 1.0)) for k, v in rope.items()
},
rms_norm_eps=hf_config.get("rms_norm_eps", 1e-6),
include_lm_head=include_lm_head,
cache_layout=cache_layout,
gelu_mode=gelu_mode,
first_layer=first,
)
def input_names(cfg: SparkStepConfig) -> list[str]:
return [
"x",
"cos_sw",
"sin_sw",
"cos_full",
"sin_full",
"mask_sw",
"mask_full",
] + [f"{kv}_cache_{i}" for i in range(cfg.num_layers) for kv in ("k", "v")]
def output_names(cfg: SparkStepConfig) -> list[str]:
head = "logits" if cfg.include_lm_head else "hidden"
return [head] + [
f"{kv}_new_{i}" for i in range(cfg.num_layers) for kv in ("k", "v")
]
def _angles(position: int, rotary_dim: int, theta: float, dtype: torch.dtype):
inv = 1.0 / (
theta ** (torch.arange(0, rotary_dim, 2, dtype=torch.float32) / rotary_dim)
)
ang = position * inv
cos = torch.cat([ang.cos(), ang.cos()]).view(1, 1, 1, rotary_dim).to(dtype)
sin = torch.cat([ang.sin(), ang.sin()]).view(1, 1, 1, rotary_dim).to(dtype)
return cos, sin
def example_inputs(
cfg: SparkStepConfig,
context: int,
position: int | None = None,
dtype: torch.dtype = torch.float32,
) -> tuple[torch.Tensor, ...]:
"""Concrete inputs for tracing at a given context length."""
pos = context if position is None else position
sw_len = cfg.cache_len(SLIDING, context)
full_len = cfg.cache_len(FULL, context)
cos_sw, sin_sw = _angles(pos, cfg.rotary_dim(SLIDING), cfg.rope_theta[SLIDING], dtype)
cos_full, sin_full = _angles(pos, cfg.rotary_dim(FULL), cfg.rope_theta[FULL], dtype)
# Both layouts score the same number of keys: ring scores its cache plus
# the new token, roll scores the window it just rebuilt (which already
# contains it). Ring's sliding cache is one shorter, so the widths match.
mask_sw = torch.zeros(1, 1, 1, sw_len + (1 if cfg.layout_for(SLIDING) == "ring" else 0), dtype=dtype)
mask_full = torch.zeros(1, 1, 1, full_len + 1, dtype=dtype)
x = (torch.randn(1, 1, cfg.hidden_size) * 0.1).to(dtype)
caches: list[torch.Tensor] = []
for t in cfg.layer_types:
n = cfg.cache_len(t, context)
for _ in range(2):
caches.append(
torch.randn(1, cfg.num_key_value_heads, n, cfg.head_dim, dtype=dtype)
* 0.5
)
return (x, cos_sw, sin_sw, cos_full, sin_full, mask_sw, mask_full, *caches)
def kv_cache_bytes(cfg: SparkStepConfig, context: int, bytes_per_elem: int = 2) -> dict[str, int]:
"""KV footprint of the hybrid layout versus an all-full-attention model."""
per_entry = 2 * cfg.num_key_value_heads * cfg.head_dim * bytes_per_elem
hybrid = sum(cfg.cache_len(t, context) for t in cfg.layer_types) * per_entry
uniform = cfg.num_layers * context * per_entry
return {"hybrid": hybrid, "uniform": uniform, "saved": uniform - hybrid}
def load_spark_weights(
module: SparkDecodeStep, weights_dir: str | Path, dtype: torch.dtype = torch.float32
) -> int:
"""Load real Spark weights into the exported step. Returns tensors copied."""
from safetensors.torch import load_file
weights_dir = Path(weights_dir)
shards = sorted(weights_dir.glob("*.safetensors"))
if not shards:
raise FileNotFoundError(f"no safetensors under {weights_dir}")
state: dict[str, torch.Tensor] = {}
for shard in shards:
state.update(load_file(str(shard)))
cfg = module.cfg
copied = 0
with torch.no_grad():
for i, layer in enumerate(module.layers):
src = f"model.layers.{cfg.first_layer + i}."
for dst_name, key in (
("input_layernorm", "input_layernorm.weight"),
("post_attention_layernorm", "post_attention_layernorm.weight"),
):
getattr(layer, dst_name).copy_(state[src + key].to(dtype))
copied += 1
for mod, key in (
(layer.q_k_v_proj, "self_attn.q_k_v_proj.weight"),
(layer.g_proj, "self_attn.g_proj.weight"),
(layer.out_proj, "self_attn.out_proj.weight"),
(layer.gate_proj, "mlp.gate_proj.weight"),
(layer.up_proj, "mlp.up_proj.weight"),
(layer.down_proj, "mlp.down_proj.weight"),
):
mod.weight.copy_(state[src + key].to(dtype))
copied += 1
if cfg.include_lm_head:
module.norm.copy_(state["model.norm.weight"].to(dtype))
# Spark ties the output projection to the input embedding.
emb = state.get("lm_head.weight", state["model.embedding.weight"])
module.lm_head.weight.copy_(emb.to(dtype))
copied += 2
return copied
def export_onnx(
module: SparkDecodeStep, context: int, path: str | Path, opset: int = 18
) -> dict[str, Any]:
"""Trace to ONNX at a fixed context length and sanitize for Qualcomm tools."""
cfg = module.cfg
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
args = example_inputs(cfg, context)
module.eval()
with torch.no_grad():
torch.onnx.export(
module,
args,
str(path),
input_names=input_names(cfg),
output_names=output_names(cfg),
opset_version=opset,
dynamo=False,
)
stats = sanitize_onnx(path)
return {
"path": str(path),
"context": context,
"layers": cfg.num_layers,
"layer_types": list(cfg.layer_types),
"include_lm_head": cfg.include_lm_head,
"sanitized": stats,
"kv_bytes": kv_cache_bytes(cfg, context),
}
def _main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--config", required=True, help="path to Spark config.json")
ap.add_argument("--weights", default=None, help="directory of safetensors shards")
ap.add_argument("--out", required=True)
ap.add_argument("--context", type=int, default=1024)
ap.add_argument(
"--layers",
default="all",
help="'all', 'sliding', 'full', or START:STOP",
)
ap.add_argument("--no-lm-head", action="store_true")
ap.add_argument("--cache-layout", default="auto", choices=["auto", "ring", "roll"])
ap.add_argument("--gelu", default="exact", choices=["exact", "tanh", "sigmoid"])
a = ap.parse_args(argv)
hf = json.loads(Path(a.config).read_text())
types = hf["layer_types"]
if a.layers == "all":
sl = None
elif a.layers == "sliding":
i = types.index(SLIDING)
sl = slice(i, i + 1)
elif a.layers == "full":
i = types.index(FULL)
sl = slice(i, i + 1)
else:
start, stop = (int(v) for v in a.layers.split(":"))
sl = slice(start, stop)
cfg = config_from_spark(hf, sl, include_lm_head=not a.no_lm_head,
cache_layout=a.cache_layout, gelu_mode=a.gelu)
step = SparkDecodeStep(cfg)
if a.weights:
print(f"loaded {load_spark_weights(step, a.weights)} tensors")
print(json.dumps(export_onnx(step, a.context, a.out), indent=2))
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(_main())