wuklab-NUC16Intel Xe3 iGPU + NPU 5OpenVINO int8

Does NPU/CPU-assisted prefill make Holo1.5-7B faster and cheaper on the NUC 16?

A one-shot microbenchmark + OSWorld pilot for Holo1.5-7B (Qwen2.5-VL-7B) on a fanless Intel Core Ultra mini-PC, measuring where each pipeline stage should run — and whether the memory system, not compute, is the real bottleneck.

One representative OSWorld step: a 262144px screenshot → 1248 patches / 312 vision tokens, prompt length S=347 tokens. Every number below is measured on the real int8 OpenVINO IR with RAPL energy (reps-slope, net-of-idle); config totals compose the measured stages plus a unified-memory cross-device transfer tax (37.837 pJ/byte, measured).

Key findings

The five placements

Holo1.5's LLM is a plain transformer (KV cache only, 3-section M-RoPE — no Gated-DeltaNet), so it runs coherently on the NPU directly and prefill/decode can be split across devices with a zero-copy KV handover.

configurationlatency s/stepenergy J/step prefilltransfer mstransfer Jmissing
G0.1 whole-iGPU8.535223.72batched0.00.0
G0.2 vision-iGPU + LLM-NPU18.272223.89batched0.110.0004
G0.3 whole-NPU18.395223.28batched0.00.0
H0 prefill-NPU / decode+vision-iGPU8.935226.94batched0.560.0019
H1 op-split prefill8.538223.73op-split3.10.0105
End-to-end cost of one OSWorld step under each placement (measured stages + unified-memory transfer tax).
End-to-end cost of one OSWorld step under each placement (measured stages + unified-memory transfer tax).

On latency the split is clean: G0.1 / H0 / H1 (iGPU decode) ≈ 8.5–9 s, the NPU-decode configs (G0.2 / G0.3) ≈ 18 s. The energy column looks nearly flat here for a methodological reason worth stating: the NPU needs a static KV buffer, and this run sizes it to the deployed max context (L=3072) while the iGPU decodes at the true context (~347) — so the NPU decode pays for 9× more attention than it uses, which cancels its per-token energy edge. The right-sized control below recovers the NPU's ~40% decode-energy advantage; the practical lesson is to size the NPU KV buffer to the real context.

Where each stage belongs

Per-stage latency and energy by device on the real int8 IR.
Per-stage latency and energy by device on the real int8 IR.

Vision and prefill are compute-heavy (the accelerators help); decode is a per-token full-weight read (the accelerator barely matters — the memory system does).

The memory wall

Decode achieved DRAM bandwidth vs KV context — decode saturates the memory system.
Decode achieved DRAM bandwidth vs KV context — decode saturates the memory system.
Roofline: prefill is compute-bound, decode memory-bound.
Roofline: prefill is compute-bound, decode memory-bound.

Each decode token streams the whole ~7 GB int8 weight set from DRAM, so decode runs near the DRAM roofline regardless of device. Prefill amortizes that same weight read across all S context tokens in one batched pass — which is why prefill is compute-bound (~2 pJ/FLOP on the iGPU) and decode is memory-bound (~100–170 pJ/FLOP). The intuition motivating this study was that moving the compute-bound prefill onto the NPU's matrix engine would help. It doesn't — not because the NPU can't do it (it can), but because prefill is a small slice of a step, so where it runs barely moves the total.

The decode device is the real choice — a latency↔energy Pareto. The NPU streams weights at only ~27 GB/s (≈30% of DDR5 peak) versus the iGPU's ~58 GB/s, so NPU decode is ~2× slower — but it draws far less power, so per token it uses less energy than the iGPU. There is no dominant decode device: the iGPU is the latency choice, the NPU is the energy choice. Prefill, by contrast, is compute-bound and both accelerators handle it about equally (the NPU compiles the batched prefill in ~5 min and runs it at ~2.6 pJ/FLOP, on par with the iGPU) — and since prefill is a small slice of a step, moving it around barely changes the total.

How to actually optimize the memory side. Because decode is bounded by weight bytes moved per token, the levers are all on the bytes, not device placement: coarser weight quantization (INT4/mixed), weight streaming/residency so the read is not re-paid every token, or amortizing one weight pass over more tokens (speculative / multi-token decode). Relocating decode only trades latency for energy along the Pareto — it does not break the DRAM roofline.

H1 — fine-grained operator split

Prefill compute composition by operator type; H1 assigns QKV/O + FFN to the NPU.
Prefill compute composition by operator type; H1 assigns QKV/O + FFN to the NPU.

H1 would split each layer — QKV/O projections + FFN on the NPU, attention/norm/RoPE + decode on the iGPU. But the motivation for a fine-grained split evaporates once you know the whole prefill already compiles and runs on the NPU (~2.6 pJ/FLOP, on par with the iGPU): there is no compute-bound operator that needs rescuing onto a different engine. A per-op split would only add a per-layer NPU↔iGPU activation-shuffle tax (priced in Phase 1) for no compute win, so H1 lands at ≈ G0.1 / whole-NPU prefill. The interesting split is not within prefill but between prefill and decode — which is exactly the H0 / G0.2 latency↔energy trade above.

