[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>
This commit is contained in:
1 parent
3834de3105
commit
4e3e8a1ebc
62 files changed
+11043
-56
No files matched your search
@@ -0,0 +1,182 @@
|
||||
# External stage backends: the Radeon 890M and the AMD NPU
|
||||
|
||||
`vllm_omni.edge.local.external` lets the local engine place one stage on a device
|
||||
vLLM does not drive, prove it actually ran there, and charge its memory to the
|
||||
same ledger as everything else. Status: the plumbing is done and measured
|
||||
(M3.0); no model component runs on either device yet (M3.1–M3.4).
|
||||
|
||||
## Why these devices need another process
|
||||
|
||||
Neither can be opened from the engine's interpreter, and for different reasons.
|
||||
|
||||
The **XDNA2 NPU** is an MCDM device. WSL2's GPU-PV forwards WDDM display
|
||||
adapters only, so `/dev/accel` does not exist inside WSL and no amount of
|
||||
installing there changes it; VitisAI is a Windows DLL besides.
|
||||
|
||||
The **Radeon 890M** *is* reachable from WSL, over DirectML through `/dev/dxg` —
|
||||
but `torch-directml` pins torch 2.4.1 and Omni runs 2.13.
|
||||
|
||||
They cannot even share a Windows venv: `onnxruntime-directml` is pinned at
|
||||
1.24.4, and the VitisAI EP needs onnxruntime ≥ 1.25.
|
||||
|
||||
So: one protocol, four routes, each in its own interpreter.
|
||||
|
||||
| route | interpreter | artifact | status on this laptop |
|
||||
|---|---|---|---|
|
||||
| `ort-vitisai` | native Windows, plain ORT ≥ 1.25 | `onnx:a16w8` | working |
|
||||
| `torch-dml` | WSL `.venvs/dml` | `pt:fp16` / `pt:fp32` | working |
|
||||
| `ort-dml` | native Windows, onnxruntime-directml | `onnx:fp16` / `onnx:fp32` | venv not installed |
|
||||
| `ort-cpu` | anything with ORT | any ONNX | reference arm |
|
||||
|
||||
`python -m vllm_omni.edge.local devices` prints which resolved; a route that did
|
||||
not resolve says what is missing rather than disappearing.
|
||||
|
||||
## Placement is verified, never assumed
|
||||
|
||||
A VitisAI session lists the EP in `get_providers()` **whether or not it claimed
|
||||
a single node**, and a graph it declined runs entirely on the CPU and returns
|
||||
bit-identical numbers. Identical output cannot confirm NPU execution, and a
|
||||
small difference cannot confirm it either. The only evidence is ORT's per-node
|
||||
provider assignment, which exists only after something has run — so `open()`
|
||||
always does one profiled warm-up run and the planner refuses on the node split.
|
||||
|
||||
An *unmeasured* placement (`fraction_on_target is None`) is refused too. It is
|
||||
not rounded to "fine".
|
||||
|
||||
On the torch-directml route there is no partitioner, so the evidence is coarser
|
||||
and equivalent: the device the output tensors came back from. A module that
|
||||
fell back to the CPU returns CPU tensors.
|
||||
|
||||
## Memory
|
||||
|
||||
The 890M and the NPU report `memory_bytes == 0` on purpose. All three devices
|
||||
allocate out of the CPU's RAM, so repeating the pool size on each row would let
|
||||
a caller add three copies of the same memory together. Capacity comes from the
|
||||
host row — the same number the CPU stage is being budgeted against.
|
||||
|
||||
The worker is a different process, on Windows a different OS, so the engine's
|
||||
psutil sampler cannot see it. It reports its own resident set, and that is what
|
||||
closes the budget-versus-measurement loop.
|
||||
|
||||
## Refusal codes
|
||||
|
||||
| code | means |
|
||||
|---|---|
|
||||
| `no_exported_graph` | the device runs graphs and there is no export for this stage at this precision |
|
||||
| `execution_provider_declined_graph` | the session ran and left the graph on the CPU |
|
||||
| `numeric_gate_failed` | it ran on the device and disagreed with the reference |
|
||||
| `device_unreachable_from_this_process` | the device works; no interpreter here drives it |
|
||||
| `budget_exceeds_device` | the stage does not fit the shared host pool |
|
||||
|
||||
All of them exit 2 from the CLI. A refusal is a result.
|
||||
|
||||
## Using it
|
||||
|
||||
```bash
|
||||
python -m vllm_omni.edge.local external \
|
||||
--graph out/code2wav.onnx --format onnx:fp16 --component code2wav \
|
||||
--device gpu_integrated:amd --runs 20 --json record.json
|
||||
```
|
||||
|
||||
`--device` requires that device, so "does the NPU run this?" is answered with a
|
||||
refusal rather than quietly with the iGPU. `--prefer` falls through instead.
|
||||
`--min-placement` sets how much of the graph the target EP must take.
|
||||
|
||||
## First real model component: Qwen3.8-27B's vision tower
|
||||
|
||||
`vllm_omni.edge.local.artifacts.vision_tower` exports it at a fixed image size.
|
||||
It is the component that best fits the transport constraint — 27 blocks run once
|
||||
per image, 4.8 MB in and 4.0 MB out — and it needs no quantization story for the
|
||||
iGPU, because the FP8 checkpoint keeps all 333 visual tensors in bf16.
|
||||
|
||||
The export wraps `transformers`' `Qwen3_5VisionModel` (whose parameters match the
|
||||
checkpoint name for name, 333/333) and only fixes its shapes; the wrapper's
|
||||
output is **bitwise identical** to the model's own `forward`. At a fixed size the
|
||||
bilinear indices, position ids and `cu_seqlens` are constants, so folding them
|
||||
into buffers removes every integer `Gather` from the graph.
|
||||
|
||||
| | 890M (DirectML, fp32) | AMD NPU (A16W8) |
|
||||
|---|---|---|
|
||||
| placement | **85 of 113 nodes** | **0 of 3246** — declined |
|
||||
| latency | **227.0 ms** device, 237.2 round trip, 11.4 transport | — |
|
||||
| vs torch CPU (1363 ms) | **6.0×**, or 5.7× including transport | — |
|
||||
| vs RTX 5090 (95.9 ms) | 2.4× slower | — |
|
||||
| parity vs torch CPU fp32 | 9.10e-06 normalized, cosine 0.99999994 | — |
|
||||
|
||||
Moving the tower off the discrete GPU frees **1862 MiB** of its VRAM, which
|
||||
matters for a 21–29 GB model on a 23.89 GiB card.
|
||||
|
||||
The NPU refusal is about **this artifact**, not the model or the precision: the
|
||||
same A16W8 scheme applied to the merger subgraph alone *was* accepted (1 of 8
|
||||
nodes), and cutting requantization from 3058 to 984 Q/DQ nodes did not change the
|
||||
whole-graph answer. The next step is a bounded bisect of what the partitioner
|
||||
will take, not a broader claim.
|
||||
|
||||
One number worth keeping straight when comparing devices: the 890M's fp32 output
|
||||
is **closer** to the CPU reference (9.10e-06) than the RTX 5090's is (5.65e-04),
|
||||
because CUDA runs matmul in TF32 by default. That is a statement about what each
|
||||
device actually computed, not about which is more accurate.
|
||||
|
||||
## Measured, 2026-09-15, this laptop
|
||||
|
||||
Transport, WSL ↔ native Windows: **11 µs** half round trip, **330 MB/s**.
|
||||
|
||||
| | NPU (A16W8 probe graph) | 890M (100.7 M-param torch.export graph) |
|
||||
|---|---|---|
|
||||
| device compute | 0.855 ms | 9.749 ms |
|
||||
| same graph on CPU | 6.270 ms | 49.661 ms |
|
||||
| transport | **2.797 ms** (cross-OS) | 0.523 ms (same OS) |
|
||||
| placement | vitisai 1 / CPU 2 nodes | outputs on `privateuseone` |
|
||||
| numerics vs CPU | 3.03e-05 normalized | — |
|
||||
|
||||
**The transport column is the design constraint.** On the NPU it consumes more
|
||||
than half of a 5.4 ms compute saving, because that graph moves 1 MiB in and
|
||||
1 MiB out. An external stage has to be compute-heavy and I/O-light: a vision
|
||||
tower that runs once per image qualifies, a decode step that runs once per token
|
||||
does not. This is the measured basis for the proposal's "coarse stages only".
|
||||
|
||||
The 890M's 4.8× includes transport, and deserves a caveat: the iGPU hit
|
||||
1.32 TFLOP/s, matching its recorded 1.23 fp32 rate, while the CPU path reached
|
||||
only 0.26 TFLOP/s against a 1.54 peak. It is as much a slow CPU path at that
|
||||
shape as a fast iGPU. Separately measured this session: the 890M does
|
||||
**2.85 TFLOP/s in fp16**, 2.3× the fp32-only figure the earlier notes carried.
|
||||
|
||||
## Exporting a graph these backends will accept
|
||||
|
||||
Run `sanitize_onnx` on every dynamo export. It is not a nicety.
|
||||
|
||||
torch's dynamo exporter emits `Reshape` with `allowzero=1`. onnxruntime's
|
||||
DirectML provider rejects that at **session initialisation** with a bare
|
||||
`E_INVALIDARG` out of `MLOperatorAuthorImpl.cpp` — and onnxruntime then does not
|
||||
raise. It prints `Falling back to ['CPUExecutionProvider'] and retrying` and
|
||||
hands back a working CPU session. Measured here: the 27B vision tower placed
|
||||
**0 of 1171 nodes** on the iGPU and computed correct features entirely on the
|
||||
CPU. With 58 `allowzero` attributes cleared, the same graph placed **85 of 113**.
|
||||
|
||||
`vllm_omni.edge.qnn_export.sanitize_onnx` was written for Qualcomm's converter
|
||||
and fixes exactly this, along with `IsNaN`/`Where` guard pairs and rank-0 `axes`
|
||||
initializers — all generic dynamo warts rather than vendor-specific ones. It
|
||||
preserves external data, so a multi-gigabyte export stays a pair of files.
|
||||
|
||||
`vllm_omni.edge.local.artifacts.vision_tower.export_onnx` always calls it.
|
||||
|
||||
Two independent guards catch the fallback if a graph slips through: the worker
|
||||
compares `session.get_providers()` against what was requested and reports the
|
||||
swap by name, and the node-assignment gate refuses on the placement itself.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **A16W8 is the only door on XDNA2.** A8W8 — what `quantize_static` produces by
|
||||
default — and fp32 are declined with no error and every node left on the CPU.
|
||||
- **Dequantize nodes stay on the CPU** by the partitioner's own account, so a
|
||||
graph that is mostly requantization will not benefit.
|
||||
- **TorchScript cannot be placed on DirectML** (torch 2.4.1 / torch-directml
|
||||
0.2.5): `map_location` raises `Cannot access storage of OpaqueTensorImpl`, and
|
||||
load-then-`.to(device)` trips `assert isinstance(param, Parameter)` inside
|
||||
`Module._apply`. Use `torch.export` `.pt2`, which is what
|
||||
`vllm_omni.edge.decoder_export` already emits — and do not call `.eval()` on
|
||||
the loaded module, which 2.4.1 does not support.
|
||||
- Small graphs cannot amortise dispatch; the NPU's advantage grows with GEMM
|
||||
size (2.6× at 256×1024×1024, 5.3× at 1024×2048×2048).
|
||||
|
||||
Raw records: [`analysis/experiments/m3_amd_devices_20260915/`](../../../analysis/experiments/m3_amd_devices_20260915/README.md)
|
||||
@@ -0,0 +1,466 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,286 @@
|
||||
# The local text mode: one stage, one device, and a plan you can argue with
|
||||
|
||||
`vllm_omni.edge.local` runs a single autoregressive text stage on whichever
|
||||
device *this process* can actually drive, and writes down why. It is the M0
|
||||
milestone of the [single-device engine
|
||||
proposal](../../../analysis/architecture_local_engine_20260915.md): the smallest
|
||||
thing that turns "a configuration that happens to run" into "a configuration
|
||||
that can be explained, reproduced and refused".
|
||||
|
||||
It adds no kernels, no scheduler and no graph compiler. vLLM keeps doing all of
|
||||
that inside the Omni stage. What this adds is the layer above: which device,
|
||||
which artifact, how many bytes, and what happens when the answer is no.
|
||||
|
||||
```bash
|
||||
python -m vllm_omni.edge.local devices # what this process can drive
|
||||
python -m vllm_omni.edge.local plan --model <ckpt> # exit 2 == explicit refusal
|
||||
python -m vllm_omni.edge.local run --model <ckpt> --prompt "..."
|
||||
python -m vllm_omni.edge.local accept --model <ckpt> --json out.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. "It fits" is not "it runs"
|
||||
|
||||
`models/Spark-X2.5-1.7B-int8` is 1.84 GiB of compressed-tensors int8 W8A8. The
|
||||
RTX 5090 Laptop in the reference machine has 23.89 GiB. Every capacity check
|
||||
passes, the engine starts, the weights load — and the first forward dies:
|
||||
|
||||
```
|
||||
dispatch_scaled_mm, csrc/.../c3x/scaled_mm_helper.hpp:34,
|
||||
Int8 not supported on SM120. Use FP8 quantization instead, or run on older arch (SM < 100).
|
||||
```
|
||||
|
||||
cutlass' `c3x` has no int8 `scaled_mm` for SM >= 100, and sm_120 is inside that
|
||||
range. Roughly forty seconds and a stack trace from a subprocess to learn
|
||||
something that is written in the checkpoint's `quantization_config` and the
|
||||
device's compute capability.
|
||||
|
||||
The planner reads both and refuses in about a second:
|
||||
|
||||
```
|
||||
REFUSED: no device in this process can run this checkpoint as asked.
|
||||
[weight_format_unsupported] cuda:0: the checkpoint is compressed-tensors:int8 and
|
||||
cuda:0 (NVIDIA GeForce RTX 5090 Laptop GPU, sm_120) executes only
|
||||
['awq', 'compressed-tensors:fp8', 'dense', 'gptq', 'modelopt_fp4']
|
||||
remedy: this device has fp8 tensor cores but no int8 scaled_mm path
|
||||
(cutlass c3x has none for SM >= 100). Use an fp8 or a dense build here,
|
||||
or run the int8 build on the CPU backend (.venvs/omni-cpu).
|
||||
```
|
||||
|
||||
The remedy is not decoration. The same checkpoint completes the full acceptance
|
||||
run on the CPU backend, at 16.3 tok/s median — so this is a property of the
|
||||
device, not a defect in the artifact, and the refusal says which.
|
||||
|
||||
## 2. What a device *is*, here
|
||||
|
||||
`hardware_probe` answers "what is in the box". That is the wrong question at
|
||||
load time, because a vLLM wheel is built for one platform: the CUDA wheel
|
||||
cannot run a CPU stage however many cores the probe finds, and the CPU wheel
|
||||
cannot reach the card beside it. So a device is runnable only when the probe
|
||||
finds it **and** `vllm.platforms.current_platform` is the platform that drives
|
||||
it. Everything else is enumerated with the reason it is not available:
|
||||
|
||||
```
|
||||
[runnable] cuda:0 NVIDIA GeForce RTX 5090 Laptop GPU (23.9GiB vram) backend=vllm:cuda
|
||||
[not here] npu:amd NPU Compute Accelerator Device (host_ram) backend=None
|
||||
An MCDM device; WSL2's GPU-PV forwards WDDM display adapters only...
|
||||
[not here] gpu_integrated:amd AMD Radeon(TM) 890M Graphics (host_ram) backend=None
|
||||
No /dev/dri under WSL2, but reachable through DirectML over /dev/dxg...
|
||||
[not here] cpu AMD Ryzen AI 9 HX 370 (30.9GiB host_ram) backend=None
|
||||
vllm.platforms.current_platform is 'cuda', not 'cpu'...
|
||||
```
|
||||
|
||||
The iGPU and the NPU are reported rather than dropped, because "the machine has
|
||||
an NPU" and "a stage can run on it" are different claims and a plan has to be
|
||||
able to show which one it relied on. They share `host_ram` with the CPU and
|
||||
carry zero bytes, so nothing can accidentally add three copies of the same RAM
|
||||
together.
|
||||
|
||||
`--mask gpu_discrete` hides the discrete GPU to exercise the no-NVIDIA
|
||||
deployment class. It is a policy, not amnesia: the device stays in the record
|
||||
with `masked: true` and its real memory.
|
||||
|
||||
## 3. The budget is a sum, and every term is visible
|
||||
|
||||
```
|
||||
ADMITTED on cuda:0 (NVIDIA GeForce RTX 5090 Laptop GPU) via vllm:cuda
|
||||
artifact 43e867b180f1a3d2 dense 7.66GiB
|
||||
budget 12.51GiB of 23.89GiB vram (total)
|
||||
weights 7843 MiB vram session_weights D 5 safetensors shard(s), on-disk size
|
||||
kv_cache 720 MiB vram session E flat budget at 1x context; 248 MiB if
|
||||
the backend accounts 27 sliding layers separately
|
||||
activations 120 MiB vram request E 2048-token prefill chunk x 12 live tensors
|
||||
backend_workspace 512 MiB vram session E attention scratch, driver/allocator context
|
||||
load_transient 1569 MiB vram load E freed once the weights are resident
|
||||
external_reserve 1024 MiB vram session E memory this engine does not own
|
||||
safety_margin 1024 MiB vram session E fragmentation and allocator slack
|
||||
```
|
||||
|
||||
`D` is read off the artifact; `E` is estimated. The peak is a **max**, not a
|
||||
sum: the loader's transient is gone before vLLM sizes the KV pool, so adding
|
||||
both would refuse plans that fit.
|
||||
|
||||
The KV line comes from `vllm_omni.edge.kv_budget`, which reads the real
|
||||
`layer_types`. Spark-4B is 9 full-attention and 27 sliding@512 layers, so a
|
||||
backend that accounts them separately needs 248 MiB where a flat charge needs
|
||||
720. The budget takes the flat number (it is the safe one) and reports the
|
||||
other.
|
||||
|
||||
**The estimates are checked, not believed.** After every run the engine reports
|
||||
the budgeted peak against the sampled one:
|
||||
|
||||
| | CUDA / Spark-4B bf16 | CPU / Spark-1.7B int8 |
|
||||
|---|---:|---:|
|
||||
| counter | `nvml_device_used` (whole card) | `tree_rss` (process tree) |
|
||||
| budgeted peak | 12.51 GiB | 6.55 GiB |
|
||||
| measured attributable peak | 8.76 GiB | 5.48 GiB |
|
||||
| budget / measured | 1.43x | 1.20x |
|
||||
|
||||
A budget above the measurement is the intended direction — admission needs a
|
||||
provable upper bound, not a prediction — but 1.40x is loose enough to be worth
|
||||
narrowing, and the number is in the record so somebody can.
|
||||
|
||||
### The flag that is not GPU-only
|
||||
|
||||
The plan sets `gpu_memory_utilization` on **both** pools, derived from its own
|
||||
budget. On the CPU backend vLLM reads that same field as the fraction of *host
|
||||
RAM* to reserve, so leaving it at its default there is not "no opinion" — it is
|
||||
asking for 92% of the machine. A 6.55 GiB CPU plan failed to start with
|
||||
`desired CPU memory utilization (0.92, 28.43 GiB)` until this was fixed.
|
||||
|
||||
The KV pool is set as an absolute `kv_cache_memory_bytes` rather than left as
|
||||
whatever remains inside that fraction, so the cache is the size the request
|
||||
limits imply. Both backends honour it exactly: CUDA logs 754974720 bytes, CPU
|
||||
logs `Explicitly set (0.14/30.91) GiB for KV cache`.
|
||||
|
||||
## 4. Streaming, state and cancellation
|
||||
|
||||
Three contracts, which is what the proposal says the first version needs:
|
||||
|
||||
* **`ChunkEvent`** carries the *delta*, never the output so far, plus a `seq`
|
||||
that detects loss and an `epoch` that retires it.
|
||||
* **`StateHandle`** is an opaque reference to state the backend owns. It is
|
||||
tied to the artifact that produced it (the same shapes from a different
|
||||
quantization are not the same state) and is never `migratable` — moving a KV
|
||||
cache between backends needs a measured layout conversion for the specific
|
||||
pair.
|
||||
* **`BoundedEventStream`** bounds chunks *and* bytes and blocks the producer,
|
||||
which is the backpressure. A single event larger than the whole byte bound
|
||||
still passes on an empty queue, or it would deadlock.
|
||||
|
||||
Cancelling is not "stop calling `next()`". The backend has work in flight and
|
||||
its tokens arrive after the cancel. `cancel()` retires the epoch first, then
|
||||
aborts in the backend, then closes the stream — *before* waiting on the
|
||||
producer, so a backend that hangs while unwinding delays cleanup rather than
|
||||
the caller. In the recorded runs the fence discarded 3 in-flight events on CUDA
|
||||
and 2 on CPU, and zero events from the retired epoch reached the consumer.
|
||||
|
||||
The device counter does **not** fall on cancel, because vLLM's KV pool is
|
||||
pre-allocated. What falls is the in-flight count. The report says so, so that
|
||||
"memory did not drop" is not read as "nothing was released".
|
||||
|
||||
## 5. Measuring memory without fooling yourself
|
||||
|
||||
Three counters, three different accountings, never summed:
|
||||
|
||||
* `nvml_device_used` is the whole card, every process. Under WSL2
|
||||
`nvmlDeviceGetComputeRunningProcesses` returns an empty list, so GPU bytes
|
||||
**cannot** be attributed to this engine's PIDs on this host. "Attributable
|
||||
peak" means peak minus a pre-load baseline (2.79 GiB here), not a private
|
||||
total.
|
||||
* `tree_pss` includes a proportional share of file-backed pages, which on an
|
||||
mmap'd safetensors load is partly page cache.
|
||||
* `host_available` moves for reasons that are not us.
|
||||
|
||||
Sampling is every 0.25 s, so every peak is a lower bound, and the record says
|
||||
that rather than assuming it away.
|
||||
|
||||
## 6. What Spark needed before any of this worked
|
||||
|
||||
Two gaps that made "the model is in the registry" and "the model runs under
|
||||
Omni" different statements:
|
||||
|
||||
1. **No `PipelineConfig`.** Spark's model definition had been registered for a
|
||||
while, but with no pipeline `Omni(model=<Spark checkpoint>)` had nothing to
|
||||
resolve, so the model was reachable only through plain `vllm.LLM`. Added as
|
||||
a single `LLM_AR` text stage in
|
||||
`vllm_omni/model_executor/models/spark2_5/pipeline.py`.
|
||||
2. **A stage-call mismatch.** Omni's AR model runner hands every stage model
|
||||
`sampling_metadata`, `logits_index` and `sampler`. Spark's
|
||||
`ForCausalLM.forward` caught them in `**kwargs` and forwarded them into
|
||||
`Spark2_5Model.forward`, which takes four arguments — `TypeError` on the
|
||||
first forward. They now stop at the `ForCausalLM` boundary, where the
|
||||
decoder stack has no use for them anyway.
|
||||
|
||||
One stage is the design, not a placeholder. An autoregressive session stays on
|
||||
one backend by default: prefill and decode share the KV cache, and a per-token
|
||||
device boundary buys nothing and costs a synchronization. Multi-stage is M2,
|
||||
and it needs the tensor/buffer contract that a single text stage — which hands
|
||||
a buffer to nobody — cannot be used to design.
|
||||
|
||||
## 7. Does the Omni path change the answer?
|
||||
|
||||
This is the one claim the rest of M0 cannot make for itself. Everything above
|
||||
is about the engine's own behaviour; this asks whether routing a decode through
|
||||
the Omni stage changed the model's output. The validation plan makes it a
|
||||
*hard* initial acceptance condition.
|
||||
|
||||
Same checkpoint, same device, same request limits, same environment, greedy
|
||||
with `ignore_eos` -- the only difference is the path: plain `vllm.LLM` straight
|
||||
into vLLM against `AsyncOmni` -> StageRuntime -> the stage's EngineCore.
|
||||
|
||||
**12 of 12 prompts, 128 greedy tokens each, token-for-token identical -- on
|
||||
both paths**: CUDA with the bf16 build and CPU with the int8 one. The quantized
|
||||
build gets its own run rather than inheriting the result, because quantization
|
||||
is exactly what makes a near-tie likelier to flip.
|
||||
|
||||
The comparison is on **token ids, not text**: two different id sequences can
|
||||
detokenize to the same string, and a text comparison would hide exactly the
|
||||
drift worth catching. The record keeps the first divergent position rather than
|
||||
a yes/no, because greedy decoding is not bitwise stable across batch shapes --
|
||||
a different prefill chunking reorders reductions and can flip an argmax at a
|
||||
near-tie, so "split at token 120" and "split at token 3" are different
|
||||
findings. Neither occurred.
|
||||
|
||||
The two engines load sequentially in one process, reference first and released
|
||||
before the second starts; two copies of a 7.66 GB checkpoint would not fit the
|
||||
budget this milestone is about respecting.
|
||||
|
||||
```bash
|
||||
python -m vllm_omni.edge.local parity --model <ckpt> --json out.json # exit 3 on divergence
|
||||
```
|
||||
|
||||
### The two paths do not need the same environment
|
||||
|
||||
The reference cannot start on this host with a default environment, and both
|
||||
reasons are worth knowing:
|
||||
|
||||
* under WSL2, vLLM's own GPU model runner allocates a `UvaBuffer` for its input
|
||||
batch and raises `RuntimeError: UVA is not available` without pinned memory.
|
||||
Omni's AR model runner does not take that path and starts either way.
|
||||
* FlashInfer's sampler JIT-compiles on first use and needs `nvcc`, which is not
|
||||
installed -- `nvidia-smi` reporting a CUDA version is not a toolkit. This one
|
||||
kills the engine at the *first token*, not at load.
|
||||
|
||||
Both now live in one `RUNTIME_ENV` applied by every entry point that starts an
|
||||
engine, because a comparison whose reference is configured differently from its
|
||||
subject is measuring the configuration.
|
||||
|
||||
## 8. Limits
|
||||
|
||||
* One model, one stage; no multi-stage, no tensor/buffer contract.
|
||||
* `--mask` is a process-level policy, not a physical absence. A full no-card
|
||||
verification needs another machine.
|
||||
* The iGPU and NPU are enumerated only; neither has a stage backend (M3).
|
||||
* Reference consistency covers **one decode mode**: single sequence, greedy,
|
||||
128 tokens, on both paths. It does not cover concurrency above 1 (which
|
||||
changes batch shapes, and greedy is not bitwise stable across those), longer
|
||||
outputs, or non-greedy sampling -- and it is not a quality evaluation. It
|
||||
says the Omni path did not change the output, not that the output is good.
|
||||
* Performance figures are one sample per prompt, not p50/p95, with no fixed
|
||||
power profile and no cache-cold guarantee.
|
||||
|
||||
## 9. Tests
|
||||
|
||||
```bash
|
||||
# hermetic; no GPU, no weights, ~3 s
|
||||
pytest tests/edge/local --ignore=tests/edge/local/test_e2e_local_text.py
|
||||
|
||||
# end to end against a real checkpoint; the venv decides which artifact
|
||||
pytest tests/edge/local/test_e2e_local_text.py
|
||||
```
|
||||
|
||||
The e2e tests run the acceptance criteria themselves: both startups reach a
|
||||
decision, twelve fixed prompts each reach 128 tokens, the budget bounds the
|
||||
measured peak, cancellation leaks nothing, and the Omni path decodes what plain
|
||||
vLLM decodes. They skip rather than fail when no checkpoint is present.
|
||||
**78 pass in each venv**; about 4 min on this machine either way.
|
||||
|
||||
The twelve prompts are the same twelve
|
||||
`analysis/experiments/spark_edge/quality_int4.py` measures the quantized builds
|
||||
against, so these numbers stay comparable with the existing W4A16 / W4A8 /
|
||||
Q4_K_M fidelity work.
|
||||
|
||||
Full run records:
|
||||
[`analysis/experiments/m0_local_text_20260915/`](../../../analysis/experiments/m0_local_text_20260915/README.md).
|
||||
Reference in new issue
Block a user