Characterization (reference): GPU-target + NPU-draft speculative decoding on Lunar Lake — works, but consistently slower than GPU-only (0.55–0.74× throughput). Methodology + numbers #36484
blairducrayoppat
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
TL;DR. On an Intel Core Ultra 7 258V (Lunar Lake) I measured speculative decoding with the target on the GPU and the draft model on the NPU. With an NPU-legal (symmetric INT4) draft it runs end-to-end and produces correct output — but it is a consistent net slowdown (0.55×–0.74× of GPU-only throughput, i.e. 1.35×–1.8× slower), whereas the same draft on the CPU gives a 1.35× speedup. The cause is simple and reproducible: the NPU runs the small draft model ~3.6× slower per token than the CPU does, and speculative decoding only pays off when the draft is cheap. This is reference data that confirms OpenVINO's documented guidance (use a CPU draft for a GPU/CPU target, or put both models on the NPU) — not a bug report. The related thread (openvino#34450) is resolved/closed; this just quantifies the symmetric-draft path it points to.
Raw data: the per-config measurements (JSONL + CSV + dataset card) are published as a dataset → https://huggingface.co/datasets/blairducrayoppat/npu-specdecode-lunarlake
Setup
LLMPipeline.--sym).optimum-cli export openvino --model Qwen/Qwen3-0.6B --task text-generation-with-past --weight-format int4 --sym --group-size 128 --ratio 1.0. The--symflag matters: per-group asymmetric INT4 is unsupported on the NPU (it produces u4 zero-points the NPU compiler rejects; see #34450). This symmetric export is my working recipe that satisfies the NPU constraint (verified: 0u4zero-point constants, 196i4weight constants in the IR).ignore_eos=True, fixed 128-token budget, 1 warm-up + 3 measured reps, each config in a fresh process.ignore_eosforces a fixed decode length so per-config throughput is compared over identical work rather than over runs that stop at different points. Metrics fromPerfMetrics/SDPerModelsPerfMetrics. Construct/compile time is measured once and excluded from throughput (the slowdown is steady-state per-token cost, not compile). Single prompt.num_assistant_tokens(NAT): on the stateful path this is an initial/seed value the pipeline adapts each round (grows on a full accept, shrinks otherwise, capped relative to the seed) — so NAT below is the seed, not a fixed per-round proposal count. Both the CPU and NPU drafts use this same stateful path, so the comparison is apples-to-apples.Constructing it (the one gotcha)
A heterogeneous NPU draft only constructs via the stateful speculative pipeline. Passing a
scheduler_configselects the continuous-batching pipeline, whose paged-attention scheduler the NPU doesn't support — on 2026.1 that raisesRuntimeError: ... Option not found: scheduler_config(on currentmasterthe property is silently dropped on the NPU branch instead). Omitscheduler_configand it works:(The
assistant_confidence_thresholdform of dynamic drafting is continuous-batching-only, so it's unavailable on this stateful NPU-draft path.)Result 1 — the draft model alone, per device
Same Qwen3-0.6B-symmetric model, run standalone (no speculative decoding), 128 tokens, 3 reps:
The NPU runs this small model ~3.6× slower per token than the CPU (and ~5× slower than the GPU). That's expected: the NPU is built for sustained large-matmul throughput, and pays a high fixed per-inference dispatch cost that dominates on a tiny model invoked repeatedly. NPU first-compile also adds ~12 s.
Result 2 — speculative decoding (GPU target + draft)
128 tokens, greedy, 3 reps. NAT =
num_assistant_tokensseed (see Method). Speedup vs the 12.2 tok/s GPU-only baseline. Acceptance rate = accepted ÷ draft-proposed (this matches GenAI's own definition; single-prompt, illustrative).The CPU draft accelerates; the NPU draft decelerates at every setting. The NPU draft is least-bad at NAT seed=1 — the opposite of normal spec-decode tuning — because fewer draft tokens means less of the expensive NPU per-token cost.
Why
Speculative decoding nets a win only when the time to draft a few tokens is much smaller than the target verification it amortizes.
draft_tpotfromSDPerModelsPerfMetrics; the delta over the 34 ms standalone is cross-device GPU↔NPU synchronization). Drafting ~3 tokens ≈ 150 ms — already more than the ~63 ms target forward it's trying to amortize. Every round adds more latency than it removes.Crucially this is not an acceptance/quality problem: NPU-draft acceptance is comparable to the CPU draft (it's the same draft model; row-to-row differences are single-prompt noise) — ~0.60 at NAT seed=1. The draft proposes good tokens; it just costs too much to produce them on the NPU. Output is correct: under greedy, the NPU-draft and CPU-draft outputs are token-identical to each other (704/704 chars at 256 tokens). Speculative decoding is theoretically lossless w.r.t. the target's greedy decode; in practice the batched verification pass uses a different floating-point reduction order than the sequential no-draft path, so a single near-tie argmax can flip — which is why both spec-decode paths diverge from a no-draft run at the same token. It is not an NPU defect.
How this lines up with the documented design
This corroborates OpenVINO's existing guidance rather than contradicting it:
speculative_decoding_lmsamples (C++ and Python) default both models to CPU and state: "CPU, GPU and NPU can be used. For NPU, the preferred configuration is when both the main and draft models use NPU." — i.e. the NPU is positioned as a whole-LLM device, not as a draft behind a non-NPU target.So if you want to free the CPU by drafting elsewhere: today, on this hardware, a GPU target wants a CPU (or GPU) draft, not an NPU draft.
Caveats
Single machine, single prompt, n=3 (tight variance, but not a broad sweep); greedy; fixed 128-token budget; Windows; OpenVINO 2026.1 (behavior may differ on 2026.2/master). The acceptance numbers are prompt-dependent and illustrative — the robust finding is the throughput / per-token-latency result, which is device physics, not prompt-specific. These numbers use default NPU prompt/response budgets; on this stack a non-default NPUW
MAX_PROMPT_LENtriggered a0xC0000005access violation, so reproduce with defaults first.Reproducer
The core is the ~15-line snippet above, plus the same call with
"CPU"/ no draft. Full harness (bench_community.py,draft_standalone.py,bench_aggregate.py) and raw per-rep JSON (128-token budget) available — happy to share; the aggregated per-config numbers (JSONL/CSV) are in the dataset.Open questions (forward-looking)
assistant_confidence_threshold) on the NPU roadmap?AI assistance: I used an AI coding assistant to help build the benchmark harness, cross-check every number against the raw result JSON, and draft this write-up; all measurements were produced by running the harness on my hardware and validated against the on-disk result files. Per the OpenVINO AI Usage Policy.
Beta Was this translation helpful? Give feedback.
All reactions