Control & correction — is the NPU behavior Holo-specific?

Control: plain dense LLMs (Qwen2.5-1.5B and -7B, the latter the identical LLM arch as Holo1.5). The NPU batches prefill for both; decode is the same latency↔energy trade.
Control: plain dense LLMs (Qwen2.5-1.5B and -7B, the latter the identical LLM arch as Holo1.5). The NPU batches prefill for both; decode is the same latency↔energy trade.

To be sure the NPU-prefill finding wasn't an artifact of the Holo1.5 VL export (custom fork, 3-section M-RoPE), I exported two plain dense text LLMs with stock tooling — Qwen2.5-1.5B and Qwen2.5-7B, the latter the identical LLM architecture as Holo1.5 (28L / 3584 / 28 heads / 4 KV / FFN 18944) but text-only with standard RoPE — and profiled them the same way. Both compile and run batched prefill on the NPU (1.5B in 64 s, 7B in 284 s, NPU util 0.89), and both show the same shape: prefill NPU ≈ iGPU, decode a latency↔energy trade (NPU slower, ~40% lower energy). This is what corrected the earlier "NPU can't batch prefill" conclusion: it was the profiler's PERF_COUNT/f16 compile hints ballooning the large-T compile past its timeout — not a hardware limit. The bare driver config compiles the very same Holo graph in ~5 min.

Phase 2 — OSWorld pilot (success preservation)

One task per domain (round-robin, 8 steps max) on the runnable placements. The question was whether NPU-assisted prefill reduces success. It does not — because success is 0/10 either way on this hard subset. Inspecting trajectories, Holo1.5-7B forms coherent intents and roughly-correct grounding, but a single-image history (max_images=1) makes it repeat a click when a slightly-off target leaves the screen unchanged, and it occasionally emits out-of-range coordinates. The whole-NPU arm additionally can't reach the resolution its grounding needs (NPU vision compiler ceiling ~262144 px). Neither limit is about prefill placement — so the offload question is moot for success, and the latency/energy verdict above stands on its own.

configsolvedsuccessnote
G0.3 whole-NPU0/100.00 solved — NPU vision compiler caps resolution at ~262144px, so grounding lands off-screen (clicks miss). Prior full-NPU run.
G0.1 whole-iGPU0/100.00 solved. Trajectories show coherent intent + roughly-correct grounding, but single-image history (max_images=1) causes repeated-click loops when a slightly-off click doesn't change the screen, plus occasional out-of-range coords. Success here is gated by grounding/agent-loop robustness, NOT prefill placement.

Domains covered: chrome, gimp, libreoffice_calc, libreoffice_impress, libreoffice_writer, multi_apps, os, thunderbird, vlc, vs_code.

Per-stage measurements

stagedevctxlat mspsys Jdram J dram fraceff GB/spJ/FLOP
vision_patchCPU27.921.20850.00720.0060.2
vision_patchiGPU1.790.04450.00140.0313.3
vision_mergerCPU2286.8194.05010.4920.005
vision_mergeriGPU268.48.05170.33260.0410.1
prefillCPU4723.48191.4681.57650.0081.538.8
prefilliGPU386.4413.10860.58990.04518.42.7
decodeCPU347127.724.00540.28050.0755.6280.5
decodeiGPU347123.13.16430.26850.08557.6221.6
vision_patchNPU52.080.78260.11760.150.1
vision_mergerNPU341.286.70370.38220.0570.1
prefillNPU785.8316.33080.91320.0569.03.3
decodeNPU3072269.03.11650.34660.11127.0202.7

Memory-wall verdict (per device)

{
 "CPU": {
  "prefill_eff_bw_gbs": 1.5,
  "prefill_pJ_flop": 38.8,
  "decode_eff_bw_gbs": 55.6,
  "decode_bw_util_%": 62.0,
  "decode_dram_frac": 0.07,
  "decode_pJ_flop": 280.5
 },
 "iGPU": {
  "prefill_eff_bw_gbs": 18.4,
  "prefill_pJ_flop": 2.7,
  "decode_eff_bw_gbs": 57.6,
  "decode_bw_util_%": 64.3,
  "decode_dram_frac": 0.085,
  "decode_pJ_flop": 221.6
 },
 "NPU": {
  "prefill_eff_bw_gbs": 9.0,
  "prefill_pJ_flop": 3.3,
  "decode_eff_bw_gbs": 27.0,
  "decode_bw_util_%": 30.1,
  "decode_dram_frac": 0.111,
  "decode_pJ_flop": 202.7
 }
}

Method & reproducibility

Model Hcompany/Holo1.5-7B → OpenVINO int8 stateless IR (162/162 LLM layers int8_sym). Energy via powercap RAPL (no root), reps-slope net-of-idle marginal per-infer energy. Serial, quiet machine. Code: experiments/holo15/ in the edge-ai repo (microbench.py · analyze.py · serve.py). Run folder is write-once.