@@ -5,21 +5,17 @@ account of how far it currently runs. Like SYCL, OpenVINO is **not**
55auto-detected: it needs an explicit ` -DGGML_OPENVINO=ON ` and the OpenVINO
66runtime 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
1817iGPU 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
2420OpenVINO is Intel's inference toolkit; ggml's backend translates a ggml compute
2521graph 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)
127123binaries: ` libopenvino.so ` and its TBB live under ` /opt/intel ` . Configure fails
128124early 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*
154150apply - ggml exposes OpenVINO as a single device and the target is chosen by
155151name.
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
199252opset1::Multiply (Split[1]:f32[1,113,5,32], Multiply[0]:f32[1,50,1,32])
200253Argument 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