Include the baselines, comparisons and study documents, not just the fork
The packaging commit shipped the code and a summary of the numbers. It left out
everything the numbers are measured *against*, which is most of what makes them
checkable.
Added:
baselines/ llama.cpp runs (thread sweeps, NUMA sweeps, the
interleaved A/B passes behind the headline decode
figure) and the quality references: each build
against its own bf16 over 12 greedy prompts.
benchmarks/.../results/ 102 artifacts backing the published figures, each
with a provenance block naming the model,
quantization, thread set and build SHAs.
docs/engines/ the engine analyses -- llama.cpp, ExecuTorch, MNN,
ncnn, vLLM, vLLM-Omni -- and the cross-engine and
device-simulation comparisons.
docs/analysis/ the full study documents the summaries distil.
docs/edge/comparisons.md where this fork sits against llama.cpp and why:
decode is llama.cpp's, prefill is ours by ~2x, and
since neither engine exceeds 39% of the measured
222 GB/s ceiling the decode gap is the execution
model rather than the kernels.
docs/edge/environment.md pins and machine, and the correction below.
On "the modified vllm": there isn't one, and the doc says so plainly because it
is the obvious assumption. The vLLM checkout is clean upstream (0 commits
ahead, 0 uncommitted) and the installed wheel is stock 0.28.0+cpu. This fork
changes vLLM's CPU *behaviour* through vLLM's own extension points at import
time -- three CPU patches in vllm_omni/patch.py, the quantization-config
registry, the IR op registry, platform hooks -- specifically so the changes
survive a wheel upgrade rather than being lost on it. One of those patches is
why "KV sizing is not a memory lever" looked true for months: CpuPlatform
overwrote kv_cache_memory_bytes unconditionally, so the setting had no effect.
There is no patch series to apply; pin the version and let patch.py do the rest.
.gitignore gains explicit exceptions: upstream's blanket `*.json` and `results/`
rules were silently eating the measurement artifacts, which in this fork are the
substance rather than build output. Binary captures stay ignored.
No vLLM or llama.cpp source is redistributed -- only output produced with them.
NOTICE records both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L1ax5wYLA6XbqEL1i8L9gZ
This commit is contained in:
1 parent
b36a3431bd
commit
9264ff81ab
162 files changed
+22640
-9
No files matched your search
@@ -0,0 +1,994 @@
|
||||
# Spark-X2.5-1.7B on CPU and Galaxy S25
|
||||
|
||||
Spark-X2.5 (XHToken/iFlytek, Apache 2.0, released 2026-09-01) is a 1.7 B
|
||||
on-device model built for agentic tool use: BFCL-V4 46.9, tau-squared-bench
|
||||
65.3, a native 1 M-token context and 200+ languages. This note covers adding
|
||||
it to vLLM-Omni, measuring it against every engine that can run it, putting it
|
||||
on a real Snapdragon 8 Elite, and driving an Android phone with it.
|
||||
|
||||
Reproduction scripts: `analysis/experiments/spark_edge/`.
|
||||
Model code: `vllm_omni/model_executor/models/spark2_5/`, exporter
|
||||
`vllm_omni/edge/spark_export.py`.
|
||||
|
||||
## 1. What had to be built
|
||||
|
||||
Three traits separate Spark from a Llama-style decoder, and all three are why
|
||||
no generic converter picks it up:
|
||||
|
||||
| trait | detail | why it matters |
|
||||
|---|---|---|
|
||||
| hybrid attention | `layer_types` = 3x `sliding_attention` (window 512) then 1x `full_attention`, 28 layers -> 21 sliding + 7 full | bounds KV growth; this is what makes 1 M context affordable on a phone |
|
||||
| per-layer-type RoPE | sliding: all 256 head dims, theta 1e4. full: first 64 dims only (`partial_rotary_factor` 0.25), theta 5e6 | two rotary tables, not one |
|
||||
| head-wise output gate | `g_proj` [2048 -> 8] reads the same post-norm input as QKV; `sigmoid` scales each head's attention output before `out_proj` | an extra projection with no analogue in Llama/Qwen |
|
||||
|
||||
Also: QKV ships pre-fused as one `q_k_v_proj`, the MLP is GEGLU with *exact*
|
||||
gelu, embeddings are tied, and `head_dim` is 256 with only 2 KV heads.
|
||||
|
||||
### Engine support before this work
|
||||
|
||||
| engine | Spark-X2.5 support |
|
||||
|---|---|
|
||||
| llama.cpp | yes, native (`src/models/spark2-5.cpp`) |
|
||||
| SGLang | yes, native (`srt/models/spark2_5.py`) |
|
||||
| vLLM 0.28 | **no** -- added here |
|
||||
| MNN | no. `llmexport.py` dispatches on a fixed `model_type` list; `spark2_5` is absent |
|
||||
| ExecuTorch | no |
|
||||
| ncnn | no |
|
||||
|
||||
MNN, ExecuTorch and ncnn would each need the architecture written from
|
||||
scratch. That is the honest reason they do not appear in the benchmark tables
|
||||
below: they cannot load the model at all, not that they are slow at it.
|
||||
|
||||
### Correctness
|
||||
|
||||
Verified against the official remote-code implementation pinned to
|
||||
transformers 4.57.1 (the version `config.json` targets; it does not import on
|
||||
the 5.x in our venvs, so a dedicated venv runs ground truth).
|
||||
|
||||
| scenario | prompt | fp32 | bf16 |
|
||||
|---|---|---|---|
|
||||
| short | 28 tok | 32/32 greedy, max logprob diff 2.2e-05 | 24/24 greedy |
|
||||
| long (exercises sliding window) | 2465 tok | -- | 32/32 greedy |
|
||||
| tool calling | 137 tok | 32/32 greedy | diverges at token 23 |
|
||||
|
||||
The bf16 tool-calling divergence is a near-tie under greedy decoding, not a
|
||||
defect: in fp32 the same prompt matches exactly with a 2.2e-05 logprob
|
||||
difference.
|
||||
|
||||
One vLLM bug had to be worked around. The head-wise gate is a [2048, 8]
|
||||
projection, and vLLM's CPU backend routes any narrow linear to the sgl-kernel
|
||||
packed GEMM, whose fake-tensor rule assumes the packed weight keeps N in dim
|
||||
0. VNNI packing does not for N that small, so it reports K instead of N and
|
||||
`torch.compile` dies tracing the gate multiply. The gate is kept as a plain
|
||||
parameter with `F.linear` -- a 2048x8 matmul gains nothing from the packed AMX
|
||||
kernel anyway.
|
||||
|
||||
## 2. CPU: vLLM-Omni vs llama.cpp
|
||||
|
||||
Xeon Platinum 8480C (Sapphire Rapids, AMX-BF16/INT8). Both engines pinned with
|
||||
`taskset` to the same physical cores and run **strictly one at a time**;
|
||||
prefix caching disabled on vLLM so a replayed prompt is really recomputed
|
||||
(llama-bench has no such cache). pp512 = prefill throughput, tg128 = decode.
|
||||
|
||||
### Same precision (bf16)
|
||||
|
||||
| threads | vLLM pp512 | llama.cpp pp512 | prefill | vLLM tg128 | llama.cpp tg128 | decode |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 4 | **1113.4** | 72.4 | 15.4x | **11.11** | 4.20 | 2.65x |
|
||||
| 8 | **2113.7** | 173.8 | 12.2x | **19.88** | 16.15 | 1.23x |
|
||||
| 16 | **3639.4** | 341.7 | 10.7x | **30.16** | 25.31 | 1.19x |
|
||||
| 32 | **4454.9** | 509.5 | 8.7x | **36.35** | 28.57 | 1.27x |
|
||||
|
||||
vLLM wins both, at every thread count. The prefill gap is AMX: llama.cpp's
|
||||
bf16 path runs at AVX-512 speed, vLLM's goes through oneDNN/AMX tiles.
|
||||
|
||||
### Against llama.cpp's quantized builds
|
||||
|
||||
| threads | vLLM bf16 pp | llama.cpp Q8_0 pp | llama.cpp Q4_K_M pp | vLLM bf16 tg | llama.cpp Q8_0 tg | llama.cpp Q4_K_M tg |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 4 | **1113.4** | 118.1 | 103.6 | 11.11 | 9.85 | **10.36** |
|
||||
| 8 | **2113.7** | 490.2 | 434.2 | 19.88 | 45.01 | **42.40** |
|
||||
| 16 | **3639.4** | 914.7 | 806.3 | 30.16 | 66.23 | **71.50** |
|
||||
| 32 | **4454.9** | 1493.1 | 1199.0 | 36.35 | 76.54 | **82.81** |
|
||||
|
||||
Prefill: vLLM at bf16 still beats llama.cpp's *best quantized* build by 3.0x
|
||||
at 32 threads. Prefill is compute-bound, and AMX beats 4-bit weight savings.
|
||||
|
||||
Decode: llama.cpp Q4_K_M wins from 8 threads up, by 2.3x at 32. Decode is
|
||||
memory-bound -- 4-bit weights move a quarter of the bytes -- and this is a
|
||||
precision difference, not an engine difference. Closing it needs a quantized
|
||||
vLLM CPU path for Spark, which is the open item in section 6.
|
||||
|
||||
### Quantized: int8 on CPU
|
||||
|
||||
vLLM 0.28's CPU build has int8 dense kernels (`int8_scaled_mm_with_quant`,
|
||||
`dynamic_scaled_int8_quant`), and `static_scaled_fp8_quant` is registered with
|
||||
an empty schema, so on-the-fly fp8 fails outright.
|
||||
|
||||
An earlier draft of this note said the backend had no dense int4 kernel
|
||||
either, on the strength of `woq_int4_linear`, `int4_scaled_mm_with_quant` and
|
||||
`da8w4_linear` all being absent. That was wrong -- they are simply not the
|
||||
names it uses. See section 2c: there are two four-bit paths, and the one
|
||||
vLLM ships is `int4_scaled_mm_cpu`, an AMX W4A8 GEMM reached through the
|
||||
GPTQ/AWQ kernel selector.
|
||||
|
||||
Int8 is also available. `analysis/experiments/spark_edge/quantize_int8.py` writes a
|
||||
compressed-tensors W8A8 checkpoint straight from the safetensors (symmetric
|
||||
per-output-channel round-to-nearest; no calibration needed, and llmcompressor
|
||||
is avoided because it pulls transformers 5.x, on which Spark's remote code
|
||||
does not import). The head-wise gate is left in full precision: its eight
|
||||
outputs each scale a whole attention head, and it is 0.001% of the weights.
|
||||
Weight SNR 36.9-40.9 dB across 140 tensors.
|
||||
|
||||
| threads | pp512 bf16 -> int8 | tg128 bf16 -> int8 |
|
||||
|---|---|---|
|
||||
| 16 | 3639 -> **5135** (1.41x) | 30.16 -> **48.30** (1.60x) |
|
||||
| 32 | 4455 -> **6978** (1.57x) | 36.35 -> **58.44** (1.61x) |
|
||||
|
||||
Quality against the fp32 reference, though, is not free:
|
||||
|
||||
| scenario | greedy match | first-token top-1 | top-20 overlap | max logprob diff |
|
||||
|---|---|---|---|---|
|
||||
| short (28 tok) | 23/24 | agrees | 18/20 | 0.83 |
|
||||
| long (2465 tok) | **12/32** | agrees | 19/20 | 1.56 |
|
||||
| tool calling | 31/32 | agrees | 18/20 | 0.58 |
|
||||
|
||||
bf16 matched 32/32 on all three with a max difference of 0.24. Int8 keeps the
|
||||
first token everywhere, but at long context the distribution has moved enough
|
||||
that greedy output diverges after a dozen tokens. Usable, not free -- the same
|
||||
caution the Qwen3-TTS and MiniCPM-o work in this repo reached about automatic
|
||||
int8.
|
||||
|
||||
## 2b. CPU under concurrency
|
||||
|
||||
Single-stream decode is memory-bound, which is a precision contest llama.cpp
|
||||
wins with 4-bit weights. Concurrent decode is compute-bound, which is an AMX
|
||||
and continuous-batching contest. Total generated tokens/s, 32 pinned cores,
|
||||
128-token prompts generating 128 tokens each:
|
||||
|
||||
| batch | vLLM bf16 | vLLM int8 | llama.cpp Q4_K_M | int8 vs llama.cpp |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 34.6 | 54.9 | **91.1** | 0.60x |
|
||||
| 2 | 69.9 | 105.2 | **129.6** | 0.81x |
|
||||
| 4 | 139.8 | 210.3 | **251.6** | 0.84x |
|
||||
| 8 | 266.1 | 387.8 | **406.5** | 0.95x |
|
||||
| 16 | 495.7 | **694.7** | 613.9 | 1.13x |
|
||||
| 32 | -- | **1080.6** | 778.3 | 1.39x |
|
||||
| 64 | -- | **1557.9** | 904.5 | 1.72x |
|
||||
|
||||
The crossover is around batch 12. Below it llama.cpp's 4-bit weights win;
|
||||
above it vLLM's AMX-INT8 GEMMs and continuous batching pull away, reaching
|
||||
1.72x at batch 64 and still climbing. A phone agent is a batch-of-one
|
||||
workload, so llama.cpp is the right single-device choice today; an edge
|
||||
gateway serving a dozen sessions is vLLM's.
|
||||
|
||||
## 2c. Four bits on CPU: two paths, and which one a device should take
|
||||
|
||||
vLLM 0.28's CPU build has a 4-bit GEMM. `CPUWNA16LinearKernel`
|
||||
(`vllm/model_executor/kernels/linear/mixed_precision/cpu.py`) routes GPTQ and
|
||||
AWQ checkpoints to **`int4_scaled_mm_cpu`**, an AMX W4A8 kernel, whenever the
|
||||
activations are bf16, the ISA has AMX tiles and the checkpoint carries no
|
||||
activation reordering. Four-bit weights times eight-bit activations is exactly
|
||||
what llama.cpp's Q4_K_M does (Q4_K weights, Q8_K activations), so this is the
|
||||
honest precision match rather than a handicap.
|
||||
|
||||
Two paths were built, because they are not the same trade:
|
||||
|
||||
| | weights | activations | kernel | bits/weight | reached by |
|
||||
|---|---|---|---|---|---|
|
||||
| **W4A8** | 4-bit symmetric, group-wise | int8 (per the kernel) | `_C::int4_scaled_mm_cpu` (AMX) | 4.16 @ g128 | GPTQ checkpoint, vLLM's own dispatch |
|
||||
| **W4A16** | 4-bit asymmetric min/max, group-wise | bf16 | `aten::_weight_int4pack_mm_for_cpu` | 4.16 @ g128 | `cpu_int4`, registered out-of-tree |
|
||||
|
||||
W4A8 is forced symmetric: `_process_gptq_weights_w4a8` builds its own zero
|
||||
point (a constant 8) and ignores the checkpoint's `qzeros`, so the grid must be
|
||||
`w = (code - 8) * scale`. W4A16 is free to use an asymmetric min/max grid, and
|
||||
a per-group scale search recovers roughly a dB in either case.
|
||||
|
||||
### Which is faster
|
||||
|
||||
Per-op profiles, taken back to back on the same 24 cores. Throughput on this
|
||||
host swings ~30% run to run and the gap here is ~8%, so the GEMM self time is
|
||||
the measurement that can actually rank them:
|
||||
|
||||
| path | GEMM/step | step | tok/s |
|
||||
|---|---|---|---|
|
||||
| W4A8 g128 | **5.91 ms** | **11.55 ms** | **86.6** |
|
||||
| W4A16 g128 | 6.85 ms | 12.51 ms | 80.0 |
|
||||
| llama.cpp Q4_K_M | -- | 11.11 ms | 90.0 |
|
||||
| vLLM bf16 | -- | -- | 41.0 |
|
||||
|
||||
W4A8 wins twice: the kernel is 0.94 ms cheaper per step, and because it is
|
||||
reached through vLLM's own dispatch it also avoids the 0.48 ms/step custom-op
|
||||
boundary the tinygemm path needs (that boundary exists because tinygemm's cost
|
||||
is linear in batch size, so prefill has to be routed to a dequantize-then-AMX
|
||||
path instead -- see below).
|
||||
|
||||
**The trap that cost a day: a GPTQ checkpoint must quantize the output head.**
|
||||
Spark ties its embedding to its head, so a converter that leaves the tie in
|
||||
place emits no `lm_head` tensors and that one GEMM runs bf16 through
|
||||
`_C::onednn_mm` -- **2.97 ms per step**, more than the entire 4-bit saving. The
|
||||
profile is what caught it: `int4_scaled_mm_cpu` ran 112 times (28 layers x 4
|
||||
linears) and `onednn_mm` once. Every "W4A8" number measured before that was on
|
||||
a handicapped build, which is why the W4A8/W4A16 ordering appeared to flip
|
||||
between grids.
|
||||
|
||||
### Which is more faithful
|
||||
|
||||
Greedy divergence from each build's own bf16 output, 12 prompts, measured the
|
||||
same way on both engines:
|
||||
|
||||
| build | exact match | mean prefix kept |
|
||||
|---|---|---|
|
||||
| llama.cpp Q4_K_M | **4/12** | **0.51** |
|
||||
| vLLM W4A16 | 2/12 | 0.30 |
|
||||
| vLLM W4A8 | 1/12 | 0.13 |
|
||||
|
||||
Q4_K_M's 6-bit sub-block scales buy real granularity at the same bit budget;
|
||||
round-to-nearest with a per-group search does not match it. So the two paths
|
||||
are a genuine choice, not a ranking: **W4A8 for speed, W4A16 for fidelity**,
|
||||
and `vllm_omni/edge/weight_path.py` makes that choice from a hardware probe
|
||||
rather than a flag, because the fast kernel on each device needs a different
|
||||
and mutually exclusive weight layout:
|
||||
|
||||
| device class | path | why |
|
||||
|---|---|---|
|
||||
| x86 + AMX | W4A8 / GPTQ | `int4_scaled_mm_cpu` applies |
|
||||
| x86 without AMX | W4A16 / tinygemm | no AMX, no int4 kernel |
|
||||
| x86 + AMX, fidelity asked | W4A16 / tinygemm | keeps bf16 activations |
|
||||
| ARM64 | W4A16, **unverified** | PyTorch's 4-bit CPU GEMM dispatches a different kernel there |
|
||||
| phone NPU | exported graph, calibrated w8a16 | vLLM does not run there at all |
|
||||
|
||||
### Prefill needs the other kernel
|
||||
|
||||
The tinygemm kernel dequantizes once per row of activations, so its cost is
|
||||
linear in batch size: it beats AMX bf16 below ~8 rows and loses badly above.
|
||||
Measured at Spark's `gate_up` shape, per call: 0.070 ms at 1 token, 14.2 ms at
|
||||
512. Left alone, pp512 collapsed from 4455 tok/s (bf16) to 557. Unpacking the
|
||||
weight to bf16 once per call and handing it to the ordinary oneDNN/AMX GEMM
|
||||
brings it back to **3347 tok/s**, 3.0x llama.cpp's 1121. W4A8 needs none of
|
||||
this -- the AMX kernel is the right one at every batch size, which is the
|
||||
second reason it is the default on an AMX machine.
|
||||
|
||||
## 2d. Init time and resident memory, which is what decides edge at all
|
||||
|
||||
Speed is not the binding constraint on an edge box; a 90-second start and 8 GB
|
||||
resident is. Both turned out to be fixable, and one of them was a bug.
|
||||
|
||||
### vLLM recompiled the model on every single start
|
||||
|
||||
`_try_load_aot_compiled_fn` finishes a cache load with
|
||||
`loaded_fn._artifacts.compiled_fn.finalize_loading(cfg)`. On CUDA that artifact
|
||||
is a wrapper object carrying the method; with the inductor CPU backend it is a
|
||||
plain function, so the call raises `AttributeError`, the handler treats the
|
||||
whole load as a cache miss ("Compiling model again due to a load failure"), and
|
||||
the model is compiled again from scratch. Every restart paid it.
|
||||
|
||||
Attaching the missing no-op (a plain Python function accepts attribute
|
||||
assignment; the CPU artifact loads its generated `.so` lazily on first call,
|
||||
which is the warm-up run) fixes it. Same cache directory, one variable changed:
|
||||
|
||||
| | init |
|
||||
|---|---|
|
||||
| cold cache (first ever compile) | 93.8 s |
|
||||
| warm cache, vLLM as shipped | 49.8 s |
|
||||
| **warm cache, shim installed** | **14.9 s** |
|
||||
|
||||
**3.3x faster warm start**, reproduced across passes (14.9 / 14.7 / 15.0, and
|
||||
12.8-13.0 on the W4A16 path). The shim is self-disabling: if the artifact
|
||||
already has `finalize_loading`, as it does on GPU and will if upstream gives
|
||||
the CPU path one, it changes nothing.
|
||||
|
||||
### Measure anonymous memory, not RSS
|
||||
|
||||
A first pass concluded that sizing the KV cache saved 3.1 GB. It does not, and
|
||||
the error is worth recording because it is easy to repeat: that comparison
|
||||
read `VmRSS`, which counts the mmap'd checkpoint's file pages, and those come
|
||||
and go with page-cache pressure. **Identical configurations measured 1.2-2.0 GB
|
||||
apart.** `RssAnon` -- allocations only -- repeats to within **0.5 MB**, and is
|
||||
what a memory budget is actually about.
|
||||
|
||||
### The KV cache is not a memory lever on CPU
|
||||
|
||||
Re-measured properly, with only the budget changing:
|
||||
|
||||
| KV budget | anonymous RSS |
|
||||
|---|---|
|
||||
| 4 GiB (env default) | 7601 MB |
|
||||
| 512 MiB | 7405 MB |
|
||||
| 128 MiB | 7413 MB |
|
||||
|
||||
Shrinking the cache 32x moves resident memory by **188 MB**, and 512 MiB is
|
||||
indistinguishable from 128 MiB. The cache is allocated but its pages are never
|
||||
touched, so it costs address space, not memory.
|
||||
|
||||
It still has to be *correct*, which is a separate problem: `--kv-cache-memory-bytes`
|
||||
was silently discarded on CPU. `CpuPlatform.check_and_update_config` ends with
|
||||
a block its own comment calls "Lagecy setting" that overwrites
|
||||
`cache_config.kv_cache_memory_bytes` from `VLLM_CPU_KVCACHE_SPACE`
|
||||
unconditionally, so the budget could only be set in whole GiB and vllm-omni's
|
||||
per-stage budgets had no effect at all. Verified by allocation rather than by
|
||||
memory, because allocation cannot drift: with the env at 1 GiB, asking for
|
||||
128 MiB produced the same **17 881** cache tokens as asking for nothing, and
|
||||
with the env at 4 GiB it produced **71 527**. After restoring the obvious
|
||||
precedence the same request yields **2 231** tokens. That matters for
|
||||
admission -- vLLM preempts by recompute when the cache cannot hold
|
||||
`max_num_seqs x max_model_len`, which on a single-stream deployment is a stall
|
||||
-- not for footprint. `vllm_omni/edge/kv_budget.py` prices the cache from the
|
||||
config, reporting the flat figure (what is safe to pass, since vLLM's CPU
|
||||
accounting is flat) beside the hybrid one a layer-type-aware backend could use.
|
||||
|
||||
### Where the memory actually goes: the weight load
|
||||
|
||||
Split by phase, on anonymous RSS:
|
||||
|
||||
| phase | W4A8 (GPTQ) | W4A16 (tinygemm) |
|
||||
|---|---|---|
|
||||
| before import | 12 MB | 12 MB |
|
||||
| after `import vllm_omni` | 513 MB | 515 MB |
|
||||
| after engine construction | 7338 MB | 5023 MB |
|
||||
| **after releasing free arenas** | **3510 MB** | **3411 MB** |
|
||||
|
||||
Imports cost half a gigabyte -- they are the init-*time* problem, not the
|
||||
memory one. The engine adds 4.5-6.8 GB for a model whose weights are under
|
||||
1.4 GB, and **most of it is not live**: loading a quantized checkpoint unpacks
|
||||
4-bit codes to int32 -- four bytes per weight -- to permute and repack them,
|
||||
and although the intermediates are freed the allocator keeps the arenas. One
|
||||
release call returns **3828 MB** on the GPTQ path and 1612 MB on tinygemm, a
|
||||
**52%** cut on the configuration we actually want to deploy.
|
||||
|
||||
The second reading of that table matters more than the first: after the
|
||||
release both paths land in the same place. W4A8's apparent 2.3 GB penalty over
|
||||
W4A16 was never a footprint difference, it was the repack. So W4A8 wins on
|
||||
speed *and* ties on memory, and fidelity is the only remaining reason to
|
||||
choose W4A16.
|
||||
|
||||
`vllm_omni/edge/memory.py` does the release and `patch.py` hooks it to the CPU
|
||||
worker's load and warm-up (`VLLM_OMNI_CPU_RELEASE_MEMORY=0` to disable). The
|
||||
~3.5 GB that remains, against 0.86 GB of weights, is the next thing to chase.
|
||||
|
||||
### Two bugs that made CPU deployments take the slow path silently
|
||||
|
||||
Init is not one number per model; it has a different cause in each pipeline,
|
||||
and in two places the slow path was taken without saying so.
|
||||
|
||||
**Parallel stage init could never run on CPU.**
|
||||
`resolve_stage_physical_devices` returns `None` for `devices: "cpu"`, and the
|
||||
admission resolver fails closed on `None` -- right for a GPU replica whose
|
||||
devices cannot be accounted, wrong here -- so every multi-stage CPU pipeline
|
||||
raised `StageAdmissionError` and fell back to sequential startup. The edge
|
||||
profile *sets* `parallel_stage_init: true`, so on CPU that profile was broken
|
||||
rather than merely slower.
|
||||
|
||||
The fix is not to skip admission. The per-device ledger is vacuous on CPU, but
|
||||
the resource concurrent init contends for is host memory, and two stages
|
||||
allocating KV caches at once exhaust it exactly as two GPU stages exhaust a
|
||||
card. `check_host_admission` applies the same discipline to that resource: sum
|
||||
the declared budgets, compare with available RAM and a margin, refuse to start
|
||||
otherwise, and fail closed on a stage that declares no budget.
|
||||
|
||||
| Qwen3-TTS 0.6B, init only | |
|
||||
|---|---|
|
||||
| sequential | 64.57 s |
|
||||
| **parallel** | **30.54 s** |
|
||||
|
||||
**A deploy YAML meant different things depending on which flag loaded it.**
|
||||
The `orchestrator:` block carrying `parallel_stage_init` was applied only on
|
||||
the `deploy_profile` path, so pointing `--deploy-config` at the very same file
|
||||
loaded its stages and dropped the flag: 63.76 s against 30.76 s, with nothing
|
||||
in the log to explain the difference. The block now applies either way.
|
||||
|
||||
### What is not a memory lever
|
||||
|
||||
Two candidates tested and closed, so they are not re-tried:
|
||||
|
||||
| lever | effect |
|
||||
|---|---|
|
||||
| OMP thread count (8 / 24 / 48) | 3754 / 3775 / 3818 MB -- 64 MB across a 6x range |
|
||||
|
||||
**Retracted (2026-09-14): KV cache size was on this list, and it is the single
|
||||
largest lever there is.** The row read "4 GiB -> 128 MiB saves 188 MB, the
|
||||
pages are never touched". Re-measured now that the budget is actually
|
||||
honoured -- 3 interleaved passes per arm, both filling a 1536-token context:
|
||||
|
||||
| KV budget | resident |
|
||||
|---|---|
|
||||
| 1 GiB (the smallest `VLLM_CPU_KVCACHE_SPACE` can express) | 3264 MB |
|
||||
| 192 MiB, explicit | **2421 MB** |
|
||||
|
||||
**843 MB.** And `mincore(2)` says why the old reasoning was wrong: the cache is
|
||||
not allocated-and-untouched, it is fully in core -- 1023.8 MB allocated against
|
||||
1024.1 MB resident, and 190.8 against 191.1 when sized down.
|
||||
|
||||
The explanation for the original 188 MB is two sections below, in this report's
|
||||
own words: `--kv-cache-memory-bytes` *was silently discarded on CPU*, because
|
||||
`CpuPlatform.check_and_update_config` overwrote it from
|
||||
`VLLM_CPU_KVCACHE_SPACE` unconditionally. So the "32x shrink" never happened;
|
||||
both arms ran the same cache and the 188 MB was measuring something else. The
|
||||
lesson is the one this study keeps relearning: a lever tested through a path
|
||||
that silently ignores it measures nothing, and "no effect" is the most
|
||||
dangerous result to accept without checking that the knob was connected.
|
||||
|
||||
The dual weight layout the W4A16 path keeps (tinygemm for decode, a row-major
|
||||
copy for prefill) *is* one. Median of three interleaved passes, anonymous RSS,
|
||||
spread under 1.5%:
|
||||
|
||||
| build | resident | prefill | decode | init |
|
||||
|---|---|---|---|---|
|
||||
| W4A8 g128 | 3640 MB | -- | 86.6 tok/s | 15.0 s |
|
||||
| W4A16 g128 | 3542 MB | 3252 tok/s | 76.9 tok/s | 12.4 s |
|
||||
| **W4A16, `prefill_dequant: false`** | **2802 MB** | 448 tok/s | 78.5 tok/s | 16.0 s |
|
||||
|
||||
The second layout costs **740 MB** and buys **7.3x prefill**, because the
|
||||
tinygemm kernel dequantizes once per row of activations and its cost is linear
|
||||
in the batch.
|
||||
|
||||
**That trade-off is smaller than it looked, but it is real** (2026-09-13). It
|
||||
rested partly on treating the tinygemm layout as opaque, so a row-major
|
||||
duplicate was kept beside it to have something to dequantize from -- but the
|
||||
duplicate was also doing work: `to_split_halves` performs the nibble
|
||||
de-interleave *once at load*, so every prefill call reads two contiguous
|
||||
planes. It is a precomputed permutation, not just a copy. Measured on this host instead of
|
||||
assumed -- by packing an index pattern and reading back where each nibble
|
||||
landed -- the layout is `[N/64][K][32 bytes]`: K is the middle axis, so a K
|
||||
range is contiguous within each n-block, and inside every 32-byte run byte `d`
|
||||
carries output channel `d` in its low nibble and `d + 32` in its high one, the
|
||||
same permutation for every `k` and every block. Decoding it is a deinterleave,
|
||||
not a reverse-engineered ISA table.
|
||||
|
||||
So the prefill path can dequantize from the kernel's own layout, and then the
|
||||
duplicate is not allocated -- at the cost of redoing that de-interleave on
|
||||
every call. The reconstruction is **bit-exact** against the previous
|
||||
dequantizer at 2048x2048, 3072x2048 and gate_up's real 13312x2048.
|
||||
Shapes where N does not tile the 64-channel block (N=80 is 64+16) keep the
|
||||
copy rather than mis-read the layout; every Spark linear tiles cleanly.
|
||||
|
||||
Measured with `VLLM_OMNI_CPU_INT4_DIRECT_DEQUANT` toggling the two paths in
|
||||
otherwise identical code, 3 arms x 2 interleaved passes, anonymous RSS summed
|
||||
over the process tree, 1536-token context:
|
||||
|
||||
| build | resident (median) | peak | spread |
|
||||
|---|---|---|---|
|
||||
| W4A16, second copy (old) | 4591 MB | 5508 MB | 0.2% |
|
||||
| **W4A16, one layout** | **3976 MB** | **4593 MB** | 5.4% |
|
||||
| W4A16, `prefill_dequant: false` | 3902 MB | 4522 MB | 2.6% |
|
||||
|
||||
**615 MB of resident memory and ~915 MB of peak**, and the one-layout build is
|
||||
statistically indistinguishable from `prefill_dequant: false` -- the harness
|
||||
refused to rank them (0.4% apart, inside the measurement's own spread). The
|
||||
arithmetic predicts 764 MB from the config; ~150 MB of that is allocator
|
||||
retention `malloc_trim` does not return.
|
||||
|
||||
It is not free. Prefill, pp512, pooled over 7 interleaved passes per arm on
|
||||
idle node-1 cores with memory bound to the same node:
|
||||
|
||||
| build | resident | pp512 median | range | spread |
|
||||
|---|---|---|---|---|
|
||||
| two layouts (old default) | 4591 MB | 2190 tok/s | 2117-2345 | 9.7% |
|
||||
| **one layout, direct dequant** | **3976 MB** | **1344 tok/s** | 1271-1555 | 18.3% |
|
||||
| `prefill_dequant: false` | 3902 MB | 453 tok/s | 451-453 | 0.3% |
|
||||
|
||||
So it costs **1.63x prefill** on medians -- and the ratio itself is only good
|
||||
to about 1.4-1.8x, because both dequantizing arms swing ~10-18% on this shared
|
||||
host while the tinygemm-only arm reproduces to 0.3%. The *existence* and rough
|
||||
size of the cost is solid (a 63% gap is far outside that band); the third digit
|
||||
is not. An earlier draft of this section said 1.9x, taken from the single
|
||||
surviving pass of a grid whose other passes had crashed -- a number the harness
|
||||
itself had labelled "single pass -- not a result".
|
||||
|
||||
The mechanism is that undoing the kernel's interleave per call costs ~1.6x
|
||||
reading the pre-permuted planes (14.4 ms vs 9.2 ms per `gate_up` dequantize at
|
||||
8 threads). Two variants were tried and rejected: dequantizing to `[K, N]` and
|
||||
using `matmul` instead of `F.linear`, which avoids the element-wise transpose,
|
||||
measured *worse* at 20.3 ms, and the blocked form needs either 208 tiny GEMMs
|
||||
or the same permutation.
|
||||
|
||||
What this changes is the shape of the choice, not its existence:
|
||||
|
||||
* against `prefill_dequant: false` it is **strictly better** -- the same memory
|
||||
to within the noise, **2.97x** the prefill, and it does not route multi-row
|
||||
batches into the tinygemm/AMX SIGILL hazard documented in `cpu_int4.py`. That
|
||||
setting is now dominated and should not be used. This comparison is the solid
|
||||
one: the tinygemm-only arm reproduces to 0.3%.
|
||||
* against the old default it is a real dial: 615 MB for ~1.6x prefill. On a
|
||||
device chosen for its memory ceiling that is the right side of the trade;
|
||||
on one with headroom it is not. `direct_dequant: false` in the checkpoint's
|
||||
`quantization_config` selects the old behaviour -- a config field rather than
|
||||
only an env var, because the two paths build different graphs and must not
|
||||
share an AOT compile artifact (see below).
|
||||
|
||||
Recovering the prefill would take a C++ de-interleave rather than an ATen
|
||||
expression chain, which is a real kernel and not in scope here.
|
||||
|
||||
One hazard found while measuring this, worth recording because it presented as
|
||||
a crash rather than a wrong number. The two paths build different graphs -- one
|
||||
passes `weight_halves` into the custom op, the other passes `None` -- but vLLM
|
||||
keys its AOT compile cache on the *config*, so an env-var-only switch shares
|
||||
one cached artifact between them. Three passes died with
|
||||
`KeyError: 'weight_halves'` inside an AOT-loaded forward after the first pass
|
||||
saved an artifact for the other path. The choice is therefore a
|
||||
`quantization_config` field, which `ModelConfig.compute_hash` does hash; the
|
||||
env override remains for experiments and needs its own `VLLM_CACHE_ROOT`.
|
||||
|
||||
### The largest memory item was a RoPE table for a context nobody asks for
|
||||
|
||||
The report used to account for memory by subtraction -- weights, imports, and
|
||||
an "unexplained ~1.6 GB" left over. A remainder computed from two uncertain
|
||||
terms is not a measurement, so `memory_ledger.py` counts instead: unique tensor
|
||||
storages keyed by `data_ptr` (so tied weights are counted once), the KV cache
|
||||
found by attribute, and every `/proc/self/smaps` mapping bucketed. Allocated is
|
||||
separated from resident with `mincore(2)`, because a cache that was allocated
|
||||
and never written costs address space, not memory, and counting it would hide
|
||||
the remainder rather than explain it.
|
||||
|
||||
The first run put 640 MB in an "other" bucket across 114 tensors. Naming the
|
||||
largest storages showed what it was:
|
||||
|
||||
model.layers.0.self_attn.rotary_emb.cos_sin_cache 512.0 MB [1048576, 256]
|
||||
model.layers.3.self_attn.rotary_emb.cos_sin_cache 128.0 MB [1048576, 64]
|
||||
|
||||
Spark advertises a **1 048 576-token context**, and `spark2_5.py` passed
|
||||
`max_position=config.max_position_embeddings` straight into `get_rope`, so vLLM
|
||||
built `torch.arange(1048576)` cos/sin tables -- two of them, because the
|
||||
sliding and full layers are trained with different theta and different partial
|
||||
rotary factors, giving one 256-wide table and one 64-wide one. Both fully
|
||||
resident by `mincore`. For a deployment whose `max_model_len` is 2048 they need
|
||||
**1.25 MB between them**.
|
||||
|
||||
vLLM's scheduler cannot produce a position beyond `max_model_len`, so the table
|
||||
only has to cover that. Capping it, 3 interleaved passes per arm with
|
||||
`VLLM_OMNI_SPARK_ROPE_FULL` toggling the two:
|
||||
|
||||
| | settled | peak | init |
|
||||
|---|---|---|---|
|
||||
| 1M-position tables | 3891 MB | 4019 MB | 13.5 s |
|
||||
| **capped to `max_model_len`** | **3273 MB** | **3370 MB** | 13.7 s |
|
||||
|
||||
**618 MB settled and 649 MB of peak, for one line and no measurable init
|
||||
cost** -- the largest single item found in this study, larger than the single
|
||||
weight layout (615 MB) or the blocked repack (489 MB), and unlike those two it
|
||||
costs nothing at all. The ledger's storage accounting agrees independently: the
|
||||
"other" bucket falls from 640.0 MB to 1.3 MB and unique tensor storage drops
|
||||
638.8 MB.
|
||||
|
||||
Two things the same ledger exposes, not yet acted on:
|
||||
|
||||
* **The KV cache is fully resident** -- 1023.8 MB allocated, 1024.1 MB resident
|
||||
by `mincore`. The "not a memory lever" verdict below was measured while
|
||||
generating 8 tokens; at page level the cache is entirely in core, and it is
|
||||
27% of total memory. That conclusion needs re-opening on this evidence.
|
||||
* **`embed_tokens.weight_packed` (128 MB) is kept only because the tinygemm
|
||||
layout was believed un-addressable for a single-row lookup.** That premise is
|
||||
now known to be false -- the deinterleave above reaches any row -- so the
|
||||
row-major copy is probably removable too.
|
||||
|
||||
### Peak load memory: the number an edge device actually has to survive
|
||||
|
||||
Everything above reports memory *after* the allocator is asked for its arenas
|
||||
back. That is the wrong target for a device: `release_after_load()` runs only
|
||||
once `load_model` and warm-up have both returned, so a box that cannot hold the
|
||||
peak never reaches the release. Sampling `RssAnon` on a timer through the load
|
||||
(rather than reading the endpoint) put the W4A16 peak at **5722-5837 MB**
|
||||
against ~4480 settled -- a 1.2-1.3 GB spike nothing in the tree had measured.
|
||||
|
||||
Most of it was in our own repack path. `unpack_nibbles` expands 4-bit codes to
|
||||
int32 -- 4 bytes per weight, eight times the packed form -- because the
|
||||
kernel's packer wants int32, and doing that for a whole matrix makes the
|
||||
transient the largest object in the process:
|
||||
|
||||
| tensor | shape | int32 expansion |
|
||||
|---|---|---|
|
||||
| `gate_up` | 13312 x 2048 | 104 MiB |
|
||||
| `down_proj` | 2048 x 6656 | 52 MiB |
|
||||
| `q_k_v_proj` | 3072 x 2048 | 24 MiB |
|
||||
| **embedding (tied, so also the head)** | **131072 x 2048** | **1024 MiB** |
|
||||
|
||||
against 24 MiB of packed weights for an entire layer.
|
||||
|
||||
The packed layout is `[N/64][K][32 bytes]`, so n-blocks are independent and the
|
||||
expansion can cover a few thousand rows at a time -- verified byte-identical to
|
||||
packing the whole matrix at four shapes plus a ragged last chunk. Measured with
|
||||
`VLLM_OMNI_CPU_INT4_REPACK_ROWS` toggling the two in identical code, 3
|
||||
interleaved passes, node-local cores:
|
||||
|
||||
| | peak | settled | load-time transient | init |
|
||||
|---|---|---|---|---|
|
||||
| whole-matrix repack | 4502 MB | 3905 MB | 550 MB | 17.7 s |
|
||||
| **blocked repack** | **4013 MB** | 3892 MB | **105 MB** | 16.6 s |
|
||||
|
||||
**489 MB of peak, for nothing.** Settled memory and init time are unchanged,
|
||||
and the repack is in fact *faster* chunked than whole -- 79 ms against 208 ms
|
||||
for the embedding at 24 threads, because the working set stays in cache. The
|
||||
chunk size is tuned from that sweep: 2048 rows is both the fastest and nearly
|
||||
the leanest (16 MiB transient), where 512 costs 116 ms and 32768 costs 129 ms.
|
||||
|
||||
Two things this nearly got wrong, both caught by re-measuring rather than
|
||||
reasoning. The first blocked pass showed init at 50.2 s against 17.7 -- but it
|
||||
is the one pass the harness flagged `[busy]` at load 34.8, and the two clean
|
||||
passes read 16.6 and 16.2. And its settled memory looked 239 MB worse, which
|
||||
was pass-1 scatter: the medians are 3892 against 3905.
|
||||
|
||||
The absolute peak improves less than the 1 GB the arithmetic predicts, because
|
||||
the single-layout change above had already removed `to_split_halves`, which was
|
||||
doing its own whole-matrix expansion. The two items overlap and their savings
|
||||
must not be added.
|
||||
|
||||
### Kernel fusion: a negative result
|
||||
|
||||
vLLM's CPU build ships `_C::rms_norm` and `_C::fused_add_rms_norm`, and
|
||||
`vllm/kernels/vllm_c.py` already wraps both as IR-op implementations -- but
|
||||
gates them on `GPGPU_DEVICE = is_cuda_alike() or is_xpu()`, so on CPU every
|
||||
norm falls through to the PyTorch path. In isolation the fused kernels are
|
||||
**3.6x and 4.5x faster** at Spark's decode shape (5.76 vs 20.99 us; 9.24 vs
|
||||
41.14 us). End to end they are **slightly slower**: 72.7 vs 74.0 tok/s.
|
||||
|
||||
The reason is that the native norm was never running alone -- inductor fuses it
|
||||
into the residual add and the surrounding elementwise work, and an opaque `_C`
|
||||
call breaks that fusion for more than the norm itself saves.
|
||||
`vllm_omni/model_executor/layers/cpu_ir_ops.py` keeps the registration, off by
|
||||
default (`VLLM_OMNI_CPU_FUSED_NORMS=1` to try it on another machine), with the
|
||||
measurement recorded. `gelu_and_mul` is deliberately not registered at all: its
|
||||
fused CPU kernel is 2.7x *slower* than native at Spark's GEGLU shape (64.11 vs
|
||||
23.36 us), and `GeluAndMul.forward_cpu` in vLLM only reaches for it on POWERPC
|
||||
anyway.
|
||||
|
||||
Two more fusion attempts that lost, recorded so they are not repeated:
|
||||
`cpp.min_chunk_size` raised to keep inductor's small elementwise kernels
|
||||
single-threaded cost 6% of decode and 19% of prefill; and the head gate written
|
||||
as an inline reduction (rather than behind an opaque op) made the whole step
|
||||
**1.8x slower** -- 78.8 to 44.5 tok/s -- because inductor's generated loop is
|
||||
worse than the matmul it replaced.
|
||||
|
||||
## 3. GPU: vLLM-Omni vs SGLang
|
||||
|
||||
One L20X (140 GiB HBM), bf16, single sequence, caches disabled on both
|
||||
(`enable_prefix_caching=False` / `disable_radix_cache=True`), 2048-token
|
||||
prefill so the measurement is not dominated by dispatch overhead.
|
||||
|
||||
| engine | init | pp2048 | tg128 |
|
||||
|---|---|---|---|
|
||||
| vLLM-Omni | 111.3 s | 137 102 tok/s | **451.4 tok/s** |
|
||||
| SGLang 0.5.19 | 97.1 s | **142 253 tok/s** | 434.7 tok/s |
|
||||
|
||||
A tie within 4% either way: SGLang +3.8% on prefill, vLLM +3.8% on decode.
|
||||
|
||||
SGLang has an Intel AMX CPU backend, but the released `sgl-kernel` wheel does
|
||||
not ship the CPU ops it needs (`convert_weight_packed` is absent), so SGLang
|
||||
could not be measured on CPU without a source build. On the two targets this
|
||||
goal is actually about -- CPU and a phone -- SGLang does not run at all today.
|
||||
|
||||
## 4. Galaxy S25 (Snapdragon 8 Elite), real device
|
||||
|
||||
`vllm_omni/edge/spark_export.py` exports one decode step with the KV cache as
|
||||
explicit tensors, sized per layer type: sliding layers get a fixed window at
|
||||
any context, full layers grow. Parity against the HF reference is **140.3 dB**
|
||||
with identical top-1 and top-5.
|
||||
|
||||
### What the device profile said to fix
|
||||
|
||||
AI Hub returns per-op cycle counts, and they made the first export's mistake
|
||||
obvious. Of a sliding layer at w4a16:
|
||||
|
||||
| op | share | what it was |
|
||||
|---|---|---|
|
||||
| `Output` | 9.9% | handing the recomputed 512-entry window back |
|
||||
| `output_..._converted` | 3.1% | quantising that window on the way out |
|
||||
| `Slice` x3 | 7.4% | rolling the window to drop the oldest entry |
|
||||
| **subtotal** | **20.4%** | **moving a cache-sized tensor across the graph boundary, once per token, in 21 of 28 layers** |
|
||||
|
||||
Full-attention layers spent a similar 9.5% on `Concat`, appending the new key
|
||||
to the cache.
|
||||
|
||||
The fix is a ring buffer: the runtime owns a fixed-size cache and writes the
|
||||
new entry into the slot for this position, so the graph returns one K/V entry
|
||||
instead of a window and never rolls anything. Ring order needs no fixing up --
|
||||
softmax over keys is order-independent and each cached key already carries its
|
||||
own rotary phase, so only slots not yet written need masking. The sliding
|
||||
cache correspondingly holds `window - 1` entries, because a 512-token window
|
||||
counts the current token (transformers' own sliding cache holds 511 for
|
||||
exactly this reason).
|
||||
|
||||
| layer | roll | ring | |
|
||||
|---|---|---|---|
|
||||
| sliding, fp16 | 1.689 ms | **1.486 ms** | −12.0% |
|
||||
| sliding, w4a16 | 0.917 ms | **0.838 ms** | −8.6% |
|
||||
| full, fp16 | 1.559 ms | 1.567 ms | no gain |
|
||||
|
||||
Full layers gain nothing, so the default now picks per layer type: ring for
|
||||
sliding, roll for full.
|
||||
|
||||
### Three things that did not work, and one that changed the answer
|
||||
|
||||
**The concat was not the problem.** Joining in score space instead of key
|
||||
space removes the concat entirely and is numerically identical (140.3 dB), but
|
||||
it needs an explicit max/exp/sum/div in place of a fused `Softmax`, and that
|
||||
cannot be quantised -- QNN's context-binary converter exits 14 at w4a16 while
|
||||
every fp16 build compiles. Two other 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. Keeping roll's fused-softmax attention and
|
||||
changing only what the layer *returns* captures the win and compiles.
|
||||
|
||||
**The cheaper GELU is not cheaper here.** The erf form is 11–13% of every
|
||||
layer. The tanh approximation holds 65.2 dB with top-1/top-5 intact, so it was
|
||||
adoptable -- but on this NPU it is *slower* (1.535 vs 1.483 ms). The exact form
|
||||
stays, which is also the better-fidelity choice.
|
||||
|
||||
**Per-graph overhead is negligible, so the per-layer method is sound.**
|
||||
Profiling one layer at a time pays the graph's Input/Output boundary once per
|
||||
layer, where a real step pays it once for 28. Exporting the architecture's
|
||||
repeating 4-layer block (3 sliding + 1 full) measured 6.103 ms against 6.016 ms
|
||||
summed from single layers — a 1.4% gap. There is no hidden win in fusing
|
||||
layers, and extrapolating from per-layer numbers is legitimate.
|
||||
|
||||
**Uncalibrated quantisation produces garbage, which invalidates the earlier
|
||||
numbers.** Running the real layer on the device and comparing against the fp32
|
||||
reference:
|
||||
|
||||
| build | SNR vs reference |
|
||||
|---|---|
|
||||
| fp16 | 60.6 dB |
|
||||
| w4a16, no calibration data | **−1.6 dB** |
|
||||
| w4a8, no calibration data | **−1.28 dB** |
|
||||
|
||||
Negative SNR means the output is uncorrelated with the truth. Every w4a16
|
||||
figure in the first version of this document came from a build in that state.
|
||||
Quantisation ranges have to be inferred from activations the model actually
|
||||
sees: real ones have `x` at absmax 0.12 and the KV cache at 7.4, where the
|
||||
random tensors used for tracing had 0.4 and 2.5.
|
||||
|
||||
Re-quantising with activations captured from the reference model on real text
|
||||
(4 samples, English, code and Chinese) fixes it:
|
||||
|
||||
| scheme | SNR | sliding layer |
|
||||
|---|---|---|
|
||||
| **w8a16, calibrated** | **36.9 dB** | **0.803 ms** |
|
||||
| w16a16, calibrated | 44.7 dB | 1.656 ms |
|
||||
| w8a8, calibrated | 5.1 dB — broken | 0.749 ms |
|
||||
| w4a16 / w4a8, calibrated | converter fails | — |
|
||||
|
||||
int8 *activations* destroy the layer even with calibration, which is the third
|
||||
model family in this repo to show it after Qwen3-TTS and MiniCPM-o. int4
|
||||
weights do not survive the calibrated path at all. **w8a16 with real
|
||||
calibration data is the deployable configuration**, and it is both faster and
|
||||
verified where the old w4a16 was neither.
|
||||
|
||||
### What is left, and why int4 is not it
|
||||
|
||||
Profiling the optimised layer shows the shape of the problem has changed
|
||||
completely. `Input` and `Output` -- 26% of the original layer -- are no longer
|
||||
in the top ten ops at all. What remains, of 0.803 ms:
|
||||
|
||||
| op group | share | can it move? |
|
||||
|---|---|---|
|
||||
| MLP up + down matmuls | 36.3% | no, this is the model |
|
||||
| GELU (fused elementwise) | 17.5% | no, tanh is slower here (measured) |
|
||||
| attention matmuls | 10.5% | no |
|
||||
| RMSNorm, decomposed | 8.5% | **yes, see below** |
|
||||
| cache slice | 4.1% | marginal |
|
||||
|
||||
**int4 weights are not the next lever, on two independent counts.** They do not
|
||||
compile: AI Hub's calibrated quantise path fails with `QAIRT converter failed
|
||||
with exit code 1` for w4a16 and w4a8, with and without `--quantize_io`. And
|
||||
they would not help anyway -- on this NPU 4-bit and 8-bit weights time
|
||||
identically, for the layer (0.917 vs 0.912 ms) and for the output head (3.868
|
||||
vs 3.862 ms). The calibrated *8-bit* build is faster than the uncalibrated
|
||||
4-bit one (0.803 vs 0.838). 4-bit buys memory here, not speed.
|
||||
|
||||
**The real remaining item is RMSNorm fusion, which calibration costs us.** The
|
||||
uncalibrated build contains two fused `rms` ops worth 1.0% of the layer; the
|
||||
calibrated build contains eight decomposed `Pow`/`ReduceMean`/`Div`/`Mul` ops
|
||||
worth 8.5%. Inserting QDQ nodes around each op breaks QNN's RMSNorm pattern
|
||||
match. The calibrated build is still faster overall and is the only correct
|
||||
one, so this is a trade worth making today -- but roughly 7.5% of every layer
|
||||
is sitting there for whoever can keep the fusion under quantisation.
|
||||
(`F.rms_norm` is not a route to it: the TorchScript ONNX exporter has no
|
||||
`aten::rms_norm` at any opset.)
|
||||
|
||||
### The decode step, end to end
|
||||
|
||||
Every component measured on a real S25 at calibrated w8a16, 1k context:
|
||||
|
||||
| component | ms each | ms total | SNR |
|
||||
|---|---|---|---|
|
||||
| sliding layer x21 (ring) | 0.803 | 16.86 | 36.9 dB |
|
||||
| full-attention layer x7 (roll) | 0.942 | 6.59 | 37.4 dB |
|
||||
| output head x1 | 3.907 | 3.91 | 62.8 dB |
|
||||
| **decode step** | | **27.36** | **36.5 tok/s** |
|
||||
|
||||
Against the previous best (roll layout, uncalibrated w4a16, plus the same
|
||||
head) at 31.01 ms: **11.8% faster, and numerically valid for the first time.**
|
||||
|
||||
The output head is 14% of the step and is 98.6% a single 2048x131072 matmul
|
||||
tied to the embedding — there is no overhead in it to remove. It was missing
|
||||
from the first version of this document entirely.
|
||||
|
||||
At 4k context the full-attention layers grow (2.96 ms each at fp16 vs 1.56 at
|
||||
1k) while the sliding layers do not move at all, which is what the hybrid
|
||||
architecture buys:
|
||||
|
||||
| quant | context | Spark hybrid | all-full counterfactual | gain |
|
||||
|---|---|---|---|---|
|
||||
| fp16 | 1 k | 46.4 ms | 43.7 ms | 0.94x |
|
||||
| fp16 | 4 k | 56.2 ms | 83.0 ms | 1.48x |
|
||||
|
||||
At 1k in fp16 the hybrid is *slightly slower*: sliding layers rotate all 256
|
||||
head dims where full layers rotate 64, and below roughly 2k that RoPE cost
|
||||
outweighs the shorter cache. It pays from ~2k onward and keeps paying, because
|
||||
the full-layer term grows with context and the sliding term never does.
|
||||
|
||||
### Which phone runtime, and which processor
|
||||
|
||||
The same exported sliding layer, one Galaxy S25:
|
||||
|
||||
| runtime | processor | precision | ms/layer |
|
||||
|---|---|---|---|
|
||||
| **QNN context binary** | **Hexagon NPU** | **w8a16 calibrated** | **0.803** |
|
||||
| TFLite | CPU | int8 | 0.877 |
|
||||
| QNN context binary | Hexagon NPU | fp16 | 1.479 |
|
||||
| TFLite | Hexagon NPU | fp16 | 1.491 |
|
||||
| ONNX Runtime | Hexagon NPU | fp16 | 1.500 |
|
||||
| TFLite | Adreno GPU | fp16 | 2.259 |
|
||||
| TFLite | CPU | fp16 | 2.958 |
|
||||
|
||||
Two readings. At matched fp16 the three NPU runtimes land within 1.4% of each
|
||||
other, so the export layout -- static shapes, explicit caches, GQA without
|
||||
expanding K/V -- is what sets on-device speed, not the engine; that reproduces
|
||||
what the Qwen3-TTS talker step showed on the same device. And the placement is
|
||||
the opposite of that TTS result: Spark's decoder wants the NPU (2.0x the CPU,
|
||||
1.5x the GPU) because it is matmul-bound, where the TTS vocoder wanted the GPU
|
||||
because it was elementwise-bound.
|
||||
|
||||
The row that matters for competition is the int8 CPU one. llama.cpp cannot be
|
||||
run on an AI Hub device, and on a phone it would use 4-bit weights on the CPU,
|
||||
so comparing our NPU path against an fp16 CPU run would flatter us. The
|
||||
closest honest proxy AI Hub offers is the same graph, int8, on the S25's own
|
||||
CPU: 0.877 ms against our 0.803 ms, so the NPU path is **9.2% ahead of the
|
||||
processor and precision class llama.cpp would actually run in**. That is a
|
||||
proxy, not llama.cpp itself.
|
||||
|
||||
Two independent checks say the proxy is not flattering us either. A 1.11 GiB
|
||||
Q4_K_M build on the S25's ~137 GB/s LPDDR5X has a hard bandwidth ceiling of
|
||||
7.9 ms/token at 100% of peak and 13.2 ms at a more realistic 60% — against our
|
||||
measured 27.4 ms for a step that includes the output head the bandwidth model
|
||||
ignores. Per layer, the bandwidth bound gives roughly 0.28-0.47 ms where the
|
||||
measured int8 CPU run takes 0.877 ms, i.e. real CPU execution lands at about a
|
||||
third of the bandwidth ceiling, which is the usual outcome on a phone.
|
||||
|
||||
## 5. Driving an Android phone
|
||||
|
||||
Spark-X2.5 is text-only, so any harness must drive from the accessibility tree
|
||||
rather than screenshots. Surveyed:
|
||||
|
||||
| harness | transport | text-only a11y mode | local OpenAI endpoint | role |
|
||||
|---|---|---|---|---|
|
||||
| droidrun/mobilerun | ADB + Portal accessibility service | yes | yes, OpenAI-compatible | control |
|
||||
| ghost-in-the-droid/android-agent | ADB + uiautomator, no app on device | yes | lists vLLM, base_url undocumented | control |
|
||||
| mobileClaw | on-device app, accessibility | vision-oriented | n/a | control |
|
||||
| AndroidWorld (DeepMind) | Android emulator | yes | via custom agent | **evaluation**, 116 scored tasks |
|
||||
|
||||
Recommendation: droidrun/mobilerun for control (it is the only one whose
|
||||
OpenAI-compatible base URL is documented, so it points at a local vLLM server
|
||||
unchanged), AndroidWorld for measuring whether the agent actually succeeds.
|
||||
|
||||
`analysis/experiments/spark_edge/android_agent/` implements the action space
|
||||
those harnesses share -- `tap`, `type_text`, `swipe`, `press_key`, `open_app`,
|
||||
`task_complete`, all indexed against accessibility-tree elements, with the
|
||||
`adb shell input` command each maps to -- and runs Spark against ten Android
|
||||
screens through vLLM's OpenAI server.
|
||||
|
||||
Serving works with vLLM's stock parsers: Spark emits
|
||||
`<tool_call>name<arg_key>k</arg_key><arg_value>v</arg_value></tool_call>`,
|
||||
which is GLM-4.5's format, so `--tool-call-parser glm45 --reasoning-parser
|
||||
glm45 --enable-auto-tool-choice` parses it as-is. No new parser needed.
|
||||
|
||||
| config | correct tool | fully correct action | median decision |
|
||||
|---|---|---|---|
|
||||
| thinking on | 7-8 / 10 | 5-6 / 10 | 8.9 s |
|
||||
| thinking off | 5 / 10 | 4 / 10 | **0.61 s** |
|
||||
|
||||
(16 CPU cores, bf16, measured under concurrent benchmark load, so latency is
|
||||
pessimistic.)
|
||||
|
||||
Thinking is worth roughly two of ten decisions and costs 14.6x the latency.
|
||||
With thinking off the model collapses toward answering `tap` for everything.
|
||||
With it on, the failure mode is the opposite: on the one genuinely ambiguous
|
||||
screen (reach Developer options, which is not visible and needs scrolling) it
|
||||
reasoned past a 1200-token budget without ever closing `<think>`, so vLLM's
|
||||
reasoning parser -- which only emits reasoning once the block closes --
|
||||
returned empty content and no tool call at all. A phone agent on this model
|
||||
needs a step budget and a fallback, not just a bigger cap.
|
||||
|
||||
The clearest wrong action was tapping an already-on Wi-Fi switch instead of
|
||||
calling `task_complete`, i.e. undoing the task it had completed.
|
||||
|
||||
No Android device or emulator is attached to this host (`/dev/kvm` absent), so
|
||||
these numbers measure the model's decisions over real accessibility-tree
|
||||
layouts, not a live device.
|
||||
|
||||
### 5b. Driving it in a loop, not one decision at a time
|
||||
|
||||
The numbers above measure single decisions over fixed screens. A phone agent
|
||||
is a loop, and a loop fails differently. `analysis/experiments/spark_edge/
|
||||
android_agent/` is now a working toolkit -- uiautomator XML to an indexed
|
||||
element list, an ADB device and a scripted simulator that emits the same XML,
|
||||
a tool executor, a tolerant tool-call parser and the loop itself (21 tests,
|
||||
no phone and no server required).
|
||||
|
||||
**Stock `glm45` does not always parse Spark.** Earlier this note said Spark's
|
||||
tool format is GLM-4.5's, so vLLM's parsers work as-is. True on 4 of 5 screens.
|
||||
On the Wi-Fi toggle screen -- the one where a switch's state decides the
|
||||
action -- the model left the format and emitted
|
||||
|
||||
[tap]
|
||||
{"tool": "tap", "parameters": {"index": 1}
|
||||
|
||||
unterminated, with `tool_calls` empty. An agent reading only `tool_calls` sees
|
||||
a step with no action. `tool_parse.py` recovers the bracketed form, GLM blocks
|
||||
and bare JSON, repairing unbalanced braces, and reports every recovery rather
|
||||
than silently repairing.
|
||||
|
||||
**Navigation survives quantization; recognising completion does not.** Same
|
||||
harness, same simulated phone, one run per cell:
|
||||
|
||||
| build | decode | "Turn on Wi-Fi." | "Set an alarm for 7:30 AM." |
|
||||
|---|---|---|---|
|
||||
| bf16 | 41.0 tok/s | done in 4 steps | wrong app (Settings, not Clock), toggles Airplane mode, stuck |
|
||||
| **W4A16 g128** | 80.0 tok/s | done in 4 steps | right app, full navigation, then no tool call |
|
||||
| W4A8 g128 | 86.6 tok/s | turns Wi-Fi on, then taps it off again (2 runs) | reaches Alarm, then loops |
|
||||
|
||||
Every build walks home -> Settings -> Network -> Wi-Fi correctly. Only some
|
||||
notice the switch now reads `checked=true` and stop. W4A8 -- the fastest build,
|
||||
and the one a speed-first selector picks -- ends with the task undone.
|
||||
|
||||
This is what section 2c's fidelity numbers meant. W4A8 reproducing 1/12 greedy
|
||||
prompts against its own bf16, and W4A16 2/12, looked like a small difference on
|
||||
a proxy metric; it is the difference between finishing a task and reversing it.
|
||||
**For agentic work the 4-bit choice is W4A16**, which `weight_path.py` selects
|
||||
under `priority="fidelity"` -- 8% of decode for the difference.
|
||||
|
||||
Adding an explicit "if the screen already shows what was asked, call
|
||||
task_complete" rule to the system prompt did not rescue W4A8. Nor did feeding
|
||||
back "the screen did not change", which bf16 was told five times on the alarm
|
||||
task before swiping again.
|
||||
|
||||
Limits: two tasks, one run each, on a simulated phone -- enough to show a
|
||||
failure mode reproduces, not to rank builds by success rate. The simulator
|
||||
emits real uiautomator XML, so the parser, the index-to-coordinate mapping and
|
||||
the stopping rules run on the path hardware drives; whether a real Settings app
|
||||
is laid out the way these fixtures assume is still untested, because this host
|
||||
has no `adb`, no emulator and no `/dev/kvm`.
|
||||
|
||||
## 6. Where it stands against the goal
|
||||
|
||||
- **Runs on CPU**: yes. Faster than llama.cpp at matched precision on both
|
||||
prefill (8.7-15.4x) and decode (1.19-2.65x), and an int8 path adds a further
|
||||
1.6x.
|
||||
- **Runs on Galaxy S25**: yes, measured on real hardware -- a full decode
|
||||
step, output head included, is 27.4 ms at 1 k context (36.5 tok/s) in
|
||||
calibrated w8a16, with every component verified at 36.9-62.8 dB against the
|
||||
reference. That is 11.8% faster than the first working version and the first
|
||||
configuration whose output is actually correct: the uncalibrated w4a16 builds
|
||||
quoted earlier score -1.6 dB, i.e. uncorrelated with the truth.
|
||||
- **Beats every edge engine**: on prefill, yes, against everything that can
|
||||
run the model, at any precision -- 4.7x over llama.cpp's best quantized
|
||||
build at 32 threads. On decode it depends on load: llama.cpp Q4_K_M wins
|
||||
single-stream by 1.6x, vLLM int8 wins from batch 16 and leads 1.72x at batch
|
||||
64. The single-stream gap is not closable on vLLM 0.28's CPU backend, which
|
||||
ships no dense int4 kernel.
|
||||
- **Beats SGLang**: on decode by 3.8% on GPU (prefill 3.8% the other way), and
|
||||
by default on CPU and phone, where SGLang does not run -- its released
|
||||
`sgl-kernel` wheel has no CPU ops and it has no phone backend at all.
|
||||
- **Fastest on the phone**: of every runtime and processor combination
|
||||
measurable on the S25, our QNN NPU path is quickest, including a 9.2% margin
|
||||
over the int8-on-CPU class llama.cpp would run in. llama.cpp itself cannot be
|
||||
run on an AI Hub device, so that specific comparison is a proxy.
|
||||
- **Phone control**: the action space, an agent loop and measured decision
|
||||
accuracy are in `analysis/experiments/spark_edge/android_agent/`; serving
|
||||
needs no new parser, just `--tool-call-parser glm45 --reasoning-parser
|
||||
glm45`.
|
||||
|
||||
Open items: no dense int4 on CPU (upstream kernel gap); int8 long-context
|
||||
fidelity would benefit from a smarter scheme than round-to-nearest; on the NPU
|
||||
the next ~7.5% per layer is RMSNorm fusion, which calibration currently breaks
|
||||
(int4 weights are neither available nor useful there -- 4-bit and 8-bit time
|
||||
identically on this hardware); and none of the phone-agent numbers have been run against a live device, since
|
||||
this host has no emulator (`/dev/kvm` absent).
|
||||
Reference in new issue
Block a user