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
50 KiB
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: falseit 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 incpu_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: falsein the checkpoint'squantization_configselects 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-kernelwheel 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).