Build up a vision-language-action model on a Qualcomm Dragonwing IQ-9075 EVK, one step at a time, until it generates robot motion on the board’s Hexagon NPUs. Everything here is self-contained — every command and every line of code you need is on this page, and each step ends in output you can check against. We use Pi0.5, a 3-billion-parameter robot foundation model: give it camera images and a plain-English instruction, and it outputs joint motion. The first NPU inference takes about ten minutes to reach. The last third of the page is the interesting part — why the four pieces of this model end up distributed across the hardware the way they do.
What you will end up with
Before you start
1. Install ROS 2 Jazzy and the Qualcomm AI runtime
Start with ROS 2. These are the commands from the Software Setup page:ppa:ubuntu-qcom-iot/qcom-ppa is already present on the stock IQ-9075 image, so you do not need to add it. Adding it a second time produces a fatal E: Conflicting values set for option Trusted. If you hit that, list /etc/apt/sources.list.d/ and delete the duplicate.2. Get Pi0.5 without an AI Hub account
A per-user cloud compile job in AI Hub is what you need to bring your own model to the NPU. For published models it is not required: Qualcomm precompiles QNN context binaries for a wide range of its chips and serves them through AI Hub Models, and anyone can download them without an account.qualcomm-qcs9075 — the IQ-9075 EVK — is Pi0.5’s default_device, so getting the model is a plain HTTPS download:
.bin files and a metadata file, 2.9 GB in total:
SDK build=v2.45.0.260326154327. It runs unmodified on QAIRT 2.46.0 from apt, so you do not need to match SDK versions exactly.3. Run your first piece of the model on the NPU
Before writing any code, prove the hardware works usingqnn-net-run, which ships with qairt-tools.
The vision encoder takes one RGB image, 224×224, channels-first, float32, normalized to [-1, 1]. Make one:
2097152 bytes — exactly 1 × 256 × 2048 float32 values.
4. Find out what you actually have to wire up
You now have four models and no idea how they connect. Do not guess, and do not trustmetadata.json’s key order — ask the binaries themselves. qnn-context-binary-utility dumps a context binary’s declared inputs and outputs, in the order the compiled graph expects them:
action_expert has 41 inputs):
action_expert’s input order again. It is l0, l1, l10, l11 … l17, l2, l3 … l9 — string-sorted, not numeric. The backbone that produces those caches emits them as l0, l1, l2 … l17. Wire them up in producer order and you silently scramble 12 of 18 layers. Nothing errors. The model runs. The actions are garbage.
This is why you generate the ordering from the binaries rather than typing it out.
How the pipeline fits together
One action chunk is 15 NPU invocations: three vision passes (one per camera slot), one token embedding, one backbone prefill, then ten denoising steps through the action expert. Constants worth knowing:token_emb emits prefix_emb but backbone calls it hidden_state, and prefix_att_2d becomes prefix_att_2d_masks. suffix_sin/suffix_cos arrive at the action expert as rope_emb_sin/rope_emb_cos.
Where the robot’s state goes
There is no state input tensor anywhere in those four graphs. Pi0.5 discretizes proprioception into 256 buckets and splices it into the language prompt. This is the format, reproduced from the upstream openpi implementation:google/paligemma-3b-pt-224 on Hugging Face is gated (HTTP 401 without an accepted licence), but the identical file is served unauthenticated from Google’s big_vision bucket — which is where openpi fetches it too:
8986bb4f423f07f8c7f70d0dbe3526fb2316056c17bae71b1ea975e77a168fc6.
Generate the token ids for a task, with a state vector folded in:
5. Run a model from your own code
qnn-net-run is a test harness; it reads files and writes files. To chain four models you need them in one process. The QRB ROS package that does this is qrb_inference_manager, and its entire API is three calls.
Install it, plus the ROS node that wraps it:
$WORK/minimal_npu.cpp. It is the complete program:
g++ line, no CMake:
6. Chain the four models
This is the part you write yourself, and it is mostly bookkeeping. The shape of it:- Everything except
x_tandtime_stepis constant across the ten denoise steps. Pack the ~34 MB of KV cache into the expert’s input buffer once, then rewrite only 6404 bytes per step. - The expert returns
x_{t+dt}directly. It applies the Euler update internally, so there is no host-sidex += dt * v.
inference_execute() enforces, and it will tell you if the total size is wrong:
7. Hit the wall: one NPU cannot hold Pi0.5
With all four contexts constructed in one process, you get this:8. Unlock the second NPU
Remember the two device nodes from step 1. The question is whether QNN will let you target them. Ask it directly — write$WORK/devices.cpp:
qrb_inference_manager calls deviceCreate(nullptr, nullptr, …), which always lands on device 0. Selecting a device means handing it a single-entry QnnDevice_PlatformInfo_t. Build the library from source and add it:
token_emb), multi-input tensors, and HTP burst mode, none of which are in the apt 1.1.1 release.
In qrb_ros_nn_inference/qrb_inference_manager/src/qnn_inference/qnn_inference.cpp, find create_device() and replace the deviceCreate call with this:
const uint32_t device_id_ = 0; member and a device_id constructor parameter on both QnnInference and QrbInferenceManager, then build:
Why we split things the way we did
With both NPUs available there are several ways to distribute four models. The obvious one is to balance by size — put roughly 1.4 GB on each device. That is not the best answer.Placement of Pi0.5's four components across the IQ-9075's two Hexagon NPUs, drawn to measured latency. One NPU pays 2258 ms of weight paging (3277 ms total). Two NPUs keep everything resident (1323 ms). Re-placing the expensive stages onto the faster NPU — same bytes per device — reaches 1111 ms.
vision_encoder feeds token_emb, which feeds backbone, which feeds action_expert. Nothing runs concurrently. Spreading the model across two NPUs does not parallelise anything — it just means every component can stay mapped, so the chain stops paying to swap weights in and out. That is where the first and largest win comes from.
Start from what the components cost, measured per action chunk:
backbone and action_expert together are ~888 ms of the ~1030 ms of compute — so they belong on the fast device, and the two cheap components go on the slow one. Balance by cost, not by size:
How that compares to AI Hub’s published figures
AI Hub profiles each component in isolation. We run the whole chain with host-side marshalling between stages, so ours are necessarily higher. This is a like-for-unlike comparison, shown to locate the overhead rather than to claim a win:backbone alone emits 36 KV tensors totalling ~34 MB that have to be read out and repacked into the expert’s input buffer. That is the obvious next target, and qrb_ros_transport DMA-buf fd passing is the mechanism — qrb_inference_manager 2.x already exposes an inference_execute_dmabuf() entry point for it.
9. Prove it is numerically correct
Fast and wrong is easy to build here — a scrambled KV cache produces confident, plausible, useless actions. So check against the reference implementation you already used in step 3. Dump each stage’s exact flat input buffer and its outputs from your chained pipeline, split the input back into per-tensor files using the graph order from step 4, replay them throughqnn-net-run, and diff:
ref/Result_0/<name>.raw against your pipeline’s output for each tensor. Ours match bitwise — all 45 output tensors across all four components, and they still match with components pinned to different NPUs:
libQnnHtp.so; the outputs are bitwise identical to qnn-net-run on the HTP backend; and context creation drops to zero once everything is resident.
Wrapping it in a ROS 2 node
Once the chain works, the ROS layer is unremarkable, which is the point: Two decisions worth copying. Inference takes ~1.1 s, far too long for an executor callback, so run it on a dedicated worker thread. And drop frames that arrive mid-inference rather than queueing them — a VLA acting on stale observations is worse than one acting at a lower rate. Publish the per-stage latency breakdown alongside every chunk. It costs nothing and it means no performance claim you make later is detached from a live measurement.Is the output any good?
Latency and bitwise fidelity prove the pipeline is correct. They say nothing about whether the actions are useful. To check that without a robot, replay a real episode from the LIBERO dataset — the data Pi0.5 was calibrated on — feeding the NPU exactly the observations a human demonstrator saw, and compare the predicted chunk against what they actually did. Both state and action use mean/std normalization, and the statistics ship with the dataset inmeta/stats.json. Scoring 15 replanning steps over a 20-step horizon:
MAE / action std is the scale-free number: 0.145 means the error is about 15% of the natural spread of actions in this dataset. Predicting the dataset mean scores 1.0 by construction, so the model is genuinely tracking the demonstrator. The gripper — effectively binary at ±1, and the one dimension where being wrong is unambiguous — matches to 0.022.
Can it actually finish the task?
The section above has a hole in it: the human’s actions decided what happened next, so the policy was never held responsible for its own. A subtly wrong policy and a good one score about the same, because neither ever compounds its mistakes. Closing the loop removes the human. The simulator renders what the arm can see, the policy decides, the simulator executes that decision, and the task’s own goal predicate says whether it worked. And it turns out this runs entirely on the board — which is not what we expected, having just established that Gazebo cannot render here. The difference is one word in a requirement:Rendering is the expensive half, so only render when you replan
A two-camera 256×256 observation costs 340 ms on that software rasterizer. A physics step costs 31 ms. The NPU produces fifty actions in 1126 ms. Render every sim step and the numbers inseparably invert — the simulator costs 3.2× the 3B VLA:NPU chunk production against simulator step consumption, drawn to measured cost. One chunk buys 50 actions for 1126 ms; rendering every sim step makes the simulator 3.2× the policy, while rendering only at replan boundaries collapses it to 18.7 s and puts the NPU back in charge. The replan horizon is the dial: H=1 costs 426 s per episode, H=50 costs 18 s.
--replan-horizon 10 is a working choice rather than a tuned one.
Four conventions that silently destroy the result
None of these throw. Get one wrong and the loop still runs, still renders, and still reports a rate — of zero. Each was settled by comparing against the recorded dataset rather than by reading documentation:The result
All ten LIBERO-10 tasks — the long-horizon suite — ten initial states each:One closed-loop episode: 'pick up the book and place it in the back compartment of the caddy', solved in 252 sim steps and 26 chunks. The strip under the video is the 50-action chunk — shaded cells are the ten that will be executed before the next replan, the amber cell is the action being applied right now, and the outlined remainder is discarded. Per-stage NPU latency and the loaded backend are burned into every frame.
backend libQnnHtp.so sits on every frame, so proof that the NPU ran travels with the footage instead of being asserted next to it. The action strip is there because it makes the discard visible — forty of every fifty predicted actions are thrown away at the next replan, which looks wasteful until you notice it is what lets the loop run at all.
Recording that video is not free: extra frames cost 340 ms a pair, so the harness prints a warning that a run with --save-frames is not a timing measurement. The video is composed by a separate offline pass over saved frames, so the compositing never lands inside a measured episode.
Two details matter more than the headline:
Every one of the fourteen failures ran to exactly 520 steps. None diverged, thrashed, or produced nonsense — they ran out of step budget mid-task. The failure mode is “too slow for the cap”, not “wrong”, which makes the cap part of the result rather than an incidental setting.
A null policy scores 0 out of 24 on the same harness. Zeros and uniform-random actions both fail every task, and the goal predicate is never already satisfied at reset. Without that check, a high success rate could just as easily have been measuring a broken predicate — and it would have looked identical from the outside.
Honest limitations
- The task success rate is on a reduced protocol. A full LIBERO evaluation is 4 suites × 10 tasks × 50 episodes, which is not affordable on one board. Any rate here states its episode count and confidence interval and is never presented as a suite-level benchmark figure.
- Gazebo will not render on this board, but MuJoCo will.
gz-harmonicinstalls fine on arm64, but Ogre2 requires desktop OpenGL 3.3 core and the Adreno driver here exposes only OpenGL ES; Ogre v1 aborts and forcing Mesa llvmpipe segfaults insideOgre2RenderEngine::LoadImpl. LIBERO’s MuJoCo renderer is satisfied by the OpenGL 4.5 compatibility profile that same llvmpipe advertises, which is why the closed-loop demo runs entirely on the board with no laptop involved. Rendering is still software: there is no hardware path, because/dev/dri/renderD128is the display controller rather than the GPU, so Mesa’sfreedrenocannot bind it, andzinkis refused by both Vulkan ICDs. - Action semantics are embodiment-specific. The published export targets LIBERO’s 7-DoF Franka Panda. Its output is not valid joint commands for a different arm.
- Normalization statistics are external. Raw joint values will produce meaningless prompts; you need the statistics the policy was trained with.
- No power or thermal measurements, and no sustained-load soak test. All figures come from short runs on a thermally unstressed board.
- Never measured with a live camera. Inputs were synthetic or replayed from disk.
- The NPUs are exclusive. Two processes each mapping the full bundle contend for the same CDSP budget and both slow down. Measure with nothing else on the NPU.
- The
~1600 MiBper-device ceiling we work to is empirical and conservative, not a documented limit.
What this says about the platform
100 TOPS makes a 3B vision-language-action model plausible. What the exercise showed is that it is also practical — at 4.3–4.5× real time — but only if you treat the two NPUs as a resource to schedule rather than as one accelerator. The gap between the naive path and the informed one is 4×, and the naive path also silently swallows a fatal mapping error. The remaining headroom is on the CPU, not the NPU: 223 ms per chunk of host-side tensor marshalling that zero-copy DMA-buf should largely remove.Related
- Depth Estimation on the NPU — the single-model version of this pattern, with every wire exposed.
qrb_ros_nn_inference— for a normal one-input, one-output model, this generic node removes even the three API calls.qrb_ros_transport— DMA-buf fd passing, the fix for the host-marshalling overhead.- Context binaries — what a
.binis and how it is produced. - Software Setup — the ROS 2 Jazzy install this page starts from.

