What running vla.cpp on Qualcomm's Hexagon NPU takes, and what it turned out
to take once it ran.
Status: running on Windows on Arm, not yet on a Linux board. vla.cpp builds with
GGML_HEXAGON=ONand runs eleven checkpoints on a Snapdragon X laptop's v73 NPU, each checked against a CPU reference. The build, the measurements and the upstream bugs found along the way are in hexagon-windows.md. What follows is the backend in general: the parts that hold on any Snapdragon part, and the Linux / IQ-9 notes from before anything ran, which are still unverified.The earlier version of this file was written without hardware and projected 0.8-2.0 s per SmolVLA action chunk on a v79 part. On a v73 part it measured 1.23 s, inside the band. Its op-gap prediction was half right:
GELU_ERFis still missing, andRELUhas landed upstream. What it did not foresee was ops the NPU claims and then computes wrongly.
Two commits, both inside the pinned b11223 (4da63377, 2026-09-27):
| Commit | Date | What it actually did |
|---|---|---|
d81e63dc CI : support IOT device (IQ9) (#22987) |
2026-05-14 | Added a third device to the Snapdragon CI matrix: QCS9075M, alongside the SM8750 / SM8850 phone parts. It is the only entry that is Linux, not Android - a separate build job (linux-iot-snapdragon, with -DGGML_OPENCL=ON on top of the preset), a BASH test framework instead of Appium/pytest, and --retries 2 --retry-delay 300 because the IoT device in Qualcomm's Device Cloud is scarce. |
192067b7 hexagon: support for multi-NPU devices (IQ9, IQ10) and fully asynchronous backend (#26501) |
2026-08-26 | 5,314 insertions over 44 files, by Qualcomm. Physical vs virtual NPU sessions, an ALLREDUCE HTP kernel and sync tokens so a tensor split can span NPUs, a fully async backend with events, GET_ROWS/SET_ROWS, Q8_0 flash attention, op fusion, and the build.py / run.py scripts that replaced the old adb/*.sh and windows/*.ps1 wrappers. |
What the names do not tell you: neither IQ9 nor IQ10 appears anywhere in
the source, the docs, or the CI config as an identifier. The only board named in
code is QCS9075M, which places IQ-9 as the Dragonwing-class industrial/robotics
part rather than a phone SoC. IQ-10 exists in that commit title and nowhere else.
So "IQ9/IQ10" is upstream shorthand; what is real is the capability the commit
added, which is what the rest of this document reads against.
The generational split visible in the code, rather than in the marketing:
- One physical NPU, N virtual sessions (
HTP0:0,HTP0:1, ...) - the IQ-9 shape, and what every phone part does today. - N physical NPUs (
HTP0:0,HTP1:0) - discovered viaFASTRPC_GET_DOMAINS, with a static fallback that hardcodes physical 0 → CDSP domain 3 (cdsp) and physical 1 → domain 4 (cdsp1). Ask for a third physical core without dynamic discovery and the backend refuses by name: "physical CDSP core %d not supported without dynamic discovery". Two is what the fallback knows about; the discovery path is open-ended.
- Two libraries.
libggml-hexagon.soon the CPU side,libggml-htp-vNN.soon the NPU side. Skels are built for v73, v75, v79 and v81 and the right one is picked at runtime fromhtpdrv_get_arch; a failed query falls back to v73 and older parts are capped to it.GGML_HEXAGON_ARCHoverrides. - Sessions are devices. Each Hexagon process domain shows up to ggml as one
device and behaves like a GPU for offload and model splitting.
GGML_HEXAGON_DEVICEStakes either a count or an explicitHTP<physical>:<virtual>list.GGML_HEXAGON_NDEVis the deprecated spelling. - ~3.5 GB per session. The backend now maps and unmaps execution buffers during graph execution to fit larger models into one session, and layer- or tensor-splitting across sessions is the alternative.
- Repack buffers. Q4_0, Q4_1, Q8_0, IQ4_NL, MXFP4, Q4_K, Q5_K and Q6_K
weights are repacked into non-host buffers; since #26501 non-host is the
default and
GGML_HEXAGON_HOSTBUF=1is the opt-out (needed to exerciseMUL_MATintest-backend-ops). - VTCM is the real budget.
supports_opprecomputes kernel params forMUL_MAT,FLASH_ATTN_EXTand friends and returns false when the tile does not fit VTCM. An op is not rejected by shape rules so much as by whether it fits - which means coverage is a function of your tensor sizes, and has to be measured on the board, not predicted from a table. - Fusion, controlled by
GGML_HEXAGON_OPFUSION:RMS_NORM+MUL,MUL_MAT+ADD, N-wayMUL_MATandMUL_MAT_ID,ALLREDUCE+ADD,GATED_DELTA_NET+CPY.
The knobs worth knowing on day one:
| Variable | Use |
|---|---|
GGML_HEXAGON_DEVICES=HTP0:0,HTP1:0 |
which NPUs/sessions to open |
GGML_HEXAGON_VERBOSE=1 |
log every op the NPU accepted, with dtypes and buffers |
GGML_HEXAGON_OPFILTER=<regex> |
force matching ops off the NPU - the bisection tool |
GGML_HEXAGON_PROFILE=1|2 |
per-op usecs/cycles (+PMU), pipe into scripts/snapdragon/ggml-hexagon-profile.py - |
GGML_HEXAGON_OPTRACE |
fine-grained HVX/HMX/DMA event trace |
GGML_HEXAGON_OPPOLL=1 |
poll for op completion instead of sleeping - matters for many small graphs |
GGML_HEXAGON_HOSTBUF=1 |
disable repack buffers (op testing) |
The previous version read supports_op at b10729 and found two gaps, RELU
and GELU_ERF, and warned that in this engine an absent op is fatal. Running it
changed all three conclusions:
RELUlanded upstream, and the local checkout used here (build 11201) has it.GELU_ERFis still missing. It now runs on the CPU (next point) in every DINOv2 and SigLIP-so400m tower, and costs what that implies: Evo-1 spends more on copies than the NPU saves.- Absent is no longer fatal.
src/backend_fallback.cppwraps the NPU in a backend that runs whatever it rejects on the CPU, without touching an arch. That also covers ops refused for shape rather than type: softmax rows that are not a multiple of 32,GROUP_NORM,SINandCOS.
The larger finding was not a missing op but wrong ones. supports_op claims
these, and they compute something else:
| Op | What goes wrong | Handled by |
|---|---|---|
GELU |
Runs x*sigmoid(1.702x) (GELU_QUICK) |
vla::gelu() builds the tanh form from correct ops |
ADD/MUL/... |
src1 broadcast over one dim but not a higher one |
Refused, runs on the CPU |
IM2COL |
Wrong unless kernel = stride > 1 and no padding | Refused, runs on the CPU |
CPY F32→F16 |
Reads back wrong on the host | Copies feeding a CPU op run on the CPU |
fused MUL_MAT+ADD, RMS_NORM+MUL |
~10x the error of the unfused ops | GGML_HEXAGON_OPFUSION=0 by default |
Each one flips the gripper channel, or comes close, on some checkpoint. The earlier warning that a wrong op would be the worse failure than a missing one was right: a missing op fails at load, and these only show in the actions. hexagon-windows.md has the numbers and how each was bisected. They belong upstream.
The shape of the diff was predicted correctly, apart from the scheduler:
- Build flags.
GGML_HEXAGON(andGGML_OPENCL) joined the accelerator list and defineGGML_USE_HEXAGON/GGML_USE_OPENCL. - A rung in src/backend.h.
- It goes through the registry, because
ggml_backend_hexagon_initis declared but not defined upstream. VLA_DEVICEindexes HTP sessions, andVLA_DEVICE=cpugives the reference.- It sets the defaults above and points
ADSP_LIBRARY_PATHat the executable's folder.
- It goes through the registry, because
- Weights.
- The NPU takes no BF16. Both accelerator builds default to F16 resident weights, which hold BF16 exactly.
- Q8_0 works, but moves actions by 1e-2 or more on most checkpoints, and does so on the CPU too.
- SmolVLA's loader learned to keep packed weights.
- Serving dependencies are optional (
VLA_BUILD_SERVER), and on Windows come from vcpkg. - No scheduler.
- The fallback wraps one backend instead, so the ~84 gallocr/compute sites across 13 archs stay as they were.
- CUDA, Metal, SYCL and OpenVINO do not compile it.
- K/V for flash attention are cast to F16 on Hexagon only (
vla::fa_kv), because the NPU kernel takes nothing else.
In order. Each step is cheap, and steps 4 and 5 are where every bug above was found.
- Confirm the arch and session lines.
- Expect
ggml-hex: Hexagon Arch version v73(or v79/v81) andallocating new session. - A v73 line on a part you believe is newer means
htpdrv_get_archfailed, and you are on the fallback skel.
- Expect
llama-benchon a small model, to prove the driver, signing andADSP_LIBRARY_PATHbefore any VLA is involved.- Run a VLA and read the fallback lines. Each process prints which ops went
to the CPU and how much was copied (
VLA_FALLBACK_STATS=1for per-graph detail). - Fidelity before latency.
- Compare
vla_predict_checkoutput against the same binary withVLA_DEVICE=cpu, and against--weight-dtype f32. - The bar is 2.9e-3.
- A run with a wrong kernel still prints a latency, and it is usually a good one.
- Compare
- Bisect anything off the bar with
GGML_HEXAGON_OPFILTER=<regex>, which moves matching ops to the CPU fallback. Families first, then single ops.GGML_HEXAGON_OPFILTER=.*must reproduce the CPU run exactly; if it does not, the fallback is at fault, not the NPU. - Then profile. Use
GGML_HEXAGON_PROFILE=1 ... |& scripts/snapdragon/ggml-hexagon-profile.py -.
The upstream path, unchanged, and the thing to get working first - if
llama-bench will not run on the device, nothing downstream matters. Requires
only Docker on the host; build.py pulls
ghcr.io/snapdragon-toolchain/arm64-linux:v0.7 itself.
# cross-compile and deploy over SSH in one step
./scripts/snapdragon/build.py --target lnx:user@host --push
# or by hand, inside the container
docker run -it --rm -u $(id -u):$(id -g) --volume $(pwd):/workspace \
--platform linux/amd64 ghcr.io/snapdragon-toolchain/arm64-linux:v0.7
[d]/workspace> cp docs/backend/snapdragon/CMakeUserPresets.json .
[d]/workspace> cmake --preset arm64-linux-snapdragon-release -B build-snapdragon
[d]/workspace> cmake --build build-snapdragon -j $(nproc)
[d]/workspace> cmake --install build-snapdragon --prefix pkg-linuxOn the device, both library paths must be set - ADSP_LIBRARY_PATH is how the
FastRPC loader finds the HTP skel:
export LD_LIBRARY_PATH=./lib
export ADSP_LIBRARY_PATH=./lib
./bin/llama-cli -m Llama-3.2-3B-Instruct-Q4_0.gguf --device HTP0 -ngl 99 -p "..."run.py maps flags to those environment variables and can drive the device over
SSH or ADB from the host:
./scripts/snapdragon/run.py --target lnx:user@host --devices HTP0 -- \
llama-bench -m Llama-3.2-1B-Instruct-Q4_0.gguf -p 128 -n 64
# multi-NPU tensor split - the IQ-10 shape
./scripts/snapdragon/run.py --target lnx:user@host --devices HTP0:0,HTP1:0 -- \
llama-completion -m gemma-2b-it-Q4_0.gguf -f prompt.txt --split-mode tensorNothing about a second-generation part is knowable from this repository, so what
follows is a bet, not a forecast. The code in #26501 tells you what Qualcomm
built for: two or more physical NPUs, an ALLREDUCE kernel with a DMA solver
and fused ALLREDUCE+ADD, sync tokens to order work across devices, and a static
domain table that stops at two.
If a dual-NPU IQ-10 lands, the obvious use - --split-mode tensor across
HTP0:0,HTP1:0, paying an allreduce per layer - is the worse fit for a VLA.
An action chunk has three stages that already hand off through host memory: the
vision tower, the VLM prefix, and the action expert. Putting the tower on one NPU
and the expert on the other pipelines across timesteps with one handoff per
stage instead of one collective per layer, and a policy server at 10 Hz has a
steady stream of chunks to pipeline.
vla.cpp cannot do that today, for the same reason it cannot split across an
Intel iGPU and NPU (ov.md reaches this conclusion from the other
direction): the core drives one backend for a whole prediction. (The CPU fallback
splits a graph by op, not by stage, so it does not change this.) It would need a
per-stage backend, not a per-op scheduler. The seam is already in the right
place. That is the one engine change this whole document argues for, and it pays
off on more than Hexagon.
The previous version listed four ways it could be wrong. Here is how each came out:
- "
RELUandGELU_ERFare a weekend each."RELUcame from upstream;GELU_ERFhas not. The CPU fallback made both moot for correctness. - "
IM2COL's partial support might not cover our patch shapes." It covers SigLIP's 16x16/16 patch embed, and SmolVLA's vision tower runs 4x faster than on the CPU. It gets every other geometry wrong while claiming it. - "Q8_0 might move an arch past 2.9e-3." It does, for most archs and on the CPU as much as the NPU, so F16 is the default.
- "VTCM might reject our GEMM shapes." Nothing was rejected for VTCM. The NPU loses where the fallback copies dominate (GELU_ERF towers, 1025-wide softmaxes), and on SigLIP-so400m, whose tower is slower on the NPU than on the CPU with nothing falling back.