2026-09-11 14:49:10 +08:00
|
|
|
# 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
|
2026-09-11 17:02:48 +08:00
|
|
|
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."""
|
2026-09-11 17:41:29 +08:00
|
|
|
cache_layout: str = "auto"
|
|
|
|
|
"""``ring``: the cache is a fixed ring buffer the runtime writes into and
|
2026-09-11 17:02:48 +08:00
|
|
|
the graph returns only the new K/V entry. ``roll``: the graph concatenates
|
2026-09-11 17:41:29 +08:00
|
|
|
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."""
|
2026-09-11 14:49:10 +08:00
|
|
|
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)}")
|
2026-09-11 17:41:29 +08:00
|
|
|
if self.cache_layout not in ("auto", "ring", "roll"):
|
2026-09-11 17:02:48 +08:00
|
|
|
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}")
|
2026-09-11 14:49:10 +08:00
|
|
|
|
|
|
|
|
@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])
|
|
|
|
|
|
2026-09-11 17:41:29 +08:00
|
|
|
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"
|
|
|
|
|
|
2026-09-11 14:49:10 +08:00
|
|
|
def cache_len(self, layer_type: str, context: int) -> int:
|
2026-09-11 17:02:48 +08:00
|
|
|
"""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
|
2026-09-11 17:41:29 +08:00
|
|
|
ring = self.layout_for(SLIDING) == "ring"
|
|
|
|
|
return min(context, self.sliding_window - 1 if ring else self.sliding_window)
|
2026-09-11 17:02:48 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-09-11 14:49:10 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-11 17:41:29 +08:00
|
|
|
self.cache_layout = cfg.layout_for(layer_type)
|
2026-09-11 14:49:10 +08:00
|
|
|
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.
|
2026-09-11 17:02:48 +08:00
|
|
|
scale = 1.0 / math.sqrt(d)
|
|
|
|
|
qg = q.view(1, kv, g, d)
|
|
|
|
|
|
|
|
|
|
if self.cache_layout == "ring":
|
2026-09-11 17:41:29 +08:00
|
|
|
# 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)
|
2026-09-11 17:02:48 +08:00
|
|
|
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
|
|
|
|
|
|
2026-09-11 14:49:10 +08:00
|
|
|
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)
|
2026-09-11 17:02:48 +08:00
|
|
|
x = x + self.down_proj(_gelu(self.gate_proj(h), cfg.gelu_mode) * self.up_proj(h))
|
|
|
|
|
return x, k_out, v_out
|
2026-09-11 14:49:10 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class SparkDecodeStep(nn.Module):
|
2026-09-11 17:02:48 +08:00
|
|
|
"""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.
|
|
|
|
|
"""
|
2026-09-11 14:49:10 +08:00
|
|
|
|
|
|
|
|
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,
|
2026-09-11 17:41:29 +08:00
|
|
|
cache_layout: str = "auto",
|
2026-09-11 17:02:48 +08:00
|
|
|
gelu_mode: str = "exact",
|
2026-09-11 14:49:10 +08:00
|
|
|
) -> 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,
|
2026-09-11 17:02:48 +08:00
|
|
|
cache_layout=cache_layout,
|
|
|
|
|
gelu_mode=gelu_mode,
|
2026-09-11 14:49:10 +08:00
|
|
|
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)
|
2026-09-11 17:41:29 +08:00
|
|
|
# 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)
|
2026-09-11 14:49:10 +08:00
|
|
|
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")
|
2026-09-11 17:41:29 +08:00
|
|
|
ap.add_argument("--cache-layout", default="auto", choices=["auto", "ring", "roll"])
|
2026-09-11 17:02:48 +08:00
|
|
|
ap.add_argument("--gelu", default="exact", choices=["exact", "tanh", "sigmoid"])
|
2026-09-11 14:49:10 +08:00
|
|
|
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)
|
|
|
|
|
|
2026-09-11 17:02:48 +08:00
|
|
|
cfg = config_from_spark(hf, sl, include_lm_head=not a.no_lm_head,
|
|
|
|
|
cache_layout=a.cache_layout, gelu_mode=a.gelu)
|
2026-09-11 14:49:10 +08:00
|
|
|
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())
|