Skip to content

Commit 2cc9513

Browse files
committed
key rope position inputs per tensor and cache the naive path so smolvla and pi05 run on openvino
1 parent 3ab16d3 commit 2cc9513

4 files changed

Lines changed: 448 additions & 82 deletions

File tree

README.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ A C++ inference engine for **Vision-Language-Action (VLA) models**, built on [`l
1212
It runs the open VLA policies - SmolVLA, π0, BitVLA, Evo-1, GR00T N1.5/1.6/1.7 and more -
1313
under one runtime, each packaged as a single self-contained GGUF that needs no Python or
1414
PyTorch at inference time. The binaries drive robots on **CPU**, **Apple Silicon**, **CUDA** -
15-
from consumer GPUs down to Jetson-class boards - or **Intel GPUs** via SYCL.
15+
from consumer GPUs down to Jetson-class boards - or **Intel GPUs and NPUs** via
16+
SYCL and OpenVINO.
1617

1718
[**Learn vla.cpp**](https://fai-modelopt-tech.github.io/learn-vla-cpp/) walks through the engine design and how each policy is implemented on ggml.
1819

@@ -27,8 +28,8 @@ from consumer GPUs down to Jetson-class boards - or **Intel GPUs** via SYCL.
2728
- CUDA 12.x (optional - required only for CUDA GPU builds)
2829
- Intel oneAPI 2025.x + GPU compute runtime (optional - only for Intel GPU
2930
builds, see [docs/backend/sycl.md](docs/backend/sycl.md))
30-
- OpenVINO 2026.x runtime (optional - only for the in-progress OpenVINO backend,
31-
see [docs/backend/ov.md](docs/backend/ov.md))
31+
- OpenVINO 2026.x runtime (optional - only for Intel CPU/GPU/NPU builds via
32+
OpenVINO, see [docs/backend/ov.md](docs/backend/ov.md))
3233
- `libzmq3-dev`, `cppzmq-dev`, `libprotobuf-dev`, `protobuf-compiler`
3334

3435
```bash
@@ -303,15 +304,18 @@ change is shown to leave it alone.
303304
Support matrix of models (rows) against platforms (columns). Legend: `Y` =
304305
supported (released and benchmarked), `~` = in progress, `-` = planned.
305306

306-
OpenVINO builds and selects its backend today, but no arch completes a
307-
prediction yet - the remaining blocker is upstream in ggml's OpenVINO backend,
308-
written up in [docs/backend/ov.md](docs/backend/ov.md).
307+
OpenVINO covers SmolVLA and π0.5 on Intel CPUs, GPUs and NPUs; on an Arc B390
308+
iGPU it is 2.9x and 4.4x the native CPU backend, and the NPU beats the CPU
309+
backend on both. The other archs are blocked on ops ggml's OpenVINO backend has
310+
no translator for. Read the known issues in
311+
[docs/backend/ov.md](docs/backend/ov.md) before running it - in particular, do
312+
not set `GGML_OPENVINO_CACHE_DIR`.
309313

310314
| Model | CPU (x86-64 / ARM) | CUDA | SYCL (Intel) | Metal | OpenVINO |
311315
|---|:--:|:--:|:--:|:--:|:--:|
312-
| [SmolVLA](https://hf.co/vrfai/smolvla-libero-gguf) | Y | Y | Y | Y | - |
313-
| [π0](https://hf.co/vrfai/pi0-libero-finetuned-v044-gguf) | Y | Y | - | Y | - |
314-
| [π0.5](https://hf.co/vrfai/pi05-libero-gguf) | Y | Y | - | ~ | - |
316+
| [SmolVLA](https://hf.co/vrfai/smolvla-libero-gguf) | Y | Y | Y | Y | Y |
317+
| [π0](https://hf.co/vrfai/pi0-libero-finetuned-v044-gguf) | Y | Y | - | Y | ~ |
318+
| [π0.5](https://hf.co/vrfai/pi05-libero-gguf) | Y | Y | - | ~ | Y |
315319
| [GR00T N1.5](https://hf.co/vrfai/gr00tn1d5-libero-object-gguf) | Y | Y | - | ~ | - |
316320
| [GR00T N1.6](https://hf.co/vrfai/gr00tn1d6-libero-gguf) | Y | Y | - | ~ | - |
317321
| [GR00T N1.7](https://hf.co/vrfai/gr00tn1d7-libero-gguf) | Y | Y | - | Y | - |

docs/backend/ov.md

Lines changed: 164 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,17 @@ account of how far it currently runs. Like SYCL, OpenVINO is **not**
55
auto-detected: it needs an explicit `-DGGML_OPENVINO=ON` and the OpenVINO
66
runtime on the configure line.
77

8-
> **Status: builds and runs, no arch completes a prediction yet.** The backend
9-
> comes up, weights fold in, and the vision towers translate and execute - on
10-
> the CPU, GPU and NPU plugins alike. The language model and action
11-
> expert do not: ggml's OpenVINO backend models a decoder-only LLM with one
12-
> position input and an F16 KV cache, and every vla.cpp arch has several
13-
> position inputs and no KV cache. See
14-
> [What still blocks it](#what-still-blocks-it). The OpenVINO column of the
15-
> README support matrix stays `-` until an arch passes end to end.
16-
17-
Checked on an **Intel Core Ultra X7 358H** (Panther Lake) with the Arc B390
8+
> **Status: SmolVLA and π0.5 run end to end on CPU, GPU and NPU.** The Arc iGPU
9+
> is 2.9x faster than the native CPU backend on SmolVLA and 4.4x on π0.5. Six fixes
10+
> were needed, four of them inside ggml's OpenVINO backend, which is written
11+
> against llama.cpp's graphs and had never seen a vision tower or an action
12+
> expert - see [What had to change](#what-had-to-change). The other archs are
13+
> blocked on ops the backend has no translator for, listed under
14+
> [What is left](#what-is-left).
15+
16+
Measured on an **Intel Core Ultra X7 358H** (Panther Lake) with the Arc B390
1817
iGPU and the AI Boost NPU, Ubuntu 24.04, OpenVINO 2026.2.1, against
19-
`vrfai/smolvla-libero-gguf` and `vrfai/pi05-libero-gguf`. All three devices
20-
behave the same, including the NPU: the naive graph path this build selects is
21-
device-independent, so the NPU never reaches the static prefill/decode path
22-
where it would differ.
18+
`vrfai/smolvla-libero-gguf` and `vrfai/pi05-libero-gguf`.
2319

2420
OpenVINO is Intel's inference toolkit; ggml's backend translates a ggml compute
2521
graph into an OpenVINO model and hands it to the CPU, GPU or NPU plugin, which
@@ -127,9 +123,9 @@ cmake --build build-ov -j$(nproc)
127123
binaries: `libopenvino.so` and its TBB live under `/opt/intel`. Configure fails
128124
early with a pointer back here if the runtime is not on `CMAKE_PREFIX_PATH`.
129125

130-
`scripts/patch_ggml_openvino.py` runs as the FetchContent patch step, so the
131-
four ggml fixes described in its docstring are applied automatically and
132-
re-applied on a clean reconfigure. There is no manual `git apply`.
126+
`scripts/patch_ggml_openvino.py` runs as the FetchContent patch step, so the six
127+
ggml fixes described in its docstring are applied automatically and re-applied on
128+
a clean reconfigure. There is no manual `git apply`.
133129

134130
## Run
135131

@@ -154,14 +150,54 @@ pair tells you whether you got the device you asked for. `VLA_DEVICE` does *not*
154150
apply - ggml exposes OpenVINO as a single device and the target is chosen by
155151
name.
156152

157-
OpenVINO compiles each graph on first use, which is slow (minutes for a vision
158-
tower). Set `GGML_OPENVINO_CACHE_DIR=<dir>` to keep compiled graphs across
159-
restarts, and give any client a receive timeout well above the first request.
153+
OpenVINO compiles each graph on first use, which is slow - a minute or two for a
154+
vision tower on the GPU. Compiled graphs are then cached in-process for the life
155+
of the model, so only the first prediction pays that; give any client a receive
156+
timeout well above the first request.
160157

161-
## What vla.cpp had to change
158+
Do **not** set `GGML_OPENVINO_CACHE_DIR` to carry them across restarts. It
159+
produces silently wrong actions here - see
160+
[Known issues](#known-issues). The backend warns at startup if it is set.
162161

163-
Three of these are ordinary correctness fixes that happen to be invisible on the
164-
other backends:
162+
## Results
163+
164+
`vla_predict_check` (a test target - add `-DVLA_BUILD_TESTS=ON`), fixed noise, one
165+
camera view, best of 6 iterations (4 for π0.5) after 3 warmups. "CPU backend" is
166+
ggml's own CPU backend on the same 16-core host; the other columns are this build
167+
with `GGML_OPENVINO_DEVICE` set. No `GGML_OPENVINO_CACHE_DIR`, for the reason in
168+
[Known issues](#known-issues).
169+
170+
| Model | CPU backend | OpenVINO CPU | OpenVINO GPU | OpenVINO NPU |
171+
|---|---:|---:|---:|---:|
172+
| SmolVLA (512px) | 1,312 ms | 1,357 ms | **448 ms** (2.9x) | 1,107 ms (1.2x) |
173+
| π0.5 (224px) | 2,775 ms | 4,278 ms | **633 ms** (4.4x) | 931 ms (3.0x) |
174+
175+
The iGPU is the reason to use this backend. The OpenVINO CPU plugin is at best
176+
parity with ggml's own CPU backend and on π0.5 well behind it, so it is only
177+
worth running to debug a translation. The NPU beats the CPU backend on both
178+
models while drawing far less power, which is the interesting result for a robot.
179+
180+
Checked against the CPU backend on the same inputs:
181+
182+
| Run | max abs deviation | RMS | peak action |
183+
|---|---:|---:|---:|
184+
| SmolVLA, OpenVINO CPU | 1.2e-3 | 1.7e-4 | 0.995 |
185+
| SmolVLA, OpenVINO GPU | 1.2e-3 | 1.9e-4 | 0.995 |
186+
| SmolVLA, OpenVINO NPU | 1.6e-2 | 2.1e-3 | 0.995 |
187+
| π0.5, OpenVINO CPU | 8.9e-4 | 8.7e-5 | 0.904 |
188+
| π0.5, OpenVINO GPU | 6.9e-4 | 1.1e-4 | 0.904 |
189+
| π0.5, OpenVINO NPU | 1.6e-3 | 1.9e-4 | 0.904 |
190+
191+
CPU and GPU sit in the same band as the SYCL backend's numbers - kernel rounding,
192+
plus the F16 K/V conversion the SDPA fix introduces. SmolVLA on the NPU is an
193+
order of magnitude looser because the NPU compile config turns on dynamic
194+
quantization; π0.5 is not, so treat SmolVLA's NPU deviation as a property of that
195+
model on that device rather than of the backend.
196+
197+
## What had to change
198+
199+
Two of these are ordinary correctness fixes on the vla.cpp side that happen to be
200+
invisible on the other backends:
165201

166202
- **Weight buffers are tagged.** `ggml_backend_alloc_ctx_tensors` leaves a
167203
buffer on `GGML_BACKEND_BUFFER_USAGE_ANY`, and ggml-openvino reads ANY as "KV
@@ -176,49 +212,120 @@ other backends:
176212
duplicates silently collapse into one node and the graph wires up the wrong
177213
tensor. `vla::graph_unique_names` relabels duplicates before compute. It
178214
compiles to nothing outside an OpenVINO build.
215+
216+
One is a judgement call about what a weight is:
217+
179218
- **SmolVLA's time tiles moved out of the weight buffer.** They are precomputed
180219
once but they are graph inputs, not checkpoint parameters. As weights they
181220
became 2-D constants that could not be concatenated with the 4-D activation
182221
beside them.
183-
- **`GGML_OPENVINO_NAIVE_GRAPH_SIZE` defaults high.** ggml-openvino translates a
184-
graph under 20 nodes literally and sends anything larger through an LLM model
185-
builder. The literal path is the one that fits a vision tower; the threshold is
186-
raised in `backend_init`, and an explicit setting still wins.
187222

188-
None of it changes what the other backends compute: `vla_predict_check` on a CPU
189-
build of this branch is byte-identical to the same build of `main` for SmolVLA
190-
and π0.5, apart from the `weight_buf` line, which drops by the size of the time
191-
tiles that moved.
223+
And `backend_init` sets one default, the way the SYCL rung already sets
224+
`GGML_SYCL_ENABLE_VMM=0`:
192225

193-
## What still blocks it
194-
195-
With the above in place, SmolVLA's SigLIP tower translates and runs, and the
196-
prefix/expert graph reaches OpenVINO's shape inference before failing:
226+
- **`GGML_OPENVINO_NAIVE_GRAPH_SIZE` defaults high.** ggml-openvino translates a
227+
graph under 20 nodes literally and sends anything larger through a model
228+
builder that infers a decoder-only LLM. The literal path is the one that fits a
229+
vision tower and an action expert; the threshold is raised in `backend_init`,
230+
and an explicit setting still wins.
231+
232+
The remaining four are in ggml's OpenVINO backend itself, applied by
233+
`scripts/patch_ggml_openvino.py` at configure time. Its docstring carries the
234+
detail; in short they narrow an llama.cpp-shaped assumption that is stricter than
235+
the ggml contract:
236+
237+
| Fix | Assumption it relaxes |
238+
|---|---|
239+
| Intel OpenCL platform selection | the first OpenCL platform is Intel's |
240+
| RESHAPE `op_case` guard | a reshape flattening dims 0-2 is the KV-cache flatten |
241+
| SDPA K/V converted with Q | K/V arrive as F16 because the KV cache is |
242+
| **Position inputs keyed per tensor** | **a graph has exactly one position input** |
243+
244+
The last one is what carries an arch through to a full prediction, and it is the
245+
one worth upstreaming. Every tensor feeding a `GGML_OP_ROPE`'s second input was
246+
renamed to a single parameter called `inp_pos`, and a shared sin/cos table was
247+
built from it. SmolVLA passes three position tensors - prefill, full and rebased -
248+
so they aliased each other and every RoPE took the table built from whichever
249+
won:
197250

198251
```text
199252
opset1::Multiply (Split[1]:f32[1,113,5,32], Multiply[0]:f32[1,50,1,32])
200253
Argument shapes are inconsistent.
201254
```
202255

203-
The two operands are RoPE tables of different lengths. `GgmlOvDecoder` maps
204-
*every* tensor feeding a `GGML_OP_ROPE`'s second input to one graph parameter
205-
named `inp_pos`, because an llama.cpp graph has exactly one position input. Every
206-
vla.cpp arch has several - SmolVLA alone passes a prefill, a full and a rebased
207-
position tensor - and they collapse onto each other.
208-
209-
π0.5 fails on the same node with the same message (`[1,50,1,128]` against
210-
`[1,262,1,128]`), so this is the shared blocker rather than a SmolVLA quirk.
211-
212-
That is not something vla.cpp can work around from the outside: the fix belongs
213-
in ggml-openvino, which needs to key position inputs per tensor rather than by a
214-
fixed name. The same class of assumption shows up in the KV-cache-shaped dynamic
215-
sequence dimension and in the `compute_op_case` pattern tables, two of which
216-
already needed narrowing (see `scripts/patch_ggml_openvino.py`).
217-
218-
Separately, several archs use ops the backend has no translator for at all -
219-
`GGML_UNARY_OP_RELU` (every GR00T, Evo-1, VLA-Adapter, OpenVLA-OFT, BitVLA,
220-
VLA-JEPA), `GGML_UNARY_OP_GELU_ERF`, `GGML_OP_NEG`, `GGML_OP_SQR` - and the core
221-
drives a single backend through `gallocr` rather than a scheduler, so there is no
222-
per-op CPU fallback to absorb them. SmolVLA, π0 and π0.5 are the three archs
223-
whose op sets are fully covered today, which is why SmolVLA is the one to retest
224-
first when the position-input handling lands upstream.
256+
When the graph has more than one, each keeps its own name. Nothing is then called
257+
`inp_pos`, the shared-table precompute returns early, and `translate_rope()`
258+
falls back to building sin/cos per op from its own position input - a path that
259+
already existed for mixed RoPE parameters. Graphs with a single position input
260+
are untouched and keep the shared table.
261+
262+
The fourth fix is about speed rather than correctness: the naive path had no
263+
`graph_key` cache, so it re-converted and re-compiled the whole OpenVINO model on
264+
*every* `ggml_backend_graph_compute`. SmolVLA on the CPU plugin ran at 22.7 s per
265+
prediction before that was fixed and 1.8 s after.
266+
267+
None of the vla.cpp-side changes alter what the other backends compute:
268+
`vla_predict_check` on a CPU build of this branch is byte-identical to the same
269+
build of `main` for SmolVLA and π0.5, apart from the `weight_buf` line, which
270+
drops by the size of the time tiles that moved.
271+
272+
## Known issues
273+
274+
**Do not set `GGML_OPENVINO_CACHE_DIR`.** OpenVINO's on-disk blob cache reloads a
275+
compiled graph that computes the wrong thing. A cold run against a fresh cache
276+
directory is correct; the very next run, reading back the blobs it just wrote, is
277+
not:
278+
279+
```text
280+
GGML_OPENVINO_DEVICE=GPU GGML_OPENVINO_CACHE_DIR=$dir # cold: max |delta| 1.2e-3
281+
GGML_OPENVINO_DEVICE=GPU GGML_OPENVINO_CACHE_DIR=$dir # warm: max |delta| 2.9e0
282+
```
283+
284+
Nothing is logged - the actions are just wrong, which for a policy server is the
285+
worst possible failure mode. `backend_init` warns at startup when the variable is
286+
set. Unverified guess at the cause: the blob key does not capture something that
287+
differs between vla.cpp's several graphs, so one graph gets another's blob. In
288+
practice, pay the compile once per process and leave it unset.
289+
290+
**SmolVLA's `VLA_TIMING=phase` path is wrong under OpenVINO.** SmolVLA has a
291+
second graph builder used when a caller asks for per-phase timings, and it does
292+
not survive translation - max |delta| 1.9 on every device, with or without the
293+
in-process cache. The default `TimingDetail::NONE` path, which is what
294+
`vla-server` and `vla-cli` use, is correct. On the native CPU backend the two
295+
paths agree exactly, so this is specific to the OpenVINO translation of that
296+
second graph and is not yet diagnosed. π0.5's phase path is unaffected. Per-stage
297+
timings for SmolVLA are therefore omitted from the table above.
298+
299+
## What is left
300+
301+
**Op coverage.** The core drives a single backend through `gallocr` rather than a
302+
scheduler, so there is no per-op CPU fallback. An arch that uses an op
303+
ggml-openvino has no translator for cannot run at all:
304+
305+
| Op | Archs that need it |
306+
|---|---|
307+
| `GGML_UNARY_OP_RELU` | GR00T N1.5/1.6/1.7, Evo-1, VLA-Adapter, OpenVLA-OFT, BitVLA, VLA-JEPA |
308+
| `GGML_UNARY_OP_GELU_ERF` | Evo-1, VLA-Adapter, OpenVLA-OFT, GR00T N1.6, BitVLA |
309+
| `GGML_OP_NEG`, `GGML_OP_SQR` | VLA-JEPA, GR00T N1.7, BitVLA |
310+
311+
SmolVLA, π0 and π0.5 are the three archs fully covered today. π0 is untested here
312+
only because there was no checkpoint on the machine; its op set matches π0.5's.
313+
314+
**BitVLA** is a separate case: it pins its ggml graph to the CPU backend by
315+
design and offloads its LM through hand-written CUDA kernels, so an OpenVINO
316+
build leaves it on the CPU regardless.
317+
318+
**Splitting across devices.** Intel's own
319+
[π0.5 write-up](https://docs.openedgeplatform.intel.com/2026.1/OEP-articles/publications/optimizing-pi0.5-lva-model.html)
320+
puts the vision encoder and language model on the iGPU and the action expert on
321+
the NPU, with the KV cache as the only cross-device handoff. That is a different
322+
toolchain - PyTorch exported to OpenVINO IR as three separate models, no ggml -
323+
so none of it drops into this backend. What does carry over is the shape of the
324+
answer: the two devices are good at different stages, and π0.5 is already within
325+
1.5x of the iGPU on the NPU alone at a fraction of the power.
326+
327+
vla.cpp cannot make that split today because the core drives one backend for a
328+
whole prediction. It would need a per-*stage* backend rather than a per-op
329+
scheduler - the vision tower, the prefix and the action expert already hand off
330+
through host memory, so the seam is in the right place - but that is an engine
331+
change, not a backend one.

0 commit comments

Comments
 (0)