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

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

467 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Qwen3.8-27B and Spark-X2.5-4B on a Ryzen AI laptop
Target machine: **AMD Ryzen AI 9 HX 370** (Zen 5, 12 cores / 24 threads,
AVX-512 with VNNI and BF16, **no AMX**), 30.9 GiB LPDDR5X, **RTX 5090 Laptop**
(24463 MiB, sm_120, PCIe 4.0 x8), **Radeon 890M** iGPU and an **XDNA2 NPU**,
running under **WSL2** (kernel 6.18.33.2).
Two models were added and both run:
| model | status | evidence |
|---|---|---|
| `XHToken/Spark-X2.5-4B` | runs; **24/24 greedy tokens identical to the HF reference** | `analysis/experiments/spark_edge/parity_hf_vs_vllm.py` |
| `Qwen/Qwen3.8-27B-FP8` | runs; correct output; 1.05 tok/s at the tightest fit | offload sweep below |
Neither needed a new model class. Spark-4B is the same `Spark2_5ForCausalLM`
the 1.7B uses and the implementation was already config-driven; Qwen3.8 is
`Qwen3_5ForConditionalGeneration`, which vLLM 0.28 already carries. What both
needed was the *sizing* and *placement* code around them, which is what this
document is about, and four environment faults, which are in
[§7](#7-what-was-actually-broken).
---
## 1. Three accelerators, and what each one costs to reach
The request was to use the CPU, the NPU and the GPU together. An earlier
version of this document said the iGPU and the NPU were simply unreachable
under WSL2. That was true of the default setup and false as a statement about
the machine: both have a route, the routes are not equivalent, and finding them
changed one of the two answers.
| device | reachable from WSL? | route | state |
|---|---|---|---|
| RTX 5090 Laptop | yes | `/dev/dxg` → `torch.cuda` | working |
| Radeon 890M iGPU | **yes** | DirectML over `/dev/dxg` (`torch-directml`) | **working, and slower than the CPU** |
| XDNA2 NPU | no | Windows-side ONNX Runtime + **VitisAI** EP | **working** — 2.6–5.3× the CPU on GEMMs |
### The iGPU: reachable, and measured not worth it
There is no `/dev/dri` under WSL2, and there does not need to be. Mesa ships a
D3D12 Gallium driver and Microsoft ships `libd3d12.so` / `libdxcore.so` in
`/usr/lib/wsl/lib`, so any WDDM adapter is addressable through `/dev/dxg`.
`glxinfo` already reports `D3D12 (AMD Radeon(TM) 890M Graphics)`, accelerated,
OpenGL 4.6. For compute, `torch-directml` offers it as a torch device:
```
DirectML device_count: 2
[0] NVIDIA GeForce RTX 5090 Laptop GPU
[1] AMD Radeon(TM) 890M Graphics
```
It computes correctly — a 512×512 matmul agrees with the CPU to **3.6e-07**.
Then it was benchmarked, which is why this section is short:
| | iGPU via DirectML | CPU | ratio |
|---|---:|---:|---|
| fp32 GEMM 2048³ | 1.12 TFLOP/s | 1.54 TFLOP/s | **0.73×** |
| memory bandwidth | 5.0–7.4 GB/s | 20.7 GB/s | **0.30×** |
| host↔device, 64 MB | 39.2 ms (3.3 GB/s) | — | — |
The iGPU is **slower than the CPU it shares memory with, on both axes**: the
D3D12 translation layer costs more than the hardware wins. It is genuinely
usable now, and the honest advice is to route work to it only when the point is
to free the CPU rather than to go faster. A native path — ROCm on bare-metal
Linux, or DirectML inside a Windows process — would not pay the translation and
is the version worth measuring.
Note the pin conflict: `torch-directml` requires `torch==2.4.1`, so it can
never share a virtualenv with the engine (torch 2.13). It gets its own.
### The NPU: working, at 2.6-5.3x the CPU
Not from WSL, and no driver install changes that: it is an **MCDM** device
(`IpuMcdmDriver`, AMD 32.0.203.329), and WSL2's GPU-PV forwards WDDM *display*
adapters only, so it has no device node in Linux. PCI passthrough is not a WSL2
feature. It has to be driven from a Windows-side process.
From Windows it works. Three things have to be right **at once**, and each one
failing on its own produces exactly the same symptom — a session that builds,
runs, returns correct numbers, and executes every node on the CPU:
1. **The right EP.** The appx package ships two.
`onnxruntime_providers_ryzenai.dll` registers cleanly, enumerates the NPU as
`OrtHardwareDeviceType.NPU`, opens a session — and claims **zero nodes** from
everything: fp32, INT8 QDQ symmetric and asymmetric, per-channel,
power-of-two scales, MatMul and Conv, and ahead-of-time `ModelCompiler`
(which emitted zero `EPContext` nodes). It is the *Light* EP. The general one
is `onnxruntime_vitisai_ep.dll`.
2. **The DLL search path.** That one fails to load from a bare
`register_execution_provider_library` with `Error loading … But no
dependency`. It needs its own directory added via `os.add_dll_directory`
first, because it pulls `dyn_dispatch_core.dll` and `vaiml.dll` from beside
it.
3. **A16W8, not A8W8.** The overlay names are embedded in the DLL and say so:
```
4x2_psf_model_a8w8_qdq.xclbin
8x4_psu_model_a16w8_qdq.xclbin <- most of them
2x4x4_bmm_model_a16w16.xclbin
```
XDNA2 wants **16-bit activations with 8-bit weights**. A8W8 — the default
shape of `quantize_static` — is rejected as completely as fp32, silently.
Also needed: **onnxruntime ≥ 1.25**, because the EP requests ORT API 25.
`onnxruntime-directml` stops at 1.24.4 and is refused, so the NPU and DirectML
cannot share a process.
With all of it right the partitioner starts talking and names its target,
`AMD_AIE2P_4x8_CMC_Overlay`. Measured against the same INT8 graph on the CPU:
| GEMM | CPU | NPU | speedup | NPU |
|---|---:|---:|---:|---:|
| 256 × 1024 × 1024 | 1.47 ms | 0.56 ms | 2.64× | 0.97 TOPS |
| 512 × 2048 × 2048 | 9.64 ms | 2.14 ms | 4.51× | 2.01 TOPS |
| 1024 × 2048 × 2048 | 19.89 ms | 3.76 ms | **5.29×** | **2.29 TOPS** |
Max relative error against the CPU: **3.2e-05**. It scales the right way — a
small GEMM cannot amortise the dispatch. Dequantize nodes stay on the CPU (the
partitioner says so explicitly), so a graph that is mostly requantization gains
nothing.
The recipe is code in `vllm_omni/edge/npu_ryzenai.py`, including the one check
worth trusting: **count nodes in an ORT profile.** A session reporting the EP in
`get_providers()` may still be running everything on the CPU — and when it does,
its outputs are bit-identical to the CPU, which is precisely what a *correct*
NPU run does not look like. A 0.000e+00 error is the bug signature, not the
success signature.
**What it is worth here.** Not decode: 2.29 TOPS against the dGPU's
48.2 TFLOP/s, over the same 20.7 GB/s system memory the CPU uses, cannot help a
bandwidth-bound step (§3). It earns its place on the compute-bound work off the
per-token path — a vision tower, a draft model for speculative decoding — where
5× the CPU on large GEMMs is real.
### Reaching a phone
The same question for mobile has a cleaner answer. The harness's `AdbDevice`
backend needs only an `adb` binary and a reachable device, and WSL2 is in NAT
mode, so **outbound** connections to the LAN work even though inbound do not.
That makes wireless debugging the path of least resistance:
```bash
sudo apt-get install -y adb # done; adb 1.0.41 (34.0.5)
adb connect <phone-ip>:5555 # Android 11+ wireless debugging
```
No USB passthrough is involved, which matters because WSL2 has none by default
— the USB route needs `usbipd-win` on the Windows side, and is worth the extra
moving part only when the phone cannot be on the same network. `AdbDevice` also
takes an explicit binary path, so `adb="/mnt/c/.../adb.exe"` drives a
USB-attached phone through the Windows adb server with no passthrough at all,
at the cost of translating paths on every `push`/`pull`.
The mobile **export** path is now working as well: the QNN export tests needed
`onnxscript` and `tabulate`, and the ExecuTorch lowering needed `flatc`. All
three are installed, and the one remaining ExecuTorch failure turned out not to
be a failure — pytest run from the repository root lets the bare `executorch/`
source checkout shadow the installed package. Run from `vllm-omni/` the suite
is **124 passed, 1 skipped**.
## 2. The measurement that decides the design
Taken on an **idle** machine (see §7 for why that word is load-bearing):
| path | measured |
|---|---|
| GPU VRAM bandwidth | **472 GB/s** |
| GPU bf16 GEMM 8192³ | **48.2 TFLOP/s** (91 W, 1447 MHz) |
| PCIe H2D, pinned, bulk | **13.9 GB/s** |
| PCIe, as seen by offloaded weight reads | **10.6 GB/s** (§5) |
| CPU RAM bandwidth | **20.7 GB/s** |
| CPU bf16 GEMM 4096³ | **1.54 TFLOP/s** |
Two ratios follow, and everything else in this document is a consequence:
```
VRAM : system RAM = 22.8 x
VRAM : PCIe link = 34 x (44x on the effective figure)
```
Autoregressive decode reads **every weight once per token** and does two FLOPs
per weight. At batch 1 it is bandwidth-bound with no room for argument, so
decode rate is set by *where the weights are*, and almost nothing else.
---
## 3. Why "use all three devices for decode" is the wrong goal
The intuition -- three processors, so split the model three ways -- inverts on
a bandwidth-bound workload. Splitting weights across pools does not add
bandwidth; the step ends when the *slowest* pool finishes, and the slow pool is
22.8x slower. Putting 10% of a model on the CPU makes the step
`0.9/472 + 0.1/20.7`, which is **3.2x slower** than putting all of it on the
GPU, not 10% slower.
The iGPU and the NPU do not escape this: both read the same LPDDR5X the CPU
does, so all three share one 20.7 GB/s pool — and the iGPU, measured, reaches
only 5-7.4 GB/s of it (§1). Adding them to a decode split adds arithmetic to a
problem that is not short of arithmetic, over a pool that is already the
bottleneck.
So the rule is: **for decode, one device, and it is the one with the memory
bandwidth.** The other devices earn their place on work that is *not* on the
per-token critical path:
| device | job | why it fits there |
|---|---|---|
| dGPU | every decode step, all resident layers | the only 472 GB/s in the machine |
| CPU | `embed_tokens` gather, detokenisation, scheduling, prompt templating | a gather reads one 10 KiB row per token, not the 2.54 GB table |
| NPU | the **vision tower** on image prompts; a **draft model** for speculative decoding | both are compute-bound and off the decode path — *pending the compiler in §1* |
| iGPU | the same class of work, when the goal is to free the CPU | reachable today, but measured at 0.73x the CPU's compute and 0.30x its bandwidth (§1) |
The vision tower is the clean case: 0.92 GB, run **once per image**, never per
token. Moving it off the GPU frees 0.92 GB of VRAM -- which by §5 is worth
86 ms/token -- and costs one prefill-time hop. That is a real heterogeneous win
and it does not touch the decode loop.
Speculative decoding is the other one, and it is the only way a second device
raises *decode* throughput: a draft model on the NPU proposes k tokens, the GPU
verifies them in a single batched pass, and the GPU's per-token weight read is
amortised over k tokens. Qwen3.8-27B **ships an MTP head for exactly this**
(`mtp_num_hidden_layers: 1`, 0.48 GB, in `mtp.safetensors`).
---
## 4. What the two models actually cost
`vllm_omni/edge/kv_budget.py` now prices both. It could price neither before:
it read `num_hidden_layers` off the top-level config, which `Qwen3_5Config`
does not have, and it charged every layer for a KV cache.
**Qwen3.8-27B-FP8** is a hybrid of a kind the old code had no concept of: of its
64 layers, **48 are gated DeltaNet**, which keep a fixed-size recurrent state
and *no KV cache at any context length*. Only the 16 full-attention layers
cache.
```
flat over 64 layers 256 KiB/token <- what a layer-blind budget charges
16 full layers only 64 KiB/token <- the truth, 4x smaller
GDN recurrent state 147 MiB/sequence <- constant, and previously invisible
```
The state is constant and the cache is linear, so they cross at ~2350 tokens:
below that, the thing the old budget did not count at all is the larger half.
At a 4096-token context the whole cache plus state is **467 MiB** (147 MiB of
state, 320 MiB of cache), and **787 MiB** at 8192 -- against 30.87 GB of
weights. For this model, **memory is a weights problem and not a
cache problem**, and long context is nearly free. That is the architectural
gift of the 3:1 linear hybrid and it is the strongest reason to want this model
on an edge box.
**Spark-X2.5-4B** is the other hybrid: 27 sliding-window(512) layers to 9 full
ones, 144 KiB/token flat, and a **3.37x** discount if the backend accounts per
layer type. Both sizes now have their published geometry pinned in
`tests/model_executor/models/spark2_5/test_spark2_5.py`, so a change that
assumes the 1.7B's 8 query heads or 28 layers fails in CI rather than on a
checkpoint.
---
## 5. The cost of not fitting, measured
Qwen3.8-27B-FP8 is **30.87 GB** of weights against a 24 GiB card. It only runs
with part of it on the host. Sweeping how much (4096-token context, batch 1,
greedy, `enforce_eager`, idle machine):
| offloaded | tok/s | s/token | marginal |
|---|---:|---:|---|
| 10 GB (the tightest fit; 9 GB fails) | **1.05** | 0.952 | |
| 12 GB | 0.89 | 1.124 | +0.086 s/GB |
| 14 GB | 0.76 | 1.316 | +0.096 s/GB |
| 16 GB | 0.66 | 1.515 | +0.100 s/GB |
Straight line, slope **+94 ms per offloaded GB**, i.e. an effective
**10.6 GB/s** -- the 13.9 GB/s bulk figure less ~20% of overhead. The model
```
s/token = resident_GB / 472 + offloaded_GB / 10.6
```
predicts the 10 GB point at 987 ms against 952 ms measured, and the whole curve
within 5%. At the tightest fit, **the 10 GB on the host is 94% of the step and
the 20.9 GB in VRAM is 4%.**
This is the number to design against: **a gigabyte that does not fit costs
44x what a gigabyte that fits costs** (94 ms against 2.1 ms). Fitting is not
one optimisation among several; it is the only one that matters until it is
solved.
### Which gigabytes to spend
vLLM's `cpu_offload_gb` offloads "non-selectively until the limit is reached",
which is the expensive way. Not every parameter is read once per token, and
`vllm_omni/edge/placement.py` sorts them:
```
lm_head 2.543 GB HOT one 248320 x 5120 GEMM per token
embed_tokens 2.543 GB COLD a gather: one row, ~10 KiB per token
visual.* 0.922 GB COLD unless the prompt carries an image
mtp.* 0.478 GB COLD unless speculative decoding is on
---------
cold set 3.94 GB free to evict
```
`embed_tokens` and `lm_head` are the same shape and the same dtype and the
opposite decision. A size-ordered, name-blind policy cannot tell them apart;
`cpu_offload_params`, which matches name segments, can.
For FP8 the cold set is 3.94 GB against a ~10.6 GB deficit, so it helps but
cannot close it -- **selective placement does not rescue this checkpoint**.
And a larger caveat, found after this section was first written: *the cold set
cannot currently be offloaded at all*. `cpu_offload_params` is real and matches
name segments as documented, but the offloader is only ever offered decoder
layers (`make_layers`, `utils.py:812`), so `embed_tokens`, `lm_head`, the vision
tower and the MTP head are out of its reach. The classification below is right
about which bytes are cheap to move; moving them needs an upstream change. See
§6.
---
## 6. The design: make it fit — and the retraction that follows
The previous version of this section said: use a 4-bit checkpoint, it is worth
~20×, everything else is worth less than 2×. That was arithmetic —
`resident_GB / 472 GB/s` — and the arithmetic was **wrong by roughly 30×**. The
4-bit build was then measured, and it is *slower* than the 8-bit one.
| build | weights | offloaded | decode |
|---|---:|---:|---:|
| `Qwen/Qwen3.8-27B-FP8` | 30.87 GB | 10 GB | **1.05 tok/s** |
| `nvidia/Qwen3.8-27B-NVFP4` | 21.95 GB | 3 GB | **0.67 tok/s** |
| the same, torch.compile + CUDA graphs | 21.95 GB | 3 GB | 0.72 tok/s |
| the same, 4 GB offloaded | 21.95 GB | 4 GB | 0.56 tok/s |
Three things went wrong at once, and each is worth stating separately.
**It does not fit, even at 21.95 GB.** The weights load to 19.92 GiB and the
card has 22.58 GiB free (the Windows desktop holds the rest). vLLM's runtime
overhead — non-torch allocations plus the activation peak — is about **2.8 GiB**
on this model, which leaves the KV cache at **−0.31 GiB**. Shrinking
`max_model_len` from 4096 to 1024 moved that to −0.30: the overhead is not the
cache, so context length cannot buy the fit.
**The lever that would have made it fit does not exist.** §5 identified 4.31 GB
of cold parameters and prescribed `cpu_offload_params={embed_tokens, visual,
mtp}`. The flag is real and matches name segments as documented. But the
offloader is only ever *offered decoder layers*: `wrap_modules` is called from
inside `make_layers` (`vllm/model_executor/models/utils.py:812`) and nowhere
else. `embed_tokens`, `lm_head`, the vision tower and the MTP head are built
outside it. Asking for the cold set moved **~0.5 GiB** and the run failed
identically to one with no offload at all. The classification was right about
which bytes are cheap to move; the mechanism to move them is missing, and
closing it is an upstream change.
**And fewer bytes turned out not to mean faster.** This is the part that
invalidates the projection rather than merely complicating it. The traffic model
`resident/472 + offloaded/10.6` fits the FP8 offload curve to within 4.3% across
four points. Against NVFP4 it is **4.7× optimistic**: predicted 3.1 tok/s at
3 GB offloaded, measured 0.67. Its own offload slope is 0.293 s/GB — an
effective **3.4 GB/s**, against FP8's 10.6 — and extrapolating to zero offload
gives ~**1.6 tok/s** even if it fit. Enabling torch.compile and CUDA graphs
changed 0.67 to 0.72, so this is not an eager-mode artifact.
The conclusion is that **NVFP4 decode on this stack is compute-bound, not
bandwidth-bound**, and a model that counts bytes cannot see that. Why it is
compute-bound is not established here: the checkpoint is a `modelopt` mix of
FP8 and NVFP4 with group-16 scales, and this fork already carries a correctness
patch for the W4A4 NVFP4 linear path, which is a hint that the path is not a
well-trodden one on sm_120. Diagnosing it is the obvious next experiment, and
until someone does, the honest statement is the measurement.
### What the design actually is, then
1. **Run the FP8 checkpoint with the smallest offload that fits** — 10 GB, 1.05
tok/s. This is the fastest configuration measured on this machine, and it is
the 8-bit one.
2. **Do not change quantization format on an estimate.** The one estimate this
document made without measuring is the one it got wrong, by 30×.
3. **Size the cache from the model** — 467 MiB at 4096 tokens (§4). Still right,
and still worth little here, because weights dominate.
4. **Speculative decoding via the shipped MTP head** is now the largest
*unexplored* lever, and the only one that attacks the per-token weight read
rather than working within it. It is untested here.
5. **Getting the cold set offloadable upstream** would free 3.94 GB on FP8, worth
~370 ms/token by the traffic model — which, on the FP8 path where that model
demonstrably holds, is the difference between 1.05 and roughly 1.6 tok/s.
The blunt summary: a 27B model on a 24 GB laptop GPU runs at **about one token
per second**, and the things that would change that by an order of magnitude —
a checkpoint that genuinely fits, or speculative decoding — are not yet in hand.
## 7. Where this beats llama.cpp, and where it does not
llama.cpp at the pinned revision **does** support this architecture -- `QWEN35`,
`QWEN35MOE` and `QWEN3NEXT` are in its arch registry and it has gated-DeltaNet
kernels. It is a real competitor here, and on a fitted 4-bit model on one
stream it is a strong one: this repository's own CPU measurements have
llama.cpp's Q4_K_M decode at 90 tok/s against our 74, and `docs/edge/` explains
that the gap is the execution model rather than the kernels.
So the case for vLLM-Omni on this machine is not raw single-stream decode. It is:
| axis | why |
|---|---|
| **Prefill** | measured ~2x llama.cpp on the CPU path (2190 vs 1121 tok/s, pp512). Prompt processing is compute-bound, which is the one regime where the hardware has headroom. |
| **Concurrency** | continuous batching and paged KV. At batch 1 they cost overhead; past ~4 streams they are the whole game, and llama.cpp has no equivalent. |
| **Speculative decoding** | the shipped MTP head is wired in vLLM and is not a GGUF concept. |
| **Multimodal pipelines** | the reason this fork exists: staged vision/audio/codec execution with an OpenAI-compatible server. |
| **Hybrid cache accounting** | 64 KiB/token instead of 256, once §4 is honoured. |
A design that claims to beat llama.cpp at batch-1 decode of a fitted model on
one GPU is claiming something this project has already measured to be false.
The defensible claim is the four rows above, and the way to establish it is
`benchmarks/edge_harness/` against `baselines/`, not assertion.
---
## 8. What was actually broken
Four faults stood between "the models are supported" and "the models run", none
of them in the model code:
1. **`RuntimeError: UVA is not available`.** vLLM disables pinned memory on WSL
by default; its own gate is kernel ≥ 4.19.121 and this kernel is 6.18. Fixed
by `VLLM_WSL2_ENABLE_PIN_MEMORY=1`. Without it the CUDA runner does not
start **at all**, for any model.
2. **No C compiler**, so Triton could not JIT and the GDN kernels could not
build. `gcc` plus `libc6-dev` (gcc alone did not pull the headers).
3. **FlashInfer's sampler wants `nvcc`**, which a runtime-only CUDA install does
not have. `VLLM_USE_FLASHINFER_SAMPLER=0`. Note this surfaced as a failure in
*warmup*, long after the model had loaded and run a forward pass.
4. **transformers 5 renamed `input_embeds` → `inputs_embeds`**, which broke the
vendored Spark modeling file used as the parity *reference*. Shimmed in
`parity_hf_vs_vllm.py` rather than by editing the cached module, so the
reference stays the code the checkpoint ships.
The required environment, in full:
```bash
export VLLM_WSL2_ENABLE_PIN_MEMORY=1
export VLLM_USE_FLASHINFER_SAMPLER=0
sudo apt-get install -y gcc libc6-dev
```
### And one measurement error, which is the real lesson
The first roofline taken on this machine read **3.67 GB/s** H2D, **38 GB/s**
VRAM and **9 TFLOP/s**, with `nvidia-smi` reporting a 33 W power cap and a
220 MHz SM clock. The conclusion drawn from it -- that the dGPU had no
bandwidth advantage over the CPU and the design should lean on the CPU -- was
wrong in every part.
Those numbers were taken **while two model downloads were saturating the disk,
the network and the CPU**. Re-measured on an idle machine the same calls give
13.9 GB/s, 472 GB/s and 48.2 TFLOP/s, at 91 W and 1447 MHz, with the power
limit reading 100.98 W. The GPU was never capped; it was idling because the
benchmark could not feed it.
This repository already has [a methodology
document](methodology.md) that exists because of five retractions of exactly
this shape. This is the sixth, and the rule it re-teaches is the one already
written down: **a number taken on a contended machine is not a number.** The
harness has a contention guard for this; it was not in the path of an
interactive measurement, which is precisely where the mistake got made.