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
248 lines
8.5 KiB
Python
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
|