Files
vllm-omni/tests/edge/test_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

248 lines
8.5 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for the Spark-X2.5 decode-step exporter.
The point of the export is the hybrid cache layout, so that is what these
check: sliding layers stay at a fixed 512 entries however long the context
gets, full layers grow, and the window actually rolls.
"""
import pytest
import torch
from vllm_omni.edge.spark_export import (
FULL,
SLIDING,
SparkDecodeStep,
SparkStepConfig,
config_from_spark,
example_inputs,
input_names,
kv_cache_bytes,
output_names,
)
HF_CONFIG = {
"hidden_size": 64,
"num_attention_heads": 4,
"num_key_value_heads": 2,
"head_dim": 16,
"intermediate_size": 128,
"vocab_size": 256,
"sliding_window": 8,
"rms_norm_eps": 1e-6,
"layer_types": [SLIDING, SLIDING, SLIDING, FULL],
"rope_parameters": {
"full_attention": {"rope_theta": 5000000, "partial_rotary_factor": 0.25},
"sliding_attention": {"rope_theta": 10000, "partial_rotary_factor": 1.0},
},
}
def _cfg(**kw) -> SparkStepConfig:
return config_from_spark(HF_CONFIG, **kw)
@pytest.mark.core_model
@pytest.mark.cpu
def test_sliding_cache_is_capped_but_full_cache_grows():
cfg = _cfg(cache_layout="roll")
assert cfg.cache_len(SLIDING, 4) == 4 # shorter than the window
assert cfg.cache_len(SLIDING, 1024) == 8 # capped at sliding_window
assert cfg.cache_len(SLIDING, 1_000_000) == 8
assert cfg.cache_len(FULL, 1024) == 1024
@pytest.mark.core_model
@pytest.mark.cpu
def test_rotary_dim_differs_by_layer_type():
cfg = _cfg()
assert cfg.rotary_dim(SLIDING) == 16 # all head dims
assert cfg.rotary_dim(FULL) == 4 # first quarter only
@pytest.mark.core_model
@pytest.mark.cpu
def test_kv_bytes_beats_uniform_attention_and_the_gap_grows():
cfg = _cfg()
near = kv_cache_bytes(cfg, 16)
far = kv_cache_bytes(cfg, 4096)
assert near["hybrid"] < near["uniform"]
assert far["hybrid"] < far["uniform"]
# The saving is a growing fraction, not a constant one.
assert far["saved"] / far["uniform"] > near["saved"] / near["uniform"]
@pytest.mark.core_model
@pytest.mark.cpu
def test_example_inputs_match_the_declared_signature():
cfg = _cfg()
args = example_inputs(cfg, context=1024)
names = input_names(cfg)
assert len(args) == len(names)
caches = args[7:]
assert len(caches) == 2 * cfg.num_layers
for i, layer_type in enumerate(cfg.layer_types):
expected = cfg.cache_len(layer_type, 1024)
assert caches[2 * i].shape == (1, cfg.num_key_value_heads, expected, cfg.head_dim)
assert len(output_names(cfg)) == 1 + 2 * cfg.num_layers
@pytest.mark.core_model
@pytest.mark.cpu
@pytest.mark.parametrize("layout", ["ring", "roll"])
def test_every_layer_returns_what_the_runtime_must_write(layout):
cfg = _cfg(cache_layout=layout)
step = SparkDecodeStep(cfg).eval()
context = 1024
with torch.no_grad():
out = step(*example_inputs(cfg, context))
logits, caches = out[0], out[1:]
assert logits.shape == (1, cfg.vocab_size)
for i, layer_type in enumerate(cfg.layer_types):
k_new = caches[2 * i]
if layout == "ring":
# One entry per layer, dropped into the ring slot for this
# position -- nothing cache-sized crosses the graph boundary.
assert k_new.shape[2] == 1
elif layer_type == SLIDING:
assert k_new.shape[2] == cfg.cache_len(SLIDING, context)
else:
assert k_new.shape[2] == 1
@pytest.mark.core_model
@pytest.mark.cpu
def test_ring_and_roll_agree_on_the_same_history():
"""Both layouts must attend over the same window of tokens.
Roll keeps `sliding_window` entries and drops the oldest after appending;
ring keeps one fewer and appends in score space. Given a shared history
they have to produce identical output, or the device numbers measure a
different model.
"""
torch.manual_seed(0)
ring = _cfg(cache_layout="ring", layer_slice=slice(0, 1))
roll = _cfg(cache_layout="roll", layer_slice=slice(0, 1))
step_ring = SparkDecodeStep(ring).eval()
step_roll = SparkDecodeStep(roll).eval()
step_roll.load_state_dict(step_ring.state_dict())
context = 64
args_roll = list(example_inputs(roll, context))
k_hist, v_hist = args_roll[7], args_roll[8]
# Ring sees the same history minus the entry roll is about to discard.
args_ring = list(args_roll)
args_ring[5] = torch.zeros(1, 1, 1, ring.cache_len(SLIDING, context) + 1)
args_ring[7] = k_hist[:, :, 1:]
args_ring[8] = v_hist[:, :, 1:]
with torch.no_grad():
out_ring = step_ring(*args_ring)
out_roll = step_roll(*args_roll)
assert torch.allclose(out_ring[0], out_roll[0], atol=2e-4, rtol=2e-4)
@pytest.mark.core_model
@pytest.mark.cpu
def test_roll_layout_drops_the_oldest_entry():
"""Roll's rolled window is the old cache shifted by one with the new key
appended -- what makes a masked pad slot safe to prepend."""
cfg = _cfg(cache_layout="roll", layer_slice=slice(0, 1))
step = SparkDecodeStep(cfg).eval()
args = list(example_inputs(cfg, context=1024))
k_cache = args[7]
with torch.no_grad():
out = step(*args)
k_new = out[1]
assert torch.allclose(k_new[:, :, :-1], k_cache[:, :, 1:])
@pytest.mark.core_model
@pytest.mark.cpu
def test_ring_sliding_cache_holds_one_less_than_the_window():
ring, roll = _cfg(cache_layout="ring"), _cfg(cache_layout="roll")
assert ring.cache_len(SLIDING, 1024) == HF_CONFIG["sliding_window"] - 1
assert roll.cache_len(SLIDING, 1024) == HF_CONFIG["sliding_window"]
# Full-attention layers are unaffected by the layout.
assert ring.cache_len(FULL, 1024) == roll.cache_len(FULL, 1024) == 1024
@pytest.mark.core_model
@pytest.mark.cpu
def test_layer_slice_selects_one_layer_type():
types = HF_CONFIG["layer_types"]
sliding = _cfg(layer_slice=slice(0, 1))
assert sliding.layer_types == (SLIDING,)
full = _cfg(layer_slice=slice(types.index(FULL), types.index(FULL) + 1))
assert full.layer_types == (FULL,)
assert full.first_layer == types.index(FULL)
@pytest.mark.core_model
@pytest.mark.cpu
def test_unknown_layer_type_is_rejected():
bad = dict(HF_CONFIG, layer_types=[SLIDING, "linear_attention", SLIDING, FULL])
with pytest.raises(ValueError, match="unknown layer types"):
config_from_spark(bad)
@pytest.mark.core_model
@pytest.mark.cpu
def test_gelu_modes_stay_close_to_the_exact_form():
"""The approximations are offered because erf GELU is ~12% of a layer on
device, but they are only adoptable if they track the trained activation."""
x = torch.linspace(-6, 6, 4096)
exact = torch.nn.functional.gelu(x)
from vllm_omni.edge.spark_export import _gelu
tanh_err = (_gelu(x, "tanh") - exact).abs().max().item()
sigmoid_err = (_gelu(x, "sigmoid") - exact).abs().max().item()
assert torch.equal(_gelu(x, "exact"), exact)
assert tanh_err < 2e-3
# The sigmoid form is markedly looser; it is kept for measurement only.
assert 1e-2 < sigmoid_err < 3e-2
assert tanh_err < sigmoid_err
@pytest.mark.core_model
@pytest.mark.cpu
def test_unknown_gelu_mode_is_rejected():
with pytest.raises(ValueError, match="unknown gelu_mode"):
_cfg(gelu_mode="relu")
@pytest.mark.core_model
@pytest.mark.cpu
def test_unknown_cache_layout_is_rejected():
with pytest.raises(ValueError, match="unknown cache_layout"):
_cfg(cache_layout="linked_list")
@pytest.mark.core_model
@pytest.mark.cpu
def test_auto_layout_picks_ring_for_sliding_and_roll_for_full():
"""Ring pays off only on sliding layers, and only there does it compile at
w4a16, so the default mixes them."""
cfg = _cfg() # default cache_layout="auto"
assert cfg.layout_for(SLIDING) == "ring"
assert cfg.layout_for(FULL) == "roll"
assert cfg.cache_len(SLIDING, 1024) == HF_CONFIG["sliding_window"] - 1
forced = _cfg(cache_layout="roll")
assert forced.layout_for(SLIDING) == forced.layout_for(FULL) == "roll"
@pytest.mark.core_model
@pytest.mark.cpu
def test_auto_layout_returns_one_entry_for_sliding_and_a_window_for_full():
cfg = _cfg()
step = SparkDecodeStep(cfg).eval()
with torch.no_grad():
out = step(*example_inputs(cfg, 1024))
caches = out[1:]
for i, layer_type in enumerate(cfg.layer_types):
# Both layouts hand back exactly one new entry here: ring by design,
# roll because a full layer only ever appends.
assert caches[2 * i].shape[2] == 1, layer_type