Files
vllm-omni/tests/edge/test_qnn_export.py
T
tzhouamandClaude Fable 5.1 4e3e8a1ebc [edge-infer] WIP snapshot: uncommitted M0/W1/W4/W5 edge work as of 2026-09-18
Verbatim copy of the edge/omni-edge working tree (tracked diffs + untracked
edge/local, tests/edge/local, docs/edge, pipeline files). Taken so that
vLLM-Omni v0.29.0rc1 can be merged on a branch without touching the
user's working tree. logs_edge/ and benchmark .npy outputs excluded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 09:47:29 +08:00

165 lines
7.2 KiB
Python

"""WP4/mobile: the decode-step graph exported for phone accelerators (QNN / TFLite / ORT).
Checks the maths of the grouped-query attention layout against a straightforward reference, the KV-cache contract,
and that the exported ONNX is free of the three constructs the Qualcomm toolchains reject.
"""
from __future__ import annotations
import importlib.util
import math
import pytest
import torch
from vllm_omni.edge import qnn_export as qx
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
CFG = qx.DecodeStepConfig(
hidden_size=32,
num_hidden_layers=2,
num_attention_heads=4,
num_key_value_heads=2,
head_dim=8,
intermediate_size=48,
vocab_size=16,
rope_theta=10000.0,
)
L = 6
def _module(seed: int = 0) -> qx.DecodeStep:
torch.manual_seed(seed)
module = qx.DecodeStep(CFG).eval()
for p in module.parameters():
torch.nn.init.normal_(p, std=0.15)
return module
def test_config_rejects_head_counts_that_do_not_group():
with pytest.raises(ValueError):
qx.DecodeStepConfig(hidden_size=32, num_hidden_layers=1, num_attention_heads=6, num_key_value_heads=4, head_dim=8, intermediate_size=48, vocab_size=16)
def test_names_and_example_input_shapes_line_up():
names, outs = qx.input_names(CFG), qx.output_names(CFG)
args = qx.example_inputs(CFG, L)
assert len(names) == len(args) == 4 + 2 * CFG.num_hidden_layers
assert names[:4] == ["x", "cos", "sin", "mask"] and names[4:6] == ["k_cache_0", "v_cache_0"]
assert outs[0] == "logits" and outs[1:3] == ["k_new_0", "v_new_0"]
x, cos, sin, mask, *caches = args
assert x.shape == (1, 1, CFG.hidden_size) and cos.shape == sin.shape == (1, 1, 1, CFG.head_dim)
assert mask.shape == (1, 1, 1, L + 1)
assert all(c.shape == (1, CFG.num_key_value_heads, L, CFG.head_dim) for c in caches)
def test_output_contract():
module = _module()
out = module(*qx.example_inputs(CFG, L))
assert len(out) == 1 + 2 * CFG.num_hidden_layers
assert out[0].shape == (1, CFG.vocab_size)
assert all(t.shape == (1, CFG.num_key_value_heads, 1, CFG.head_dim) for t in out[1:])
def test_wrong_number_of_caches_is_rejected():
module = _module()
x, cos, sin, mask, *caches = qx.example_inputs(CFG, L)
with pytest.raises(ValueError):
module(x, cos, sin, mask, *caches[:-1])
def test_grouped_query_layout_matches_expanded_reference():
"""The exported layout reshapes q; the reference expands k/v. They must agree."""
module = _module(1)
x, cos, sin, mask, *caches = qx.example_inputs(CFG, L)
layer = module.layers[0]
got, k_new, v_new = layer(x, cos, sin, caches[0], caches[1], mask)
d, kv, g = CFG.head_dim, CFG.num_key_value_heads, CFG.group_size
h = qx._rms(x, layer.input_layernorm, CFG.rms_norm_eps)
q = qx._rope(qx._rms(layer.q_proj(h).view(1, CFG.num_attention_heads, 1, d), layer.q_norm, CFG.rms_norm_eps), cos, sin)
k = qx._rope(qx._rms(layer.k_proj(h).view(1, kv, 1, d), layer.k_norm, CFG.rms_norm_eps), cos, sin)
v = layer.v_proj(h).view(1, kv, 1, d)
keys = torch.cat([caches[0], k], 2).repeat_interleave(g, dim=1)
vals = torch.cat([caches[1], v], 2).repeat_interleave(g, dim=1)
scores = torch.matmul(q, keys.transpose(2, 3)) / math.sqrt(d) + mask
attn = torch.matmul(torch.softmax(scores, -1), vals).transpose(1, 2).reshape(1, 1, CFG.num_attention_heads * d)
expected = x + layer.o_proj(attn)
hh = qx._rms(expected, layer.post_attention_layernorm, CFG.rms_norm_eps)
expected = expected + layer.down_proj(torch.nn.functional.silu(layer.gate_proj(hh)) * layer.up_proj(hh))
assert torch.allclose(got, expected, atol=1e-5)
assert torch.allclose(k_new, k) and torch.allclose(v_new, v)
def test_masked_cache_slots_do_not_change_the_result():
"""Slots the mask disables may hold anything: that is what lets the host keep one fixed-size cache."""
module = _module(2)
args = list(qx.example_inputs(CFG, L, position=2))
first = module(*args)[0]
for i in range(4, len(args)):
cache = args[i].clone()
cache[:, :, 2:L] = 7.0 # masked region
args[i] = cache
assert torch.allclose(first, module(*args)[0], atol=1e-5)
def test_llama_style_config_skips_qk_norm():
"""MiniCPM-o's TTS head is a Llama decoder: no per-head q/k RMSNorm, so those params must not be applied."""
cfg = qx.DecodeStepConfig(**{**{f.name: getattr(CFG, f.name) for f in CFG.__dataclass_fields__.values()}, "qk_norm": False})
module = qx.DecodeStep(cfg).eval()
for p in module.parameters():
torch.nn.init.normal_(p, std=0.15)
args = qx.example_inputs(cfg, L)
before = module(*args)[0].clone()
with torch.no_grad(): # q_norm/k_norm must be dead weights when qk_norm is off
module.layers[0].q_norm.mul_(3.0)
module.layers[0].k_norm.mul_(0.1)
assert torch.equal(before, module(*args)[0])
def test_minicpmo_config_maps_the_tts_head():
cfg = qx.config_from_minicpmo({"tts_config": {"num_hidden_layers": 20, "hidden_size": 768, "num_attention_heads": 12,
"num_key_value_heads": 12, "intermediate_size": 3072, "num_audio_tokens": 6562}})
assert cfg.qk_norm is False and cfg.head_dim == 64 and cfg.vocab_size == 6562 and cfg.group_size == 1
def test_placement_table_prefers_npu_for_the_step_and_gpu_for_the_vocoder():
p = qx.PLACEMENT_MS_PER_FRAME
assert p["talker_step"]["npu"] < p["talker_step"]["gpu"]
assert p["code2wav_chunk"]["gpu"] < p["code2wav_chunk"]["npu"]
@pytest.mark.skipif(importlib.util.find_spec("onnx") is None, reason="onnx not installed")
def test_export_onnx_is_clean_for_the_qualcomm_toolchains(tmp_path):
import onnx
module = _module(3)
manifest = qx.export_onnx(module, L, tmp_path / "step.onnx")
assert manifest["cache_len"] == L and manifest["config"]["hidden_size"] == CFG.hidden_size
model = onnx.load(str(tmp_path / "step.onnx"))
onnx.checker.check_model(model, full_check=True)
assert [i.name for i in model.graph.input] == qx.input_names(CFG)
assert [o.name for o in model.graph.output] == qx.output_names(CFG)
assert not [n for n in model.graph.node if n.op_type == "IsNaN"]
assert not [n for n in model.graph.node for a in n.attribute if n.op_type == "Reshape" and a.name == "allowzero" and a.i]
inits = {i.name: i for i in model.graph.initializer}
assert not [n for n in model.graph.node if n.op_type in ("Unsqueeze", "Squeeze") and len(n.input) > 1 and n.input[1] in inits and not inits[n.input[1]].dims]
@pytest.mark.skipif(importlib.util.find_spec("onnxruntime") is None or importlib.util.find_spec("onnx") is None, reason="onnx/onnxruntime not installed")
def test_exported_graph_reproduces_eager(tmp_path):
import onnxruntime as ort
module = _module(4)
args = qx.example_inputs(CFG, L)
with torch.inference_mode():
expected = module(*args)
qx.export_onnx(module, L, tmp_path / "step.onnx")
sess = ort.InferenceSession(str(tmp_path / "step.onnx"), providers=["CPUExecutionProvider"])
feed = {name: arg.numpy() for name, arg in zip(qx.input_names(CFG), args)}
got = sess.run(None, feed)
for name, want, have in zip(qx.output_names(CFG), expected, got):
assert torch.allclose(want, torch.from_numpy(have), atol=1e-4), name