From e2c5f3bea39208da8ba6df0424458b80169cc25d Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 28 Oct 2026 23:04:14 -0700 Subject: [PATCH 01/21] Pass 0-d tensors to OpenVINO as scalars instead of failing The OpenVINO backend built every input and output tensor's shape with ov_shape_create, which refuses rank 0. So a delegate with a 0-d input or output, such as (x * 2).sum(), failed every run with an Internal error and nothing in the log. Keep the empty shape for a 0-d tensor; ov_tensor_create_from_host_ptr takes it as a scalar. Also log both error returns of create_ov_tensor. The 0-d cases of the existing OpenVINO mean tests failed this way and now pass: the op tests go from 25 failures to 21, with no new failure. --- backends/openvino/runtime/OpenvinoBackend.cpp | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/backends/openvino/runtime/OpenvinoBackend.cpp b/backends/openvino/runtime/OpenvinoBackend.cpp index fad6ef91cb1..f79b05c47b5 100644 --- a/backends/openvino/runtime/OpenvinoBackend.cpp +++ b/backends/openvino/runtime/OpenvinoBackend.cpp @@ -539,12 +539,20 @@ exr::Result OpenvinoBackend::create_ov_tensor( for (int64_t d = 0; d < rank; d++) { dims[d] = sizes[d]; } - // shape is zero-initialized; shape_free is only needed after a successful - // shape_create (the zero state is safe to skip). + // shape_create refuses rank 0, so a 0-d tensor keeps the zero-initialized + // shape, which describes a scalar. ov_shape_t shape = {}; - ov_status_e status = ov_.shape_create(rank, dims, &shape); + ov_status_e status = OV_STATUS_OK; + if (rank > 0) { + status = ov_.shape_create(rank, dims, &shape); + } dims_heap.reset(); // Release heap dims (no-op if stack was used). if (status != OV_STATUS_OK) { + ET_LOG( + Error, + "OpenVINO: failed to create a shape of rank %" PRId64 " (status=%d)", + rank, + status); return exr::Error::Internal; } @@ -553,6 +561,7 @@ exr::Result OpenvinoBackend::create_ov_tensor( ov_type, shape, tensor.mutable_data_ptr(), &ov_tensor); ov_.shape_free(&shape); if (status != OV_STATUS_OK || !ov_tensor) { + ET_LOG(Error, "OpenVINO: failed to create a tensor (status=%d)", status); return exr::Error::Internal; } return ov_tensor; From 61b74462089d168f134b56c52d7e2599d9f51227 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 17:42:56 -0700 Subject: [PATCH 02/21] Compute in float32 on OpenVINO CPUs by default, with bfloat16 as an option OpenVINO picks the inference precision when it imports a compiled model, and on CPUs with bfloat16 units it picks bfloat16. The backend set no precision, so a float32 program ran in bfloat16 there. A small float32 MLP came out with a relative error of 4e-3 against eager PyTorch instead of 1e-6, and no compile spec could change it. On a CPU the backend now asks for float32 by default. A program can opt into the faster, less precise mode when it is lowered: OpenvinoPartitioner( [CompileSpec("device", b"CPU"), CompileSpec("inference_precision", b"bf16")] ) The value is OpenVINO's INFERENCE_PRECISION_HINT, and it works for any device. Other devices, such as GPUs, keep OpenVINO's default unless the option is set, since their compiled kernels pick their own precision. A value OpenVINO rejects fails the load. The trade-off: on CPUs with bfloat16 units the default is slower than before, for float16 programs too, which now compute in float32. On a Xeon with bfloat16 units, 8 cores, a 4 x Linear(1024) MLP at batch 64 takes 2.4 ms per call in float32 and 1.5 ms in bfloat16. A program that wants the old speed sets inference_precision to bf16. The OpenVINO README describes the option. test_addmm.py gains a 256 x 256 case checked to 1e-4, which fails on that CPU without this change, and a case where an unknown precision fails the load. On that CPU, 21 existing OpenVINO op tests failed their 1e-3 tolerance before this change; all of them pass now. --- backends/openvino/README.md | 17 +++++++ backends/openvino/runtime/OpenvinoApi.h | 4 ++ backends/openvino/runtime/OpenvinoBackend.cpp | 47 +++++++++++++++---- .../tests/ops/base_openvino_op_test.py | 3 +- backends/openvino/tests/ops/test_addmm.py | 17 +++++++ 5 files changed, 77 insertions(+), 11 deletions(-) diff --git a/backends/openvino/README.md b/backends/openvino/README.md index 331aebf87ff..0b4ff984b65 100644 --- a/backends/openvino/README.md +++ b/backends/openvino/README.md @@ -48,6 +48,23 @@ print(_get_registered_backend_names()) # Should include 'OpenvinoBackend' ``` +## Precision + +On a CPU, a program computes in float32 by default. On CPUs with bfloat16 +support, OpenVINO would otherwise compute in bfloat16, which is faster but keeps +only about three significant digits. To trade that accuracy for speed, ask for +it when lowering: + +```python +OpenvinoPartitioner( + [CompileSpec("device", b"CPU"), CompileSpec("inference_precision", b"bf16")] +) +``` + +The value is OpenVINO's `INFERENCE_PRECISION_HINT`, such as `f32`, `bf16` or +`f16`; a CPU without support for the lower precision computes in float32. +Other devices, such as GPUs, keep OpenVINO's default unless the option is set. + ## Directory Structure ``` diff --git a/backends/openvino/runtime/OpenvinoApi.h b/backends/openvino/runtime/OpenvinoApi.h index e4c3e7f737a..584520a11a9 100644 --- a/backends/openvino/runtime/OpenvinoApi.h +++ b/backends/openvino/runtime/OpenvinoApi.h @@ -76,6 +76,9 @@ using ov_core_free_fn = void (*)(ov_core_t*); using ov_core_get_available_devices_fn = ov_status_e (*)(const ov_core_t*, ov_available_devices_t*); using ov_available_devices_free_fn = void (*)(ov_available_devices_t*); +// Takes one property key and value, both strings, after the device name. +using ov_core_set_property_fn = + ov_status_e (*)(const ov_core_t*, const char*, ...); using ov_core_import_model_fn = ov_status_e (*)( const ov_core_t*, const char*, @@ -115,6 +118,7 @@ struct OpenvinoFunctions { ov_core_free_fn core_free = nullptr; ov_core_get_available_devices_fn core_get_available_devices = nullptr; ov_available_devices_free_fn available_devices_free = nullptr; + ov_core_set_property_fn core_set_property = nullptr; ov_core_import_model_fn core_import_model = nullptr; ov_compiled_model_create_infer_request_fn compiled_model_create_infer_request = nullptr; diff --git a/backends/openvino/runtime/OpenvinoBackend.cpp b/backends/openvino/runtime/OpenvinoBackend.cpp index f79b05c47b5..8140ab6c809 100644 --- a/backends/openvino/runtime/OpenvinoBackend.cpp +++ b/backends/openvino/runtime/OpenvinoBackend.cpp @@ -236,6 +236,7 @@ bool OpenvinoBackend::ensure_loaded() const { LOAD_SYM(core_free, ov_core_free) LOAD_SYM(core_get_available_devices, ov_core_get_available_devices) LOAD_SYM(available_devices_free, ov_available_devices_free) + LOAD_SYM(core_set_property, ov_core_set_property) LOAD_SYM(core_import_model, ov_core_import_model) LOAD_SYM( compiled_model_create_infer_request, @@ -321,17 +322,43 @@ exr::Result OpenvinoBackend::init( size_t data_size = processed->size(); std::string device = "CPU"; + std::string precision; for (auto& compile_spec : compile_specs) { - if (std::strcmp(compile_spec.key, "device") == 0) { - const char* buf = static_cast(compile_spec.value.buffer); - size_t len = compile_spec.value.nbytes; - // Strip trailing null bytes that may be included in nbytes. - while (len > 0 && buf[len - 1] == '\0') { - --len; - } - if (len > 0) { - device.assign(buf, len); - } + const bool is_device = std::strcmp(compile_spec.key, "device") == 0; + if (!is_device && + std::strcmp(compile_spec.key, "inference_precision") != 0) { + continue; + } + const char* buf = static_cast(compile_spec.value.buffer); + size_t len = compile_spec.value.nbytes; + // Strip trailing null bytes that may be included in nbytes. + while (len > 0 && buf[len - 1] == '\0') { + --len; + } + if (len > 0) { + (is_device ? device : precision).assign(buf, len); + } + } + + // OpenVINO picks the precision when it imports the model, and on CPUs with + // bfloat16 units it picks bfloat16. So on a CPU a program computes in + // float32 unless it asks for less. Other devices keep their own default. + if (precision.empty() && device.rfind("CPU", 0) == 0) { + precision = "f32"; + } + if (!precision.empty()) { + status = ov_.core_set_property( + core, device.c_str(), "INFERENCE_PRECISION_HINT", precision.c_str()); + if (status != OV_STATUS_OK) { + ET_LOG( + Error, + "OpenVINO: failed to set inference precision '%s' for device '%s' " + "(status=%d)", + precision.c_str(), + device.c_str(), + status); + ov_.core_free(core); + return exr::Error::InvalidArgument; } } diff --git a/backends/openvino/tests/ops/base_openvino_op_test.py b/backends/openvino/tests/ops/base_openvino_op_test.py index f6bc928e4d4..187bd2338cc 100644 --- a/backends/openvino/tests/ops/base_openvino_op_test.py +++ b/backends/openvino/tests/ops/base_openvino_op_test.py @@ -25,6 +25,7 @@ def execute_layer_test( sample_inputs: tuple[torch.Tensor], expected_partitions: int = 1, assert_output_equal: bool = True, + compile_specs: tuple[CompileSpec, ...] = (), ): module = module.eval() @@ -32,7 +33,7 @@ def execute_layer_test( aten_dialect: ExportedProgram = export(module, sample_inputs) # Convert to edge dialect and lower the module to the backend with a custom partitioner - compile_spec = [CompileSpec("device", self.device.encode())] + compile_spec = [CompileSpec("device", self.device.encode()), *compile_specs] lowered_module: EdgeProgramManager = to_edge_transform_and_lower( aten_dialect, partitioner=[ diff --git a/backends/openvino/tests/ops/test_addmm.py b/backends/openvino/tests/ops/test_addmm.py index 51c1314db0d..7ed7413434c 100644 --- a/backends/openvino/tests/ops/test_addmm.py +++ b/backends/openvino/tests/ops/test_addmm.py @@ -2,6 +2,7 @@ from executorch.backends.openvino.tests.ops.base_openvino_op_test import ( BaseOpenvinoOpTest, ) +from executorch.exir.backend.backend_details import CompileSpec class TestAddMMOperator(BaseOpenvinoOpTest): @@ -26,3 +27,19 @@ def test_addmm(self): input_z = torch.randn(4, 4, dtype=torch.float32) sample_input = (input_x, input_y, input_z) self.execute_layer_test(module, sample_input) + + def test_addmm_computes_in_float32(self): + # On CPUs with bfloat16 units, OpenVINO would otherwise compute this in + # bfloat16 and miss by about 5e-2. + self.atol = self.rtol = 1e-4 + sample_input = tuple(torch.randn(256, 256) for _ in range(3)) + self.execute_layer_test(self.create_model(), sample_input) + + def test_addmm_refuses_an_unknown_inference_precision(self): + sample_input = tuple(torch.randn(4, 4) for _ in range(3)) + with self.assertRaises(RuntimeError): + self.execute_layer_test( + self.create_model(), + sample_input, + compile_specs=(CompileSpec("inference_precision", b"f7"),), + ) From 78938276ff153e5efa8fb325bb76851333e0cfce Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 10:38:06 -0700 Subject: [PATCH 03/21] Compile OpenVINO partitions with the precision the runtime asks for On ARM CPUs OpenVINO fixes the inference precision when it compiles the model, which happens during lowering. preprocess called compile_model without a config, so the model was compiled in float16 there, while the runtime later asked for float32 on import. The compiled model reported float32 and still computed in float16: test_addmm_computes_in_float32 missed by 2e-3 on a Linux aarch64 board. preprocess now passes INFERENCE_PRECISION_HINT to compile_model, with the same rule as the runtime: the inference_precision compile spec, or float32 on a CPU. On x86, where OpenVINO picks the precision on import, nothing changes. On ARM CPUs a program now computes in float32 by default too, about 1.8x slower than float16 on an Orin Nano; it can opt back in with inference_precision set to f16. --- backends/openvino/preprocess.py | 9 +++++++- backends/openvino/runtime/OpenvinoBackend.cpp | 5 ++--- backends/openvino/tests/ops/test_addmm.py | 21 +++++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/backends/openvino/preprocess.py b/backends/openvino/preprocess.py index 7a3f490b258..335427bb262 100644 --- a/backends/openvino/preprocess.py +++ b/backends/openvino/preprocess.py @@ -86,7 +86,14 @@ def preprocess( for i in int_inputs: ppp.input(i).tensor().set_shape([1]) ppp.input(i).preprocess().custom(lambda x: opset13.squeeze(x, 0).output(0)) - compiled = compile_model(ppp.build(), compile_options.get("device", "CPU")) + device = compile_options.get("device", "CPU") + # On ARM CPUs OpenVINO fixes the precision here, not when the runtime + # imports the model, so this must follow the same rule as the runtime. + precision = compile_options.get("inference_precision") or ( + "f32" if device.startswith("CPU") else None + ) + config = {"INFERENCE_PRECISION_HINT": precision} if precision else {} + compiled = compile_model(ppp.build(), device, config) model_bytes = compiled.export_model() return PreprocessResult(processed_bytes=model_bytes.getvalue()) diff --git a/backends/openvino/runtime/OpenvinoBackend.cpp b/backends/openvino/runtime/OpenvinoBackend.cpp index 8140ab6c809..e8fded33066 100644 --- a/backends/openvino/runtime/OpenvinoBackend.cpp +++ b/backends/openvino/runtime/OpenvinoBackend.cpp @@ -340,9 +340,8 @@ exr::Result OpenvinoBackend::init( } } - // OpenVINO picks the precision when it imports the model, and on CPUs with - // bfloat16 units it picks bfloat16. So on a CPU a program computes in - // float32 unless it asks for less. Other devices keep their own default. + // The same rule as preprocess.py, because on x86 CPUs OpenVINO applies the + // precision here, on import. if (precision.empty() && device.rfind("CPU", 0) == 0) { precision = "f32"; } diff --git a/backends/openvino/tests/ops/test_addmm.py b/backends/openvino/tests/ops/test_addmm.py index 7ed7413434c..a5ad7330dea 100644 --- a/backends/openvino/tests/ops/test_addmm.py +++ b/backends/openvino/tests/ops/test_addmm.py @@ -1,4 +1,7 @@ +from unittest import mock + import torch +from executorch.backends.openvino import preprocess from executorch.backends.openvino.tests.ops.base_openvino_op_test import ( BaseOpenvinoOpTest, ) @@ -35,6 +38,24 @@ def test_addmm_computes_in_float32(self): sample_input = tuple(torch.randn(256, 256) for _ in range(3)) self.execute_layer_test(self.create_model(), sample_input) + def test_addmm_passes_the_precision_to_openvino(self): + if not self.device.startswith("CPU"): + self.skipTest("only CPUs compute in float32 by default") + # Checks the lowering half too: on x86 the runtime half alone decides. + self.atol = self.rtol = 0.05 + sample_input = tuple(torch.randn(4, 4) for _ in range(3)) + bf16 = (CompileSpec("inference_precision", b"bf16"),) + for compile_specs, hint in (((), "f32"), (bf16, "bf16")): + with self.subTest(hint=hint), mock.patch.object( + preprocess, "compile_model", wraps=preprocess.compile_model + ) as compile_model: + self.execute_layer_test( + self.create_model(), sample_input, compile_specs=compile_specs + ) + self.assertEqual( + compile_model.call_args.args[2], {"INFERENCE_PRECISION_HINT": hint} + ) + def test_addmm_refuses_an_unknown_inference_precision(self): sample_input = tuple(torch.randn(4, 4) for _ in range(3)) with self.assertRaises(RuntimeError): From 614aeb819b0c7d6b18fb12b03c6b116702471130 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 10:38:44 -0700 Subject: [PATCH 04/21] Reject an unsupported OpenVINO inference_precision with a clear error OpenVINO does not check INFERENCE_PRECISION_HINT the same way on every release this backend supports. 2025.1.0 accepts a value such as f7 and runs the model. 2026.4.1 accepts it in set_property and only fails the import, so the load reports "failed to import model" without naming the precision. Check the value against f32, f16 and bf16 ourselves: at lowering, so a typo fails at export with a ValueError that names it, and at load, so a program exported with a bad value fails with InvalidArgument and a log line that names it. The test now asserts the lowering error, which no OpenVINO release produces without this check. The load-time check has no test of its own: lowering now rejects such a value before it can reach the runtime. --- backends/openvino/preprocess.py | 11 +++++++++++ backends/openvino/runtime/OpenvinoBackend.cpp | 13 +++++++++++++ backends/openvino/tests/ops/test_addmm.py | 2 +- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/backends/openvino/preprocess.py b/backends/openvino/preprocess.py index 335427bb262..540c6188fec 100644 --- a/backends/openvino/preprocess.py +++ b/backends/openvino/preprocess.py @@ -25,6 +25,10 @@ ) from openvino.preprocess import PrePostProcessor # type: ignore[import-untyped] +# The values of the inference_precision compile spec. The runtime +# (OpenvinoBackend.cpp) accepts the same ones. +_PRECISIONS = ("f32", "f16", "bf16") + @final class OpenvinoBackend(BackendDetails): @@ -92,6 +96,13 @@ def preprocess( precision = compile_options.get("inference_precision") or ( "f32" if device.startswith("CPU") else None ) + # Some OpenVINO releases accept an unknown value here. Checking now keeps + # it out of the program, where the runtime would only fail at load. + if precision and precision not in _PRECISIONS: + raise ValueError( + f"OpenVINO backend: unsupported inference_precision '{precision}', " + f"expected one of {', '.join(_PRECISIONS)}" + ) config = {"INFERENCE_PRECISION_HINT": precision} if precision else {} compiled = compile_model(ppp.build(), device, config) model_bytes = compiled.export_model() diff --git a/backends/openvino/runtime/OpenvinoBackend.cpp b/backends/openvino/runtime/OpenvinoBackend.cpp index e8fded33066..65a379c5f62 100644 --- a/backends/openvino/runtime/OpenvinoBackend.cpp +++ b/backends/openvino/runtime/OpenvinoBackend.cpp @@ -345,6 +345,19 @@ exr::Result OpenvinoBackend::init( if (precision.empty() && device.rfind("CPU", 0) == 0) { precision = "f32"; } + // The same values as in preprocess.py. Some OpenVINO releases accept an + // unknown value, and others only reject it on import, with a generic + // error. + if (!precision.empty() && precision != "f32" && precision != "f16" && + precision != "bf16") { + ET_LOG( + Error, + "OpenVINO: unsupported inference_precision '%s', expected one of " + "f32, f16, bf16", + precision.c_str()); + ov_.core_free(core); + return exr::Error::InvalidArgument; + } if (!precision.empty()) { status = ov_.core_set_property( core, device.c_str(), "INFERENCE_PRECISION_HINT", precision.c_str()); diff --git a/backends/openvino/tests/ops/test_addmm.py b/backends/openvino/tests/ops/test_addmm.py index a5ad7330dea..090a5a23a2e 100644 --- a/backends/openvino/tests/ops/test_addmm.py +++ b/backends/openvino/tests/ops/test_addmm.py @@ -58,7 +58,7 @@ def test_addmm_passes_the_precision_to_openvino(self): def test_addmm_refuses_an_unknown_inference_precision(self): sample_input = tuple(torch.randn(4, 4) for _ in range(3)) - with self.assertRaises(RuntimeError): + with self.assertRaisesRegex(ValueError, "unsupported inference_precision 'f7'"): self.execute_layer_test( self.create_model(), sample_input, From 74df90a41d3e482aca6cf087cacc0889883659d1 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 10:38:54 -0700 Subject: [PATCH 05/21] Run the OpenVINO float32 addmm test only on CPUs Only CPUs compute in float32 by default. OpenVINO's GPU default is float16, and at 256 x 256 a float16 result is far outside the test's 1e-4 tolerance, so a documented --device GPU run of the op suite would fail it. The test now skips on other devices. --- backends/openvino/tests/ops/test_addmm.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backends/openvino/tests/ops/test_addmm.py b/backends/openvino/tests/ops/test_addmm.py index 090a5a23a2e..7c2e7942b55 100644 --- a/backends/openvino/tests/ops/test_addmm.py +++ b/backends/openvino/tests/ops/test_addmm.py @@ -32,6 +32,8 @@ def test_addmm(self): self.execute_layer_test(module, sample_input) def test_addmm_computes_in_float32(self): + if not self.device.startswith("CPU"): + self.skipTest("only CPUs compute in float32 by default") # On CPUs with bfloat16 units, OpenVINO would otherwise compute this in # bfloat16 and miss by about 5e-2. self.atol = self.rtol = 1e-4 From c2ae0e0a1b25fd90e9e2361365447e0d2036e0e1 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 10:39:33 -0700 Subject: [PATCH 06/21] Describe the OpenVINO precision default for ARM CPUs and mirror it in the docs The README's Precision section described only x86. On ARM CPUs OpenVINO would compute in float16, and the float32 default now applies there too, so the section says how to opt back into float16. It also says that bfloat16 keeps two to three significant digits, not about three, what a CPU without float16 or bfloat16 support does with each value, that other values fail at lowering, and the snippet imports what it uses. docs/source/build-run-openvino.md mirrors this README and gets the same section. --- backends/openvino/README.md | 32 ++++++++++++++++----- backends/openvino/tests/ops/test_addmm.py | 4 +-- docs/source/build-run-openvino.md | 35 +++++++++++++++++++++++ 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/backends/openvino/README.md b/backends/openvino/README.md index 0b4ff984b65..75564743441 100644 --- a/backends/openvino/README.md +++ b/backends/openvino/README.md @@ -50,20 +50,38 @@ print(_get_registered_backend_names()) ## Precision -On a CPU, a program computes in float32 by default. On CPUs with bfloat16 -support, OpenVINO would otherwise compute in bfloat16, which is faster but keeps -only about three significant digits. To trade that accuracy for speed, ask for -it when lowering: +On a CPU, a program computes in float32 by default. Without this default, +OpenVINO would compute in bfloat16 on x86 CPUs with bfloat16 units, which keeps only two to +three significant digits, and in float16 on ARM CPUs, which keeps about three. +Both are faster. To trade accuracy for speed, ask for the faster precision when +lowering: ```python +from executorch.backends.openvino.partitioner import OpenvinoPartitioner +from executorch.exir.backend.compile_spec_schema import CompileSpec + OpenvinoPartitioner( [CompileSpec("device", b"CPU"), CompileSpec("inference_precision", b"bf16")] ) ``` -The value is OpenVINO's `INFERENCE_PRECISION_HINT`, such as `f32`, `bf16` or -`f16`; a CPU without support for the lower precision computes in float32. -Other devices, such as GPUs, keep OpenVINO's default unless the option is set. +On an ARM CPU, ask for `f16` instead: there float32 is about 1.8x slower than +float16, as measured on an Orin Nano. + +The value is OpenVINO's `INFERENCE_PRECISION_HINT`: `f32`, `f16` or `bf16`. An +empty value means the default, and any other value fails at lowering. A CPU +without float16 support computes an `f16` program in float32. With `bf16`, x86 +CPUs that have AVX-512 (F, BW, DQ and VL) or AVX2-VNNI-2 compute in bfloat16, +simulating it with the same lower accuracy if they have no bfloat16 units, and +other x86 CPUs compute in float32. OpenVINO has no bfloat16 on ARM CPUs, so +there `bf16` keeps OpenVINO's ARM default: float16 if the CPU supports it, +float32 otherwise. Other devices, such as GPUs, keep OpenVINO's default unless +the option is set. + +On x86 CPUs, OpenVINO applies the precision when the runtime loads a program. +So a program exported before this default also computes in float32 with this +runtime, which is slower and can use more memory. Export it again with +`inference_precision` to get the old speed. ## Directory Structure diff --git a/backends/openvino/tests/ops/test_addmm.py b/backends/openvino/tests/ops/test_addmm.py index 7c2e7942b55..95e63c0786a 100644 --- a/backends/openvino/tests/ops/test_addmm.py +++ b/backends/openvino/tests/ops/test_addmm.py @@ -34,8 +34,8 @@ def test_addmm(self): def test_addmm_computes_in_float32(self): if not self.device.startswith("CPU"): self.skipTest("only CPUs compute in float32 by default") - # On CPUs with bfloat16 units, OpenVINO would otherwise compute this in - # bfloat16 and miss by about 5e-2. + # OpenVINO would otherwise compute this in bfloat16 on x86 CPUs with + # bfloat16 units, or in float16 on ARM CPUs, far outside 1e-4. self.atol = self.rtol = 1e-4 sample_input = tuple(torch.randn(256, 256) for _ in range(3)) self.execute_layer_test(self.create_model(), sample_input) diff --git a/docs/source/build-run-openvino.md b/docs/source/build-run-openvino.md index 69a73672ac1..721e23a518b 100644 --- a/docs/source/build-run-openvino.md +++ b/docs/source/build-run-openvino.md @@ -102,6 +102,41 @@ Follow the steps below to setup your build environment: ./openvino_build.sh ``` +## Precision + +On a CPU, a program computes in float32 by default. Without this default, +OpenVINO would compute in bfloat16 on x86 CPUs with bfloat16 units, which keeps only two to +three significant digits, and in float16 on ARM CPUs, which keeps about three. +Both are faster. To trade accuracy for speed, ask for the faster precision when +lowering: + +```python +from executorch.backends.openvino.partitioner import OpenvinoPartitioner +from executorch.exir.backend.compile_spec_schema import CompileSpec + +OpenvinoPartitioner( + [CompileSpec("device", b"CPU"), CompileSpec("inference_precision", b"bf16")] +) +``` + +On an ARM CPU, ask for `f16` instead: there float32 is about 1.8x slower than +float16, as measured on an Orin Nano. + +The value is OpenVINO's `INFERENCE_PRECISION_HINT`: `f32`, `f16` or `bf16`. An +empty value means the default, and any other value fails at lowering. A CPU +without float16 support computes an `f16` program in float32. With `bf16`, x86 +CPUs that have AVX-512 (F, BW, DQ and VL) or AVX2-VNNI-2 compute in bfloat16, +simulating it with the same lower accuracy if they have no bfloat16 units, and +other x86 CPUs compute in float32. OpenVINO has no bfloat16 on ARM CPUs, so +there `bf16` keeps OpenVINO's ARM default: float16 if the CPU supports it, +float32 otherwise. Other devices, such as GPUs, keep OpenVINO's default unless +the option is set. + +On x86 CPUs, OpenVINO applies the precision when the runtime loads a program. +So a program exported before this default also computes in float32 with this +runtime, which is slower and can use more memory. Export it again with +`inference_precision` to get the old speed. + ## Build Instructions for Examples ### AOT step: From e928470ae202b7d415f7d50044ef869436c65f2c Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 13:50:05 -0700 Subject: [PATCH 07/21] Give OpenVINO outputs with a dynamic size the shape OpenVINO computed An output whose size depends on an int input, or on a dynamic input size, is planned at its upper bound. The backend handed OpenVINO that buffer and never read back the shape OpenVINO actually produced, so the output kept its upper-bound shape. For example, with x of shape (4, 8) and n = 3: x[:n].view(n, -1) # eager: (3, 8); OpenVINO delegate: (4, 8), one extra row After inference, the backend now reads each output's shape from OpenVINO and resizes the ExecuTorch output to it. The data is already laid out for that shape at the start of the buffer. A 0-d output is skipped: it has no size to change, and newer OpenVINO releases cannot return a rank-0 shape from their C API. Once an output is smaller, the next call would hand OpenVINO the smaller buffer, and OpenVINO treats a buffer's shape as its capacity, so a larger result would fail. The backend records each output's size at the first call, which is its upper bound, and restores it before every later call. The sizes live in a small list from the runtime allocator, 16 int64 values per output. test_view_data_dependent_size returned x[:n].view(n, -1).sum(0), which hid the extra row. It now returns the view itself and runs n = 3 and then n = 4 on the same loaded method, through a new more_inputs option of the shared test helper. It fails without the resize, and also without the restore. --- backends/openvino/runtime/OpenvinoApi.h | 6 ++ backends/openvino/runtime/OpenvinoBackend.cpp | 82 +++++++++++++++++++ backends/openvino/runtime/OpenvinoBackend.h | 4 + .../tests/ops/base_openvino_op_test.py | 45 +++++----- backends/openvino/tests/ops/test_view.py | 12 ++- 5 files changed, 125 insertions(+), 24 deletions(-) diff --git a/backends/openvino/runtime/OpenvinoApi.h b/backends/openvino/runtime/OpenvinoApi.h index 584520a11a9..dfb1e65c3b4 100644 --- a/backends/openvino/runtime/OpenvinoApi.h +++ b/backends/openvino/runtime/OpenvinoApi.h @@ -97,10 +97,13 @@ using ov_infer_request_set_input_tensor_by_index_fn = using ov_infer_request_set_output_tensor_by_index_fn = ov_status_e (*)(ov_infer_request_t*, size_t, const ov_tensor_t*); using ov_infer_request_infer_fn = ov_status_e (*)(ov_infer_request_t*); +using ov_infer_request_get_output_tensor_by_index_fn = + ov_status_e (*)(const ov_infer_request_t*, size_t, ov_tensor_t**); using ov_infer_request_free_fn = void (*)(ov_infer_request_t*); using ov_tensor_create_from_host_ptr_fn = ov_status_e (*)(ov_element_type_e, ov_shape_t, void*, ov_tensor_t**); using ov_tensor_free_fn = void (*)(ov_tensor_t*); +using ov_tensor_get_shape_fn = ov_status_e (*)(const ov_tensor_t*, ov_shape_t*); using ov_shape_create_fn = ov_status_e (*)(int64_t, const int64_t*, ov_shape_t*); using ov_shape_free_fn = ov_status_e (*)(ov_shape_t*); @@ -130,9 +133,12 @@ struct OpenvinoFunctions { ov_infer_request_set_output_tensor_by_index_fn infer_request_set_output_tensor_by_index = nullptr; ov_infer_request_infer_fn infer_request_infer = nullptr; + ov_infer_request_get_output_tensor_by_index_fn + infer_request_get_output_tensor_by_index = nullptr; ov_infer_request_free_fn infer_request_free = nullptr; ov_tensor_create_from_host_ptr_fn tensor_create_from_host_ptr = nullptr; ov_tensor_free_fn tensor_free = nullptr; + ov_tensor_get_shape_fn tensor_get_shape = nullptr; ov_shape_create_fn shape_create = nullptr; ov_shape_free_fn shape_free = nullptr; }; diff --git a/backends/openvino/runtime/OpenvinoBackend.cpp b/backends/openvino/runtime/OpenvinoBackend.cpp index 65a379c5f62..17627693cf6 100644 --- a/backends/openvino/runtime/OpenvinoBackend.cpp +++ b/backends/openvino/runtime/OpenvinoBackend.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include "OpenvinoBackend.h" @@ -251,9 +252,13 @@ bool OpenvinoBackend::ensure_loaded() const { infer_request_set_output_tensor_by_index, ov_infer_request_set_output_tensor_by_index) LOAD_SYM(infer_request_infer, ov_infer_request_infer) + LOAD_SYM( + infer_request_get_output_tensor_by_index, + ov_infer_request_get_output_tensor_by_index) LOAD_SYM(infer_request_free, ov_infer_request_free) LOAD_SYM(tensor_create_from_host_ptr, ov_tensor_create_from_host_ptr) LOAD_SYM(tensor_free, ov_tensor_free) + LOAD_SYM(tensor_get_shape, ov_tensor_get_shape) LOAD_SYM(shape_create, ov_shape_create) LOAD_SYM(shape_free, ov_shape_free) @@ -418,9 +423,29 @@ exr::Result OpenvinoBackend::init( ov_.compiled_model_free(compiled_model); return exr::Error::MemoryAllocationFailed; } + size_t num_outputs = 0; + status = ov_.compiled_model_outputs_size(compiled_model, &num_outputs); + if (status != OV_STATUS_OK) { + ET_LOG(Error, "OpenVINO: failed to get output count (status=%d)", status); + ov_.infer_request_free(infer_request); + ov_.compiled_model_free(compiled_model); + return exr::Error::Internal; + } + int64_t* output_bound_sizes = nullptr; + if (num_outputs > 0) { + output_bound_sizes = allocator->allocateList( + num_outputs * exr::kTensorDimensionLimit); + if (!output_bound_sizes) { + ET_LOG(Error, "OpenVINO: failed to allocate output bounds"); + ov_.infer_request_free(infer_request); + ov_.compiled_model_free(compiled_model); + return exr::Error::MemoryAllocationFailed; + } + } new (handle) ExecutionHandle; handle->compiled_model = compiled_model; handle->infer_request = infer_request; + handle->output_bound_sizes = output_bound_sizes; return handle; } @@ -508,6 +533,23 @@ exr::Error OpenvinoBackend::execute( "OpenVINO: expected tensor for output %zu", i); auto output_tensor = args[num_inputs + i]->toTensor(); + // OpenVINO takes the buffer's shape as its capacity, so an output an + // earlier call shrank goes back to its upper bound first. + int64_t* bound = + execution_handle->output_bound_sizes + i * exr::kTensorDimensionLimit; + if (!execution_handle->output_bounds_recorded) { + std::copy( + output_tensor.sizes().begin(), output_tensor.sizes().end(), bound); + } else { + exr::Error error = exr::resize_tensor( + output_tensor, + exa::ArrayRef( + bound, static_cast(output_tensor.dim()))); + if (error != exr::Error::Ok) { + ET_LOG(Error, "OpenVINO: failed to restore output %zu", i); + return error; + } + } auto result = create_ov_tensor(output_tensor); if (!result.ok()) { return result.error(); @@ -523,12 +565,52 @@ exr::Error OpenvinoBackend::execute( } } + execution_handle->output_bounds_recorded = true; + status = ov_.infer_request_infer(execution_handle->infer_request); if (status != OV_STATUS_OK) { ET_LOG(Error, "OpenVINO: inference failed (status=%d)", status); return exr::Error::Internal; } + // An output with a dynamic size, such as one set by an int input, gets a + // buffer at its upper bound and can come back smaller. + for (size_t i = 0; i < num_outputs; i++) { + auto output_tensor = args[num_inputs + i]->toTensor(); + // A 0-d output has no size to change, and newer OpenVINO releases cannot + // return a rank-0 shape from ov_tensor_get_shape. + if (output_tensor.dim() == 0) { + continue; + } + ov_tensor_t* ov_output = nullptr; + status = ov_.infer_request_get_output_tensor_by_index( + execution_handle->infer_request, i, &ov_output); + if (status != OV_STATUS_OK) { + ET_LOG( + Error, "OpenVINO: failed to get output %zu (status=%d)", i, status); + return exr::Error::Internal; + } + ov_shape_t shape = {}; + status = ov_.tensor_get_shape(ov_output, &shape); + ov_.tensor_free(ov_output); + if (status != OV_STATUS_OK) { + ET_LOG( + Error, + "OpenVINO: failed to get the shape of output %zu (status=%d)", + i, + status); + return exr::Error::Internal; + } + exr::Error error = exr::resize_tensor( + output_tensor, + exa::ArrayRef(shape.dims, static_cast(shape.rank))); + ov_.shape_free(&shape); + if (error != exr::Error::Ok) { + ET_LOG(Error, "OpenVINO: failed to resize output %zu", i); + return error; + } + } + return exr::Error::Ok; } diff --git a/backends/openvino/runtime/OpenvinoBackend.h b/backends/openvino/runtime/OpenvinoBackend.h index 510e49ee690..d552db39001 100644 --- a/backends/openvino/runtime/OpenvinoBackend.h +++ b/backends/openvino/runtime/OpenvinoBackend.h @@ -28,6 +28,10 @@ namespace openvino { struct ExecutionHandle { ov_compiled_model_t* compiled_model = nullptr; ov_infer_request_t* infer_request = nullptr; + // The sizes of each output at the first execute, which are its upper bound, + // kTensorDimensionLimit entries per output. + int64_t* output_bound_sizes = nullptr; + bool output_bounds_recorded = false; }; class OpenvinoBackend final : public ::exr::BackendInterface { diff --git a/backends/openvino/tests/ops/base_openvino_op_test.py b/backends/openvino/tests/ops/base_openvino_op_test.py index 187bd2338cc..4e20b41cbf6 100644 --- a/backends/openvino/tests/ops/base_openvino_op_test.py +++ b/backends/openvino/tests/ops/base_openvino_op_test.py @@ -26,6 +26,7 @@ def execute_layer_test( expected_partitions: int = 1, assert_output_equal: bool = True, compile_specs: tuple[CompileSpec, ...] = (), + more_inputs: tuple[tuple[torch.Tensor, ...], ...] = (), ): module = module.eval() @@ -60,12 +61,8 @@ def execute_layer_test( # Execute the model and compare the outputs with the reference outputs if assert_output_equal: - # Execute the module in eager mode to calculate the reference outputs - ref_output = module(*sample_inputs) - if isinstance(ref_output, torch.Tensor): - ref_output = [ - ref_output, - ] + # Later inputs run on the same loaded method, as an app would. + all_inputs = (sample_inputs, *more_inputs) # Load from a file: unlike a buffer, it really frees the delegate # data when the backend frees it. @@ -76,20 +73,28 @@ def execute_layer_test( program = Runtime.get().load_program(pte_path) method = program.load_method("forward") assert method is not None - outputs = method.execute(sample_inputs) + all_outputs = [method.execute(inputs) for inputs in all_inputs] # Windows cannot delete a mapped file, so release it first. del method, program - # Compare the outputs with the reference outputs - self.assertTrue(len(ref_output) == len(outputs)) - for i in range(len(ref_output)): - self.assertTrue( - torch.allclose( - outputs[i], - ref_output[i], - atol=self.atol, - rtol=self.rtol, - equal_nan=True, - ), - msg=f"ref_output:\n{ref_output[i]}\n\ntest_output:\n{outputs[i]}", - ) + for inputs, outputs in zip(all_inputs, all_outputs): + # Execute the module in eager mode to calculate the reference outputs + ref_output = module(*inputs) + if isinstance(ref_output, torch.Tensor): + ref_output = [ + ref_output, + ] + + # Compare the outputs with the reference outputs + self.assertTrue(len(ref_output) == len(outputs)) + for i in range(len(ref_output)): + self.assertTrue( + torch.allclose( + outputs[i], + ref_output[i], + atol=self.atol, + rtol=self.rtol, + equal_nan=True, + ), + msg=f"ref_output:\n{ref_output[i]}\n\ntest_output:\n{outputs[i]}", + ) diff --git a/backends/openvino/tests/ops/test_view.py b/backends/openvino/tests/ops/test_view.py index 5ea2f8cb1dc..ed354f0882f 100644 --- a/backends/openvino/tests/ops/test_view.py +++ b/backends/openvino/tests/ops/test_view.py @@ -40,7 +40,11 @@ def forward(self, x, n): n = n.item() torch._check(n >= 1) torch._check(n <= x.shape[0]) - return x[:n].view(n, -1).sum(0) - - # The size reaches the OpenVINO partition as a SymInt input. - self.execute_layer_test(View(), (torch.randn(4, 8), torch.tensor(3))) + return x[:n].view(n, -1) + + # The size reaches the OpenVINO partition as a SymInt input. The second + # call needs a larger output than the first one returned. + x = torch.randn(4, 8) + self.execute_layer_test( + View(), (x, torch.tensor(3)), more_inputs=((x, torch.tensor(4)),) + ) From 209d49b2e796a34bc5f1473da22b3b462ae2b9a1 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 01:21:18 -0700 Subject: [PATCH 08/21] OpenVINO backend: reduce bool and integer tensors in int64 PyTorch computes sum, cumsum and prod of a bool or integer tensor in int64. OpenVINO's PyTorch frontend keeps a narrower type for some of them. A cumsum over a bool mask is computed in 8 bits, so the count wraps after 255. A cumsum or prod over int32 returns int32. When such a result is a delegate output, the runtime rejects it at execute: m = torch.rand(2, 7) > 0.5 torch.cumsum(m, 1) # CALL_DELEGATE execute failed: 0x1 Inside a graph the wrong values flow on silently. pi0 and pi0.5 compute their position ids as cumsum(pad_masks) - 1 over more than 255 tokens, so every position from 255 on was wrong and the policies gave wrong actions. Cast the input of these reductions to their result dtype before the graph goes to OpenVINO. The open #24024 proposes a similar cast for bool sum and cumsum. The cast exposes an OpenVINO CPU bug, seen on 2025.4.1, 2026.0.0 and 2026.4.1 but not on the nightly: with an int64 cumsum over a constant, a loaded program gives wrong results from the second inference on. pi0 builds its attention masks from such a cumsum, and each call's mask kept only what earlier calls allowed. So the backend now replaces an int64 cumsum that reads only constants with its result before converting. Buffers the graph writes to, random ops and higher-order ops are not treated as constants, and other constants, such as compressed weights, are left as they are. The fold computes on the CPU, so lowering does not need the GPU, or the pinned memory, a constant was created with. If the CPU cannot compute such a constant, the cumsum is left to OpenVINO as before. The new tests run cumsum over bool and int32, prod over int32, and pi0-style attention masks and positions over 816 tokens through one loaded program twice, with more valid tokens the second time. Pass tests check that mutated buffers, random draws (direct and inside cond) and float cumsums are not folded, that cumsums over a split constant and over CUDA and pinned constants without a GPU are, and that a folded result does not replace a buffer. --- backends/openvino/_passes/__init__.py | 8 +- .../_passes/cast_reduction_input_pass.py | 45 +++++ .../_passes/fold_constant_cumsum_pass.py | 129 ++++++++++++++ backends/openvino/preprocess.py | 16 +- .../openvino/tests/ops/test_reductions.py | 161 ++++++++++++++++++ 5 files changed, 355 insertions(+), 4 deletions(-) create mode 100644 backends/openvino/_passes/cast_reduction_input_pass.py create mode 100644 backends/openvino/_passes/fold_constant_cumsum_pass.py create mode 100644 backends/openvino/tests/ops/test_reductions.py diff --git a/backends/openvino/_passes/__init__.py b/backends/openvino/_passes/__init__.py index 0db94ca1ec8..bc2853a73e6 100644 --- a/backends/openvino/_passes/__init__.py +++ b/backends/openvino/_passes/__init__.py @@ -4,6 +4,12 @@ # except in compliance with the License. See the license file found in the # LICENSE file in the root directory of this source tree. +from .cast_reduction_input_pass import CastReductionInputPass from .decompose_floor_divide_pass import DecomposeFloorDividePass +from .fold_constant_cumsum_pass import FoldConstantCumsumPass -__all__ = ["DecomposeFloorDividePass"] +__all__ = [ + "CastReductionInputPass", + "DecomposeFloorDividePass", + "FoldConstantCumsumPass", +] diff --git a/backends/openvino/_passes/cast_reduction_input_pass.py b/backends/openvino/_passes/cast_reduction_input_pass.py new file mode 100644 index 00000000000..2ce5c21b172 --- /dev/null +++ b/backends/openvino/_passes/cast_reduction_input_pass.py @@ -0,0 +1,45 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import torch +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.pass_base import ExportPass, PassResult + +REDUCTION_OPS = { + exir_ops.edge.aten.sum.dim_IntList, + exir_ops.edge.aten.cumsum.default, + exir_ops.edge.aten.prod.default, + exir_ops.edge.aten.prod.dim_int, +} + + +class CastReductionInputPass(ExportPass): + """Cast the input of sum, cumsum and prod to the dtype of their result. + + PyTorch reduces bool and integer tensors in int64, but OpenVINO keeps a + narrower type for some of them. A cumsum over a bool mask is computed in + 8 bits and wraps after 255, and a cumsum or prod over int32 returns int32. + """ + + def call(self, graph_module: torch.fx.GraphModule) -> PassResult: + modified = False + for node in graph_module.graph.nodes: + if node.op != "call_function" or node.target not in REDUCTION_OPS: + continue + source = node.args[0] + dtype = node.meta["val"].dtype + if source.meta["val"].dtype == dtype: + continue + with graph_module.graph.inserting_before(node): + cast = graph_module.graph.call_function( + exir_ops.edge.aten._to_copy.default, (source,), {"dtype": dtype} + ) + cast.meta["val"] = source.meta["val"].to(dtype) + node.replace_input_with(source, cast) + modified = True + if modified: + graph_module.recompile() + return PassResult(graph_module, modified) diff --git a/backends/openvino/_passes/fold_constant_cumsum_pass.py b/backends/openvino/_passes/fold_constant_cumsum_pass.py new file mode 100644 index 00000000000..1009238b02f --- /dev/null +++ b/backends/openvino/_passes/fold_constant_cumsum_pass.py @@ -0,0 +1,129 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import logging +import operator + +import torch +from executorch.exir.dialects._ops import ops as exir_ops +from torch.fx.passes.infra.pass_base import PassBase, PassResult +from torch.utils import _pytree as pytree + + +def _is_written(node: torch.fx.Node) -> bool: + """Whether an op writes to node in place, as a mutated buffer is.""" + for user in node.users: + schema = getattr(user.target, "_schema", None) + if schema is None: + continue + for arg, value in zip(schema.arguments, user.args): + if value is node and arg.alias_info is not None and arg.alias_info.is_write: + return True + return False + + +def _on_cpu(value): + if isinstance(value, torch.Tensor): + return value.cpu() + if isinstance(value, torch.device): + return torch.device("cpu") + return value + + +def _constant_nodes(graph_module: torch.fx.GraphModule) -> set: + """Nodes that depend only on constants. Graph order puts inputs first.""" + constants = set() + for node in graph_module.graph.nodes: + # Only tensors: a higher-order op such as cond reads its subgraphs, + # which may hold a random op, through get_attr nodes too. + if node.op == "get_attr": + value = operator.attrgetter(node.target)(graph_module) + if isinstance(value, torch.Tensor) and not _is_written(node): + constants.add(node) + elif ( + node.op == "call_function" + and torch.Tag.nondeterministic_seeded + not in getattr(node.target, "tags", ()) + and all(n in constants for n in node.all_input_nodes) + ): + constants.add(node) + return constants + + +def _evaluate(graph_module: torch.fx.GraphModule, targets: list) -> dict: + """Values of targets, computing only what they read, on the CPU.""" + needed = set() + stack = list(targets) + while stack: + node = stack.pop() + if node not in needed: + needed.add(node) + stack.extend(node.all_input_nodes) + order = [n for n in graph_module.graph.nodes if n in needed] + last_use = {i: n for n in order for i in n.all_input_nodes} + values = {} + for node in order: + if node.op == "get_attr": + values[node] = operator.attrgetter(node.target)(graph_module) + continue + args, kwargs = torch.fx.node.map_arg( + (node.args, node.kwargs), lambda n: values[n] + ) + # The values are the same on any device, and lowering for OpenVINO + # must not need the GPU a constant was made for. + args, kwargs = pytree.tree_map(_on_cpu, (args, kwargs)) + if kwargs.get("pin_memory"): + kwargs = {**kwargs, "pin_memory": False} + values[node] = node.target(*args, **kwargs) + for i in node.all_input_nodes: + if last_use[i] is node and i not in targets: + del values[i] + return values + + +class FoldConstantCumsumPass(PassBase): + """Replace an int64 cumsum that reads only constants with its result. + + With an int64 cumsum over a constant, OpenVINO's CPU plugin (2025.4 to + 2026.4) gives wrong results from the second inference on: masks built from + it keep only what earlier calls allowed. pi0 builds its attention masks + this way. The OpenVINO nightly no longer does this, so the pass can go once + the oldest supported OpenVINO release has the fix. Only these cumsums are + folded; constants that feed no such cumsum, such as compressed weights, are + left as they are. Runs on an unlifted module, where constants are get_attr + nodes; buffers the graph writes to are not constants. + """ + + def call(self, graph_module: torch.fx.GraphModule) -> PassResult: + graph = graph_module.graph + constants = _constant_nodes(graph_module) + cumsums = [ + n + for n in graph.nodes + if n in constants + and n.target == exir_ops.edge.aten.cumsum.default + and n.meta["val"].dtype == torch.int64 + ] + try: + values = _evaluate(graph_module, cumsums) + except RuntimeError as e: + # Some ops have modes the CPU lacks; OpenVINO still gets the cumsum. + logging.warning("OpenVINO: not folding constant cumsums: %s", e) + return PassResult(graph_module, False) + + for node in cumsums: + name = f"_folded_cumsum_{node.name}" + while hasattr(graph_module, name): + name += "_" + graph_module.register_buffer(name, values[node]) + with graph.inserting_before(node): + folded = graph.get_attr(name) + folded.meta = node.meta.copy() + node.replace_all_uses_with(folded) + if cumsums: + graph.eliminate_dead_code() + graph_module.recompile() + return PassResult(graph_module, bool(cumsums)) diff --git a/backends/openvino/preprocess.py b/backends/openvino/preprocess.py index 540c6188fec..a75976c2c25 100644 --- a/backends/openvino/preprocess.py +++ b/backends/openvino/preprocess.py @@ -9,7 +9,11 @@ from typing import final, List import torch -from executorch.backends.openvino._passes import DecomposeFloorDividePass +from executorch.backends.openvino._passes import ( + CastReductionInputPass, + DecomposeFloorDividePass, + FoldConstantCumsumPass, +) from executorch.exir.backend.backend_details import ( BackendDetails, @@ -47,7 +51,11 @@ def preprocess( Returns: PreprocessResult: The result of preprocessing, including the compiled model bytes. """ - for pass_cls in [DimOrderOpsRevertPass, DecomposeFloorDividePass]: + for pass_cls in [ + DimOrderOpsRevertPass, + DecomposeFloorDividePass, + CastReductionInputPass, + ]: result = pass_cls()(edge_program.graph_module) if result and result.graph_module: edge_program._graph_module = result.graph_module @@ -80,8 +88,10 @@ def preprocess( # The converter chooses some lowerings (e.g. boolean-mask indexing) from # the input types, so they must be known before conversion starts. + module = edge_program.module() + FoldConstantCumsumPass()(module) decoder = TorchFXPythonDecoder( - edge_program.module(), input_shapes=input_shapes, input_types=input_types + module, input_shapes=input_shapes, input_types=input_types ) frontend = FrontEndManager().load_by_framework("pytorch") ppp = PrePostProcessor(frontend.convert(frontend.load(decoder))) diff --git a/backends/openvino/tests/ops/test_reductions.py b/backends/openvino/tests/ops/test_reductions.py new file mode 100644 index 00000000000..2a6e47caba4 --- /dev/null +++ b/backends/openvino/tests/ops/test_reductions.py @@ -0,0 +1,161 @@ +# mypy: disable-error-code=import-not-found + +import unittest + +import torch +from executorch.backends.openvino._passes import FoldConstantCumsumPass +from executorch.backends.openvino.tests.ops.base_openvino_op_test import ( + BaseOpenvinoOpTest, +) +from executorch.exir import to_edge +from executorch.exir.dialects._ops import ops as exir_ops +from openvino.frontend.pytorch.torchdynamo.op_support import ( # type: ignore[import-untyped] + OperatorSupport, +) + + +class TestIntegerReductions(BaseOpenvinoOpTest): + """PyTorch reduces bool and small integer tensors to int64.""" + + def test_cumsum(self): + class CumSum(torch.nn.Module): + def forward(self, x): + return torch.cumsum(x, 1) + + for x in ( + torch.rand(2, 7) > 0.5, + torch.randint(0, 5, (2, 7), dtype=torch.int32), + ): + with self.subTest(dtype=x.dtype): + self.execute_layer_test(CumSum(), (x,)) + + def test_prod(self): + if "torch.ops.aten.prod.dim_int" not in OperatorSupport([])._support_dict: + self.skipTest("this OpenVINO release does not delegate prod") + + class Prod(torch.nn.Module): + def forward(self, x): + return x.prod(1) + + self.execute_layer_test( + Prod(), (torch.randint(1, 4, (2, 5), dtype=torch.int32),) + ) + + def test_attention_masks_with_more_valid_tokens(self): + class AttentionMasks(torch.nn.Module): + """Masks and positions from padding, as pi0 builds them.""" + + def forward(self, pad_masks): + att_masks = torch.tensor([0] * 765 + [1] * 51, dtype=torch.bool) + cumsum = torch.cumsum(att_masks[None, :], dim=1) + att_2d = cumsum[:, None, :] <= cumsum[:, :, None] + pad_2d = pad_masks[:, None, :] * pad_masks[:, :, None] + return att_2d & pad_2d, torch.cumsum(pad_masks, dim=1) - 1 + + def pad_masks(valid): + return (torch.arange(816) < valid)[None, :] + + # One loaded program, run again with more valid tokens. + self.execute_layer_test( + AttentionMasks(), (pad_masks(300),), more_inputs=((pad_masks(700),),) + ) + + +class TestFoldConstantCumsumPass(unittest.TestCase): + def fold(self, module, inputs): + """Edge program module of module, with constant cumsums folded.""" + edge = to_edge(torch.export.export(module, inputs)) + folded = edge.exported_program().module() + FoldConstantCumsumPass()(folded) + return folded + + def test_buffer_written_by_the_graph_is_not_folded(self): + class State(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("s", torch.arange(3)) + + def forward(self, x): + self.s.add_(1) + return torch.cumsum(self.s, 0) + x + + x = torch.zeros(3, dtype=torch.int64) + folded = self.fold(State(), (x,)) + eager = State() + for _ in range(2): + torch.testing.assert_close(folded(x), eager(x)) + + def test_folded_name_does_not_replace_a_buffer(self): + class Model(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer( + "_folded_cumsum_aten_cumsum_default", torch.ones(3) + ) + + def forward(self, x): + counts = torch.cumsum(torch.ones(3, dtype=torch.int64), 0) + return counts + x + self._folded_cumsum_aten_cumsum_default + + x = torch.zeros(3) + folded = self.fold(Model(), (x,)) + # The folded cumsum would have taken this name. + self.assertTrue(hasattr(folded, "_folded_cumsum_aten_cumsum_default_")) + torch.testing.assert_close(folded(x), Model()(x)) + + def test_float_cumsum_is_not_folded(self): + class Model(torch.nn.Module): + def forward(self, x): + return torch.cumsum(torch.ones(3), 0) + x + + folded = self.fold(Model(), (torch.zeros(3),)) + self.assertIn( + exir_ops.edge.aten.cumsum.default, + [n.target for n in folded.graph.nodes], + ) + + def test_random_draws_are_not_folded(self): + class Model(torch.nn.Module): + def forward(self, x): + c = torch.ones(3) + r = torch.cond( + torch.tensor(True), + lambda c: (torch.rand(3) * 100 + c).long(), + lambda c: c.long(), + (c,), + ) + direct = (torch.rand(3) * 100).long() + return torch.cumsum(r, 0) + torch.cumsum(direct, 0) + x + + folded = self.fold(Model(), (torch.zeros(3, dtype=torch.int64),)) + targets = [n.target for n in folded.graph.nodes] + self.assertEqual(targets.count(exir_ops.edge.aten.cumsum.default), 2) + + def test_cumsum_over_a_split_constant_is_folded(self): + class Model(torch.nn.Module): + def forward(self, x): + part = torch.ones(6, dtype=torch.int64).split(3)[1] + return torch.cumsum(part, 0) + x + + x = torch.zeros(3, dtype=torch.int64) + folded = self.fold(Model(), (x,)) + self.assertNotIn( + exir_ops.edge.aten.cumsum.default, + [n.target for n in folded.graph.nodes], + ) + torch.testing.assert_close(folded(x), Model()(x)) + + def test_constants_are_folded_without_a_gpu(self): + class Model(torch.nn.Module): + def forward(self, x): + ones = torch.ones(3, dtype=torch.int64, device="cuda") + pinned = torch.ones(3, dtype=torch.int64, pin_memory=True) + return torch.cumsum(ones, 0).cpu() + torch.cumsum(pinned, 0) + x + + x = torch.zeros(3, dtype=torch.int64) + folded = self.fold(Model(), (x,)) + self.assertNotIn( + exir_ops.edge.aten.cumsum.default, + [n.target for n in folded.graph.nodes], + ) + torch.testing.assert_close(folded(x), torch.tensor([2, 4, 6])) From 0ed78205fd92d7849e47341f157d4636bbf66c73 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 20:35:40 -0700 Subject: [PATCH 09/21] Check the OpenVINO inference_precision before conversion A bad inference_precision value was only reported after the whole graph had been converted, which takes a while for a large model. The check now runs first. The test makes conversion fail if it starts, so it fails when the check comes after conversion. --- backends/openvino/preprocess.py | 30 +++++++++++------------ backends/openvino/tests/ops/test_addmm.py | 6 +++-- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/backends/openvino/preprocess.py b/backends/openvino/preprocess.py index a75976c2c25..83537f26f38 100644 --- a/backends/openvino/preprocess.py +++ b/backends/openvino/preprocess.py @@ -51,6 +51,20 @@ def preprocess( Returns: PreprocessResult: The result of preprocessing, including the compiled model bytes. """ + compile_options = {} + for spec in module_compile_spec: + compile_options[spec.key] = spec.value.decode() + device = compile_options.get("device", "CPU") + requested_precision = compile_options.get("inference_precision") + # Some OpenVINO releases accept an unknown value. Checking before the + # conversion fails fast and keeps it out of the program, where the + # runtime would only fail at load. + if requested_precision and requested_precision not in _PRECISIONS: + raise ValueError( + f"OpenVINO backend: unsupported inference_precision " + f"'{requested_precision}', expected one of {', '.join(_PRECISIONS)}" + ) + for pass_cls in [ DimOrderOpsRevertPass, DecomposeFloorDividePass, @@ -82,10 +96,6 @@ def preprocess( f"{type(val).__name__}; only tensors and ints are supported" ) - compile_options = {} - for spec in module_compile_spec: - compile_options[spec.key] = spec.value.decode() - # The converter chooses some lowerings (e.g. boolean-mask indexing) from # the input types, so they must be known before conversion starts. module = edge_program.module() @@ -100,19 +110,9 @@ def preprocess( for i in int_inputs: ppp.input(i).tensor().set_shape([1]) ppp.input(i).preprocess().custom(lambda x: opset13.squeeze(x, 0).output(0)) - device = compile_options.get("device", "CPU") # On ARM CPUs OpenVINO fixes the precision here, not when the runtime # imports the model, so this must follow the same rule as the runtime. - precision = compile_options.get("inference_precision") or ( - "f32" if device.startswith("CPU") else None - ) - # Some OpenVINO releases accept an unknown value here. Checking now keeps - # it out of the program, where the runtime would only fail at load. - if precision and precision not in _PRECISIONS: - raise ValueError( - f"OpenVINO backend: unsupported inference_precision '{precision}', " - f"expected one of {', '.join(_PRECISIONS)}" - ) + precision = requested_precision or ("f32" if device.startswith("CPU") else None) config = {"INFERENCE_PRECISION_HINT": precision} if precision else {} compiled = compile_model(ppp.build(), device, config) model_bytes = compiled.export_model() diff --git a/backends/openvino/tests/ops/test_addmm.py b/backends/openvino/tests/ops/test_addmm.py index 95e63c0786a..7a170ff6d41 100644 --- a/backends/openvino/tests/ops/test_addmm.py +++ b/backends/openvino/tests/ops/test_addmm.py @@ -58,9 +58,11 @@ def test_addmm_passes_the_precision_to_openvino(self): compile_model.call_args.args[2], {"INFERENCE_PRECISION_HINT": hint} ) - def test_addmm_refuses_an_unknown_inference_precision(self): + def test_addmm_refuses_an_unknown_inference_precision_before_converting(self): sample_input = tuple(torch.randn(4, 4) for _ in range(3)) - with self.assertRaisesRegex(ValueError, "unsupported inference_precision 'f7'"): + with mock.patch.object( + preprocess, "FrontEndManager", side_effect=AssertionError("converted") + ), self.assertRaisesRegex(ValueError, "unsupported inference_precision 'f7'"): self.execute_layer_test( self.create_model(), sample_input, From b8fc9a0c899d9df6066da5a4326a25c27794c4d2 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 20:36:20 -0700 Subject: [PATCH 10/21] Fold each constant OpenVINO cumsum on its own FoldConstantCumsumPass evaluated every constant int64 cumsum in one go. If any of them could not be evaluated on the CPU, none were folded, and lowering only logged a generic warning. The unfolded cumsums then give wrong results from the second inference on with the affected OpenVINO CPU plugin. The pass now evaluates each cumsum separately, so one failure no longer keeps the others from being folded, and the warning names the cumsum and says that its results can be wrong. --- .../_passes/fold_constant_cumsum_pass.py | 29 ++++++++++++------- .../openvino/tests/ops/test_reductions.py | 29 ++++++++++++++++++- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/backends/openvino/_passes/fold_constant_cumsum_pass.py b/backends/openvino/_passes/fold_constant_cumsum_pass.py index 1009238b02f..eee6188175c 100644 --- a/backends/openvino/_passes/fold_constant_cumsum_pass.py +++ b/backends/openvino/_passes/fold_constant_cumsum_pass.py @@ -107,23 +107,32 @@ def call(self, graph_module: torch.fx.GraphModule) -> PassResult: and n.target == exir_ops.edge.aten.cumsum.default and n.meta["val"].dtype == torch.int64 ] - try: - values = _evaluate(graph_module, cumsums) - except RuntimeError as e: - # Some ops have modes the CPU lacks; OpenVINO still gets the cumsum. - logging.warning("OpenVINO: not folding constant cumsums: %s", e) - return PassResult(graph_module, False) - + modified = False + # One at a time, so a cumsum that cannot be evaluated does not keep + # the others from being folded. for node in cumsums: + try: + value = _evaluate(graph_module, [node])[node] + except RuntimeError as e: + # Some ops have modes the CPU lacks. + logging.warning( + "OpenVINO: could not fold %s, so on a CPU some OpenVINO " + "releases give wrong results for it from the second " + "inference on: %s", + node.name, + e, + ) + continue name = f"_folded_cumsum_{node.name}" while hasattr(graph_module, name): name += "_" - graph_module.register_buffer(name, values[node]) + graph_module.register_buffer(name, value) with graph.inserting_before(node): folded = graph.get_attr(name) folded.meta = node.meta.copy() node.replace_all_uses_with(folded) - if cumsums: + modified = True + if modified: graph.eliminate_dead_code() graph_module.recompile() - return PassResult(graph_module, bool(cumsums)) + return PassResult(graph_module, modified) diff --git a/backends/openvino/tests/ops/test_reductions.py b/backends/openvino/tests/ops/test_reductions.py index 2a6e47caba4..bb5df2b513d 100644 --- a/backends/openvino/tests/ops/test_reductions.py +++ b/backends/openvino/tests/ops/test_reductions.py @@ -1,9 +1,13 @@ # mypy: disable-error-code=import-not-found import unittest +from unittest import mock import torch -from executorch.backends.openvino._passes import FoldConstantCumsumPass +from executorch.backends.openvino._passes import ( + fold_constant_cumsum_pass, + FoldConstantCumsumPass, +) from executorch.backends.openvino.tests.ops.base_openvino_op_test import ( BaseOpenvinoOpTest, ) @@ -103,6 +107,29 @@ def forward(self, x): self.assertTrue(hasattr(folded, "_folded_cumsum_aten_cumsum_default_")) torch.testing.assert_close(folded(x), Model()(x)) + def test_a_cumsum_that_cannot_be_evaluated_leaves_the_others_folded(self): + class Model(torch.nn.Module): + def forward(self, x): + ones = torch.ones(3, dtype=torch.int64) + return torch.cumsum(ones, 0) + torch.cumsum(ones * 2, 0) + x + + evaluate = fold_constant_cumsum_pass._evaluate + + def fail_on_the_doubled_one(graph_module, targets): + if any("mul" in str(n.args[0].target) for n in targets): + raise RuntimeError("not on the CPU") + return evaluate(graph_module, targets) + + x = torch.zeros(3, dtype=torch.int64) + with mock.patch.object( + fold_constant_cumsum_pass, "_evaluate", side_effect=fail_on_the_doubled_one + ), self.assertLogs(level="WARNING") as logs: + folded = self.fold(Model(), (x,)) + targets = [n.target for n in folded.graph.nodes] + self.assertEqual(targets.count(exir_ops.edge.aten.cumsum.default), 1) + self.assertIn("wrong results", logs.output[0]) + torch.testing.assert_close(folded(x), Model()(x)) + def test_float_cumsum_is_not_folded(self): class Model(torch.nn.Module): def forward(self, x): From 025dab3e712491522dec49465913d78cec20c935 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 20:37:29 -0700 Subject: [PATCH 11/21] Test more of the OpenVINO integer, 0-d and dynamic size fixes Add the cases the earlier tests left out: - sum over a bool mask with more than 255 true values in a row, and over int32, which must come out in int64 as in PyTorch; - a full prod (prod.default) next to prod over one dimension; - 0-d tensors as inputs of the delegate, not only as outputs; - the earlier static output computed from a data dependent view, kept next to the new dynamic output case. The float32 addmm test now skips on CPUs where OpenVINO computes in float32 anyway, so a runner that cannot check the default reports a skip instead of a pass. --- backends/openvino/tests/ops/test_add.py | 9 ++++++ backends/openvino/tests/ops/test_addmm.py | 6 ++++ .../openvino/tests/ops/test_reductions.py | 32 ++++++++++++++----- backends/openvino/tests/ops/test_view.py | 20 +++++++++--- 4 files changed, 54 insertions(+), 13 deletions(-) diff --git a/backends/openvino/tests/ops/test_add.py b/backends/openvino/tests/ops/test_add.py index 5b68d0ff149..a976f7cc7fd 100644 --- a/backends/openvino/tests/ops/test_add.py +++ b/backends/openvino/tests/ops/test_add.py @@ -20,3 +20,12 @@ def test_add(self): module = self.create_model() sample_input = (torch.randn(2, 5, 1, 3), torch.randn(2, 5, 1, 3)) self.execute_layer_test(module, sample_input) + + def test_add_0d_inputs(self): + module = self.create_model() + for sample_input in ( + (torch.randn(2, 3), torch.tensor(2.5)), + (torch.tensor(1.5), torch.tensor(2.5)), + ): + with self.subTest(shapes=[list(x.shape) for x in sample_input]): + self.execute_layer_test(module, sample_input) diff --git a/backends/openvino/tests/ops/test_addmm.py b/backends/openvino/tests/ops/test_addmm.py index 7a170ff6d41..838f2321d7c 100644 --- a/backends/openvino/tests/ops/test_addmm.py +++ b/backends/openvino/tests/ops/test_addmm.py @@ -1,3 +1,6 @@ +# mypy: disable-error-code=import-not-found + + from unittest import mock import torch @@ -6,6 +9,7 @@ BaseOpenvinoOpTest, ) from executorch.exir.backend.backend_details import CompileSpec +from openvino import Core, Type # type: ignore[import-untyped] class TestAddMMOperator(BaseOpenvinoOpTest): @@ -34,6 +38,8 @@ def test_addmm(self): def test_addmm_computes_in_float32(self): if not self.device.startswith("CPU"): self.skipTest("only CPUs compute in float32 by default") + if Core().get_property("CPU", "INFERENCE_PRECISION_HINT") == Type.f32: + self.skipTest("OpenVINO already computes in float32 on this CPU") # OpenVINO would otherwise compute this in bfloat16 on x86 CPUs with # bfloat16 units, or in float16 on ARM CPUs, far outside 1e-4. self.atol = self.rtol = 1e-4 diff --git a/backends/openvino/tests/ops/test_reductions.py b/backends/openvino/tests/ops/test_reductions.py index bb5df2b513d..616e1589f97 100644 --- a/backends/openvino/tests/ops/test_reductions.py +++ b/backends/openvino/tests/ops/test_reductions.py @@ -33,17 +33,33 @@ def forward(self, x): with self.subTest(dtype=x.dtype): self.execute_layer_test(CumSum(), (x,)) - def test_prod(self): - if "torch.ops.aten.prod.dim_int" not in OperatorSupport([])._support_dict: - self.skipTest("this OpenVINO release does not delegate prod") + def test_sum(self): + class Sum(torch.nn.Module): + def forward(self, x): + return x.sum(1) + # More than 255 true values in a row, which 8 bits cannot count. + mask = torch.arange(800).reshape(2, 400) % 4 != 0 + for x in (mask, torch.randint(0, 5, (2, 7), dtype=torch.int32)): + with self.subTest(dtype=x.dtype): + self.execute_layer_test(Sum(), (x,)) + + def test_prod(self): class Prod(torch.nn.Module): - def forward(self, x): - return x.prod(1) + def __init__(self, dim): + super().__init__() + self.dim = dim - self.execute_layer_test( - Prod(), (torch.randint(1, 4, (2, 5), dtype=torch.int32),) - ) + def forward(self, x): + return x.prod() if self.dim is None else x.prod(self.dim) + + support = OperatorSupport([])._support_dict + x = torch.randint(1, 4, (2, 5), dtype=torch.int32) + for dim, op in ((1, "prod.dim_int"), (None, "prod.default")): + with self.subTest(op=op): + if f"torch.ops.aten.{op}" not in support: + self.skipTest(f"this OpenVINO release does not delegate {op}") + self.execute_layer_test(Prod(dim), (x,)) def test_attention_masks_with_more_valid_tokens(self): class AttentionMasks(torch.nn.Module): diff --git a/backends/openvino/tests/ops/test_view.py b/backends/openvino/tests/ops/test_view.py index ed354f0882f..4e1e853e028 100644 --- a/backends/openvino/tests/ops/test_view.py +++ b/backends/openvino/tests/ops/test_view.py @@ -36,15 +36,25 @@ def test_view(self): def test_view_data_dependent_size(self): class View(torch.nn.Module): + def __init__(self, reduce): + super().__init__() + self.reduce = reduce + def forward(self, x, n): n = n.item() torch._check(n >= 1) torch._check(n <= x.shape[0]) - return x[:n].view(n, -1) + y = x[:n].view(n, -1) + return y.sum(0) if self.reduce else y # The size reaches the OpenVINO partition as a SymInt input. The second - # call needs a larger output than the first one returned. + # call needs a larger output than the first one returned, unless the + # sum makes the output static. x = torch.randn(4, 8) - self.execute_layer_test( - View(), (x, torch.tensor(3)), more_inputs=((x, torch.tensor(4)),) - ) + for reduce in (False, True): + with self.subTest(reduce=reduce): + self.execute_layer_test( + View(reduce), + (x, torch.tensor(3)), + more_inputs=((x, torch.tensor(4)),), + ) From b3bae51f1047f6c4ee99c0694a7783b6c3f89029 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 20:37:59 -0700 Subject: [PATCH 12/21] Clarify the OpenVINO precision docs The Precision section listed which x86 instruction sets give bfloat16 and how CPUs without bfloat16 units simulate it. That is OpenVINO's behavior and changes with its releases, so link to OpenVINO's Precision Control page instead, and keep only what the f16 and bf16 values do on CPUs without float16 and on ARM. Also say: - that the float32 default applies when the device is CPU, and also to programs exported in float16; - that bf16 brings back the old speed of an older program on x86, and that on ARM an older program keeps float16 until it is exported again; - that OpenVINO's CPU plugin computes int64 in 32 bits, so an integer result past the int32 range wraps without an error, including the int64 result of cumsum and prod over smaller integer types. --- backends/openvino/README.md | 27 ++++++++++++++++++--------- docs/source/build-run-openvino.md | 27 ++++++++++++++++++--------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/backends/openvino/README.md b/backends/openvino/README.md index 75564743441..274182c94eb 100644 --- a/backends/openvino/README.md +++ b/backends/openvino/README.md @@ -50,7 +50,7 @@ print(_get_registered_backend_names()) ## Precision -On a CPU, a program computes in float32 by default. Without this default, +When the device is `CPU`, a program computes in float32 by default. Without this default, OpenVINO would compute in bfloat16 on x86 CPUs with bfloat16 units, which keeps only two to three significant digits, and in float16 on ARM CPUs, which keeps about three. Both are faster. To trade accuracy for speed, ask for the faster precision when @@ -70,18 +70,27 @@ float16, as measured on an Orin Nano. The value is OpenVINO's `INFERENCE_PRECISION_HINT`: `f32`, `f16` or `bf16`. An empty value means the default, and any other value fails at lowering. A CPU -without float16 support computes an `f16` program in float32. With `bf16`, x86 -CPUs that have AVX-512 (F, BW, DQ and VL) or AVX2-VNNI-2 compute in bfloat16, -simulating it with the same lower accuracy if they have no bfloat16 units, and -other x86 CPUs compute in float32. OpenVINO has no bfloat16 on ARM CPUs, so -there `bf16` keeps OpenVINO's ARM default: float16 if the CPU supports it, -float32 otherwise. Other devices, such as GPUs, keep OpenVINO's default unless -the option is set. +without float16 support computes an `f16` program in float32. OpenVINO has no +bfloat16 on ARM CPUs, so there `bf16` keeps the ARM default, float16 where the +CPU supports it. Which x86 CPUs compute in bfloat16 is up to OpenVINO; see its +[Precision Control](https://docs.openvino.ai/2026/openvino-workflow/running-inference/optimize-inference/precision-control.html) +page. Other devices, such as GPUs, keep OpenVINO's default unless the option is +set. + +The default also applies to a program exported in float16: on a CPU it computes +in float32 unless you lower it with `inference_precision`. On x86 CPUs, OpenVINO applies the precision when the runtime loads a program. So a program exported before this default also computes in float32 with this runtime, which is slower and can use more memory. Export it again with -`inference_precision` to get the old speed. +`inference_precision` set to `bf16` to get the old speed. On ARM CPUs, OpenVINO +applies the precision when the program is lowered, so such a program keeps +computing in float16 until it is exported again. + +OpenVINO's CPU plugin computes 64-bit integers in 32 bits. An integer result +outside the int32 range wraps around without an error. This applies to every +int64 tensor, including the int64 result of `cumsum` and `prod` over smaller +integer types. ## Directory Structure diff --git a/docs/source/build-run-openvino.md b/docs/source/build-run-openvino.md index 721e23a518b..c5d64da976c 100644 --- a/docs/source/build-run-openvino.md +++ b/docs/source/build-run-openvino.md @@ -104,7 +104,7 @@ Follow the steps below to setup your build environment: ## Precision -On a CPU, a program computes in float32 by default. Without this default, +When the device is `CPU`, a program computes in float32 by default. Without this default, OpenVINO would compute in bfloat16 on x86 CPUs with bfloat16 units, which keeps only two to three significant digits, and in float16 on ARM CPUs, which keeps about three. Both are faster. To trade accuracy for speed, ask for the faster precision when @@ -124,18 +124,27 @@ float16, as measured on an Orin Nano. The value is OpenVINO's `INFERENCE_PRECISION_HINT`: `f32`, `f16` or `bf16`. An empty value means the default, and any other value fails at lowering. A CPU -without float16 support computes an `f16` program in float32. With `bf16`, x86 -CPUs that have AVX-512 (F, BW, DQ and VL) or AVX2-VNNI-2 compute in bfloat16, -simulating it with the same lower accuracy if they have no bfloat16 units, and -other x86 CPUs compute in float32. OpenVINO has no bfloat16 on ARM CPUs, so -there `bf16` keeps OpenVINO's ARM default: float16 if the CPU supports it, -float32 otherwise. Other devices, such as GPUs, keep OpenVINO's default unless -the option is set. +without float16 support computes an `f16` program in float32. OpenVINO has no +bfloat16 on ARM CPUs, so there `bf16` keeps the ARM default, float16 where the +CPU supports it. Which x86 CPUs compute in bfloat16 is up to OpenVINO; see its +[Precision Control](https://docs.openvino.ai/2026/openvino-workflow/running-inference/optimize-inference/precision-control.html) +page. Other devices, such as GPUs, keep OpenVINO's default unless the option is +set. + +The default also applies to a program exported in float16: on a CPU it computes +in float32 unless you lower it with `inference_precision`. On x86 CPUs, OpenVINO applies the precision when the runtime loads a program. So a program exported before this default also computes in float32 with this runtime, which is slower and can use more memory. Export it again with -`inference_precision` to get the old speed. +`inference_precision` set to `bf16` to get the old speed. On ARM CPUs, OpenVINO +applies the precision when the program is lowered, so such a program keeps +computing in float16 until it is exported again. + +OpenVINO's CPU plugin computes 64-bit integers in 32 bits. An integer result +outside the int32 range wraps around without an error. This applies to every +int64 tensor, including the int64 result of `cumsum` and `prod` over smaller +integer types. ## Build Instructions for Examples From a7c8673692c79bc04fb067267e35374f7f35a3e1 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 20:58:29 -0700 Subject: [PATCH 13/21] Leave sum to OpenVINO's own int64 handling CastReductionInputPass also cast the input of sum to int64. OpenVINO's PyTorch frontend already does that for sum, so the extra cast changed nothing. Only cumsum and prod need it. test_sum, over a bool mask with more than 255 true values in a row and over int32, passes with and without the cast, and keeps checking that a sum comes out in int64 as in PyTorch. --- backends/openvino/_passes/cast_reduction_input_pass.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backends/openvino/_passes/cast_reduction_input_pass.py b/backends/openvino/_passes/cast_reduction_input_pass.py index 2ce5c21b172..35a71e43c73 100644 --- a/backends/openvino/_passes/cast_reduction_input_pass.py +++ b/backends/openvino/_passes/cast_reduction_input_pass.py @@ -9,7 +9,6 @@ from executorch.exir.pass_base import ExportPass, PassResult REDUCTION_OPS = { - exir_ops.edge.aten.sum.dim_IntList, exir_ops.edge.aten.cumsum.default, exir_ops.edge.aten.prod.default, exir_ops.edge.aten.prod.dim_int, @@ -17,11 +16,12 @@ class CastReductionInputPass(ExportPass): - """Cast the input of sum, cumsum and prod to the dtype of their result. + """Cast the input of cumsum and prod to the dtype of their result. PyTorch reduces bool and integer tensors in int64, but OpenVINO keeps a - narrower type for some of them. A cumsum over a bool mask is computed in - 8 bits and wraps after 255, and a cumsum or prod over int32 returns int32. + narrower type for these two, though not for sum. A cumsum over a bool mask + is computed in 8 bits and wraps after 255, and a cumsum or prod over int32 + returns int32. """ def call(self, graph_module: torch.fx.GraphModule) -> PassResult: From 04c97634ee1c064b4fa8588f7a4cdfd296afd82d Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 21:18:04 -0700 Subject: [PATCH 14/21] Copy OpenVINO outputs with a dynamic size out of a buffer OpenVINO owns To let an output come back smaller than its upper bound, the backend recorded each output's size at the first call on a delegate handle, and restored it before every later call. Programs from ExecuTorch 0.7 and earlier run every call of a lowered module on one handle, each with its own output tensors, so a second call with a larger output than the first failed: OpenVINO cannot write more than a borrowed buffer's shape holds. An output whose size can change now goes to a buffer that OpenVINO owns and grows as needed, one per output, made on first use and reused by every call. After inference, the backend resizes the ExecuTorch output to OpenVINO's shape and copies the data in. Each call overwrites that buffer, so nothing from one call reaches the next. Static outputs still borrow the ExecuTorch buffer, with no copy. The new test lowers one module, calls it from two places with different output sizes on one shared handle, and runs the program twice. --- backends/openvino/runtime/OpenvinoApi.h | 7 + backends/openvino/runtime/OpenvinoBackend.cpp | 145 +++++++++++------- backends/openvino/runtime/OpenvinoBackend.h | 17 +- backends/openvino/tests/ops/test_view.py | 56 +++++++ 4 files changed, 162 insertions(+), 63 deletions(-) diff --git a/backends/openvino/runtime/OpenvinoApi.h b/backends/openvino/runtime/OpenvinoApi.h index dfb1e65c3b4..b4aa25f9d32 100644 --- a/backends/openvino/runtime/OpenvinoApi.h +++ b/backends/openvino/runtime/OpenvinoApi.h @@ -102,8 +102,12 @@ using ov_infer_request_get_output_tensor_by_index_fn = using ov_infer_request_free_fn = void (*)(ov_infer_request_t*); using ov_tensor_create_from_host_ptr_fn = ov_status_e (*)(ov_element_type_e, ov_shape_t, void*, ov_tensor_t**); +using ov_tensor_create_fn = + ov_status_e (*)(ov_element_type_e, ov_shape_t, ov_tensor_t**); using ov_tensor_free_fn = void (*)(ov_tensor_t*); using ov_tensor_get_shape_fn = ov_status_e (*)(const ov_tensor_t*, ov_shape_t*); +using ov_tensor_get_byte_size_fn = ov_status_e (*)(const ov_tensor_t*, size_t*); +using ov_tensor_data_fn = ov_status_e (*)(const ov_tensor_t*, void**); using ov_shape_create_fn = ov_status_e (*)(int64_t, const int64_t*, ov_shape_t*); using ov_shape_free_fn = ov_status_e (*)(ov_shape_t*); @@ -137,8 +141,11 @@ struct OpenvinoFunctions { infer_request_get_output_tensor_by_index = nullptr; ov_infer_request_free_fn infer_request_free = nullptr; ov_tensor_create_from_host_ptr_fn tensor_create_from_host_ptr = nullptr; + ov_tensor_create_fn tensor_create = nullptr; ov_tensor_free_fn tensor_free = nullptr; ov_tensor_get_shape_fn tensor_get_shape = nullptr; + ov_tensor_get_byte_size_fn tensor_get_byte_size = nullptr; + ov_tensor_data_fn tensor_data = nullptr; ov_shape_create_fn shape_create = nullptr; ov_shape_free_fn shape_free = nullptr; }; diff --git a/backends/openvino/runtime/OpenvinoBackend.cpp b/backends/openvino/runtime/OpenvinoBackend.cpp index 17627693cf6..245e9320970 100644 --- a/backends/openvino/runtime/OpenvinoBackend.cpp +++ b/backends/openvino/runtime/OpenvinoBackend.cpp @@ -257,8 +257,11 @@ bool OpenvinoBackend::ensure_loaded() const { ov_infer_request_get_output_tensor_by_index) LOAD_SYM(infer_request_free, ov_infer_request_free) LOAD_SYM(tensor_create_from_host_ptr, ov_tensor_create_from_host_ptr) + LOAD_SYM(tensor_create, ov_tensor_create) LOAD_SYM(tensor_free, ov_tensor_free) LOAD_SYM(tensor_get_shape, ov_tensor_get_shape) + LOAD_SYM(tensor_get_byte_size, ov_tensor_get_byte_size) + LOAD_SYM(tensor_data, ov_tensor_data) LOAD_SYM(shape_create, ov_shape_create) LOAD_SYM(shape_free, ov_shape_free) @@ -425,27 +428,24 @@ exr::Result OpenvinoBackend::init( } size_t num_outputs = 0; status = ov_.compiled_model_outputs_size(compiled_model, &num_outputs); - if (status != OV_STATUS_OK) { - ET_LOG(Error, "OpenVINO: failed to get output count (status=%d)", status); + ov_tensor_t** owned_outputs = nullptr; + if (status == OV_STATUS_OK && num_outputs > 0) { + owned_outputs = allocator->allocateList(num_outputs); + } + if (status != OV_STATUS_OK || (num_outputs > 0 && !owned_outputs)) { + ET_LOG(Error, "OpenVINO: failed to set up outputs (status=%d)", status); ov_.infer_request_free(infer_request); ov_.compiled_model_free(compiled_model); return exr::Error::Internal; } - int64_t* output_bound_sizes = nullptr; - if (num_outputs > 0) { - output_bound_sizes = allocator->allocateList( - num_outputs * exr::kTensorDimensionLimit); - if (!output_bound_sizes) { - ET_LOG(Error, "OpenVINO: failed to allocate output bounds"); - ov_.infer_request_free(infer_request); - ov_.compiled_model_free(compiled_model); - return exr::Error::MemoryAllocationFailed; - } + for (size_t i = 0; i < num_outputs; i++) { + owned_outputs[i] = nullptr; } new (handle) ExecutionHandle; handle->compiled_model = compiled_model; handle->infer_request = infer_request; - handle->output_bound_sizes = output_bound_sizes; + handle->owned_outputs = owned_outputs; + handle->num_outputs = num_outputs; return handle; } @@ -533,53 +533,49 @@ exr::Error OpenvinoBackend::execute( "OpenVINO: expected tensor for output %zu", i); auto output_tensor = args[num_inputs + i]->toTensor(); - // OpenVINO takes the buffer's shape as its capacity, so an output an - // earlier call shrank goes back to its upper bound first. - int64_t* bound = - execution_handle->output_bound_sizes + i * exr::kTensorDimensionLimit; - if (!execution_handle->output_bounds_recorded) { - std::copy( - output_tensor.sizes().begin(), output_tensor.sizes().end(), bound); + ov_tensor_t*& owned = execution_handle->owned_outputs[i]; + ov_tensor_t* tensor = nullptr; + if (output_tensor.shape_dynamism() == exa::TensorShapeDynamism::STATIC) { + auto result = create_ov_tensor(output_tensor); + if (!result.ok()) { + return result.error(); + } + tensor = result.get(); } else { - exr::Error error = exr::resize_tensor( - output_tensor, - exa::ArrayRef( - bound, static_cast(output_tensor.dim()))); - if (error != exr::Error::Ok) { - ET_LOG(Error, "OpenVINO: failed to restore output %zu", i); - return error; + // OpenVINO cannot write more than a borrowed buffer's shape holds, and + // an earlier call may have shrunk that shape. So an output whose size + // can change goes to a buffer OpenVINO owns and grows, and is copied + // out after inference. + if (!owned) { + auto result = create_ov_tensor(output_tensor, /*own_memory=*/true); + if (!result.ok()) { + return result.error(); + } + owned = result.get(); } + tensor = owned; } - auto result = create_ov_tensor(output_tensor); - if (!result.ok()) { - return result.error(); - } - ov_tensor_t* tensor = result.get(); status = ov_.infer_request_set_output_tensor_by_index( execution_handle->infer_request, i, tensor); // Safe to free: see shared_ptr ownership comment on input tensor above. - ov_.tensor_free(tensor); + if (tensor != owned) { + ov_.tensor_free(tensor); + } if (status != OV_STATUS_OK) { return exr::Error::Internal; } } - execution_handle->output_bounds_recorded = true; - status = ov_.infer_request_infer(execution_handle->infer_request); if (status != OV_STATUS_OK) { ET_LOG(Error, "OpenVINO: inference failed (status=%d)", status); return exr::Error::Internal; } - // An output with a dynamic size, such as one set by an int input, gets a - // buffer at its upper bound and can come back smaller. for (size_t i = 0; i < num_outputs; i++) { auto output_tensor = args[num_inputs + i]->toTensor(); - // A 0-d output has no size to change, and newer OpenVINO releases cannot - // return a rank-0 shape from ov_tensor_get_shape. - if (output_tensor.dim() == 0) { + if (output_tensor.shape_dynamism() == exa::TensorShapeDynamism::STATIC) { continue; } ov_tensor_t* ov_output = nullptr; @@ -590,23 +586,10 @@ exr::Error OpenvinoBackend::execute( Error, "OpenVINO: failed to get output %zu (status=%d)", i, status); return exr::Error::Internal; } - ov_shape_t shape = {}; - status = ov_.tensor_get_shape(ov_output, &shape); + exr::Error error = copy_output(ov_output, output_tensor); ov_.tensor_free(ov_output); - if (status != OV_STATUS_OK) { - ET_LOG( - Error, - "OpenVINO: failed to get the shape of output %zu (status=%d)", - i, - status); - return exr::Error::Internal; - } - exr::Error error = exr::resize_tensor( - output_tensor, - exa::ArrayRef(shape.dims, static_cast(shape.rank))); - ov_.shape_free(&shape); if (error != exr::Error::Ok) { - ET_LOG(Error, "OpenVINO: failed to resize output %zu", i); + ET_LOG(Error, "OpenVINO: failed to copy output %zu", i); return error; } } @@ -625,6 +608,13 @@ void OpenvinoBackend::destroy(exr::DelegateHandle* handle) const { ExecutionHandle* execution_handle = static_cast(handle); + for (size_t i = 0; i < execution_handle->num_outputs; i++) { + if (execution_handle->owned_outputs[i] && ov_.tensor_free) { + ov_.tensor_free(execution_handle->owned_outputs[i]); + execution_handle->owned_outputs[i] = nullptr; + } + } + if (execution_handle->infer_request && ov_.infer_request_free) { ov_.infer_request_free(execution_handle->infer_request); execution_handle->infer_request = nullptr; @@ -636,8 +626,45 @@ void OpenvinoBackend::destroy(exr::DelegateHandle* handle) const { } } +exr::Error OpenvinoBackend::copy_output( + const ov_tensor_t* ov_output, + const exa::Tensor& output) const { + ov_shape_t shape = {}; + ov_status_e status = ov_.tensor_get_shape(ov_output, &shape); + if (status != OV_STATUS_OK) { + ET_LOG( + Error, "OpenVINO: failed to get an output shape (status=%d)", status); + return exr::Error::Internal; + } + exr::Error error = exr::resize_tensor( + output, + exa::ArrayRef(shape.dims, static_cast(shape.rank))); + ov_.shape_free(&shape); + if (error != exr::Error::Ok) { + return error; + } + size_t nbytes = 0; + void* data = nullptr; + if (ov_.tensor_get_byte_size(ov_output, &nbytes) != OV_STATUS_OK || + ov_.tensor_data(ov_output, &data) != OV_STATUS_OK) { + ET_LOG(Error, "OpenVINO: failed to read an output"); + return exr::Error::Internal; + } + ET_CHECK_OR_RETURN_ERROR( + nbytes == output.nbytes(), + Internal, + "OpenVINO: output has %zu bytes, expected %zu", + nbytes, + output.nbytes()); + if (nbytes > 0) { + std::memcpy(output.mutable_data_ptr(), data, nbytes); + } + return exr::Error::Ok; +} + exr::Result OpenvinoBackend::create_ov_tensor( - const exa::Tensor& tensor) const { + const exa::Tensor& tensor, + bool own_memory) const { ov_element_type_e ov_type = convert_to_openvino_type(tensor.scalar_type()); if (ov_type == OV_ELEMENT_UNDEFINED) { return exr::Error::NotSupported; @@ -678,8 +705,10 @@ exr::Result OpenvinoBackend::create_ov_tensor( } ov_tensor_t* ov_tensor = nullptr; - status = ov_.tensor_create_from_host_ptr( - ov_type, shape, tensor.mutable_data_ptr(), &ov_tensor); + status = own_memory + ? ov_.tensor_create(ov_type, shape, &ov_tensor) + : ov_.tensor_create_from_host_ptr( + ov_type, shape, tensor.mutable_data_ptr(), &ov_tensor); ov_.shape_free(&shape); if (status != OV_STATUS_OK || !ov_tensor) { ET_LOG(Error, "OpenVINO: failed to create a tensor (status=%d)", status); diff --git a/backends/openvino/runtime/OpenvinoBackend.h b/backends/openvino/runtime/OpenvinoBackend.h index d552db39001..6984714ca1a 100644 --- a/backends/openvino/runtime/OpenvinoBackend.h +++ b/backends/openvino/runtime/OpenvinoBackend.h @@ -28,10 +28,10 @@ namespace openvino { struct ExecutionHandle { ov_compiled_model_t* compiled_model = nullptr; ov_infer_request_t* infer_request = nullptr; - // The sizes of each output at the first execute, which are its upper bound, - // kTensorDimensionLimit entries per output. - int64_t* output_bound_sizes = nullptr; - bool output_bounds_recorded = false; + // A buffer OpenVINO owns for each output whose size can change, made on + // first use and reused by every call; null for the others. + ov_tensor_t** owned_outputs = nullptr; + size_t num_outputs = 0; }; class OpenvinoBackend final : public ::exr::BackendInterface { @@ -53,7 +53,14 @@ class OpenvinoBackend final : public ::exr::BackendInterface { private: bool ensure_loaded() const; ov_element_type_e convert_to_openvino_type(exa::ScalarType scalar_type) const; - exr::Result create_ov_tensor(const exa::Tensor& tensor) const; + // With own_memory, the OpenVINO tensor gets its own buffer instead of + // borrowing the tensor's. + exr::Result create_ov_tensor( + const exa::Tensor& tensor, + bool own_memory = false) const; + exr::Error copy_output( + const ov_tensor_t* ov_output, + const exa::Tensor& output) const; mutable DlHandle lib_handle_; mutable OpenvinoFunctions ov_; diff --git a/backends/openvino/tests/ops/test_view.py b/backends/openvino/tests/ops/test_view.py index 4e1e853e028..792f39a9056 100644 --- a/backends/openvino/tests/ops/test_view.py +++ b/backends/openvino/tests/ops/test_view.py @@ -1,7 +1,16 @@ +import os +import tempfile + import torch +from executorch.backends.openvino.preprocess import OpenvinoBackend from executorch.backends.openvino.tests.ops.base_openvino_op_test import ( BaseOpenvinoOpTest, ) +from executorch.exir import to_edge +from executorch.exir.backend.backend_api import to_backend +from executorch.exir.backend.backend_details import CompileSpec +from executorch.runtime import Runtime +from torch.export import Dim, export op_params = [ {"input_shape": [2, 3, 2], "target_shape": [2, 6]}, @@ -58,3 +67,50 @@ def forward(self, x, n): (x, torch.tensor(3)), more_inputs=((x, torch.tensor(4)),), ) + + def test_one_delegate_called_with_different_output_sizes(self): + class Double(torch.nn.Module): + def forward(self, x): + return x * 2 + + x, y = torch.randn(2, 8), torch.randn(4, 8) + double = to_backend( + OpenvinoBackend.__name__, + to_edge( + export(Double(), (y,), dynamic_shapes=({0: Dim("n", min=1, max=4)},)) + ).exported_program(), + [CompileSpec("device", self.device.encode())], + ) + # Programs from ExecuTorch 0.7 and earlier run every call of a lowered + # module on one delegate handle, each call with its own outputs. + double._share_handle_across_calls = True + + class TwoCalls(torch.nn.Module): + def __init__(self): + super().__init__() + self.lowered = double + + def forward(self, x, y): + return self.lowered(x), self.lowered(y) + + dynamic_shapes = ( + {0: Dim("rows_x", min=1, max=2)}, + {0: Dim("rows_y", min=1, max=4)}, + ) + program = to_edge( + export(TwoCalls(), (x, y), dynamic_shapes=dynamic_shapes) + ).to_executorch() + self.assertEqual(len(program.executorch_program.execution_plan[0].delegates), 1) + + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_dir: + pte_path = os.path.join(tmp_dir, "model.pte") + with open(pte_path, "wb") as f: + program.write_to_file(f) + loaded = Runtime.get().load_program(pte_path) + method = loaded.load_method("forward") + assert method is not None + for inputs in ((x, y), (y[:1], y[:3])): + outputs = method.execute(inputs) + for output, value in zip(outputs, inputs): + torch.testing.assert_close(output, value * 2) + del method, loaded From f25c9b5f51c1b85bf5404d2041d73a0875c950c1 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 21:18:41 -0700 Subject: [PATCH 15/21] Fold constant cumsums of every integer type for OpenVINO FoldConstantCumsumPass only folded cumsums with an int64 result. OpenVINO's CPU plugin gives the same wrong results from the second inference on for an int32 cumsum over a constant, for example cumsum(mask, dtype=int32), so such a model still got wrong attention masks. The pass now folds any integer cumsum that reads only constants. Float cumsums are still left alone. The pi0-style mask test also runs with an int32 cumsum. --- .../_passes/fold_constant_cumsum_pass.py | 7 ++++--- backends/openvino/tests/ops/test_reductions.py | 18 +++++++++++++----- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/backends/openvino/_passes/fold_constant_cumsum_pass.py b/backends/openvino/_passes/fold_constant_cumsum_pass.py index eee6188175c..d517b04fe81 100644 --- a/backends/openvino/_passes/fold_constant_cumsum_pass.py +++ b/backends/openvino/_passes/fold_constant_cumsum_pass.py @@ -85,9 +85,9 @@ def _evaluate(graph_module: torch.fx.GraphModule, targets: list) -> dict: class FoldConstantCumsumPass(PassBase): - """Replace an int64 cumsum that reads only constants with its result. + """Replace an integer cumsum that reads only constants with its result. - With an int64 cumsum over a constant, OpenVINO's CPU plugin (2025.4 to + With an integer cumsum over a constant, OpenVINO's CPU plugin (2025.4 to 2026.4) gives wrong results from the second inference on: masks built from it keep only what earlier calls allowed. pi0 builds its attention masks this way. The OpenVINO nightly no longer does this, so the pass can go once @@ -105,7 +105,8 @@ def call(self, graph_module: torch.fx.GraphModule) -> PassResult: for n in graph.nodes if n in constants and n.target == exir_ops.edge.aten.cumsum.default - and n.meta["val"].dtype == torch.int64 + and not n.meta["val"].dtype.is_floating_point + and not n.meta["val"].dtype.is_complex ] modified = False # One at a time, so a cumsum that cannot be evaluated does not keep diff --git a/backends/openvino/tests/ops/test_reductions.py b/backends/openvino/tests/ops/test_reductions.py index 616e1589f97..56ac7ea5907 100644 --- a/backends/openvino/tests/ops/test_reductions.py +++ b/backends/openvino/tests/ops/test_reductions.py @@ -65,9 +65,13 @@ def test_attention_masks_with_more_valid_tokens(self): class AttentionMasks(torch.nn.Module): """Masks and positions from padding, as pi0 builds them.""" + def __init__(self, dtype): + super().__init__() + self.dtype = dtype + def forward(self, pad_masks): att_masks = torch.tensor([0] * 765 + [1] * 51, dtype=torch.bool) - cumsum = torch.cumsum(att_masks[None, :], dim=1) + cumsum = torch.cumsum(att_masks[None, :], dim=1, dtype=self.dtype) att_2d = cumsum[:, None, :] <= cumsum[:, :, None] pad_2d = pad_masks[:, None, :] * pad_masks[:, :, None] return att_2d & pad_2d, torch.cumsum(pad_masks, dim=1) - 1 @@ -75,10 +79,14 @@ def forward(self, pad_masks): def pad_masks(valid): return (torch.arange(816) < valid)[None, :] - # One loaded program, run again with more valid tokens. - self.execute_layer_test( - AttentionMasks(), (pad_masks(300),), more_inputs=((pad_masks(700),),) - ) + for dtype in (torch.int64, torch.int32): + with self.subTest(dtype=dtype): + # One loaded program, run again with more valid tokens. + self.execute_layer_test( + AttentionMasks(dtype), + (pad_masks(300),), + more_inputs=((pad_masks(700),),), + ) class TestFoldConstantCumsumPass(unittest.TestCase): From f8c594ae46df851de2149394c5eb9571b96a184e Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 23:36:28 -0700 Subject: [PATCH 16/21] Start OpenVINO-owned output buffers empty An output whose size can change goes to a buffer OpenVINO owns, kept for the life of the delegate. That buffer was created at the output's current size, which on the first call is its upper bound, so a large dynamic output kept a second full-size copy around even when calls produced much less. For example, dynamic float32 logits of shape [1, 2048, 128256] cost about 1 GB more. Create it empty instead, with the output's rank. OpenVINO grows it to the size it writes, as it already did when a call needed more. --- backends/openvino/runtime/OpenvinoBackend.cpp | 2 +- backends/openvino/runtime/OpenvinoBackend.h | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backends/openvino/runtime/OpenvinoBackend.cpp b/backends/openvino/runtime/OpenvinoBackend.cpp index 245e9320970..da9fa43c325 100644 --- a/backends/openvino/runtime/OpenvinoBackend.cpp +++ b/backends/openvino/runtime/OpenvinoBackend.cpp @@ -685,7 +685,7 @@ exr::Result OpenvinoBackend::create_ov_tensor( dims = dims_heap.get(); } for (int64_t d = 0; d < rank; d++) { - dims[d] = sizes[d]; + dims[d] = own_memory ? 0 : sizes[d]; } // shape_create refuses rank 0, so a 0-d tensor keeps the zero-initialized // shape, which describes a scalar. diff --git a/backends/openvino/runtime/OpenvinoBackend.h b/backends/openvino/runtime/OpenvinoBackend.h index 6984714ca1a..d75fa725ab0 100644 --- a/backends/openvino/runtime/OpenvinoBackend.h +++ b/backends/openvino/runtime/OpenvinoBackend.h @@ -54,7 +54,8 @@ class OpenvinoBackend final : public ::exr::BackendInterface { bool ensure_loaded() const; ov_element_type_e convert_to_openvino_type(exa::ScalarType scalar_type) const; // With own_memory, the OpenVINO tensor gets its own buffer instead of - // borrowing the tensor's. + // borrowing the tensor's. That buffer starts empty, with the tensor's rank, + // and OpenVINO grows it to the size it writes. exr::Result create_ov_tensor( const exa::Tensor& tensor, bool own_memory = false) const; From d8312afeb078b35182a89d0037cbb339ed8c0b83 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 23:36:28 -0700 Subject: [PATCH 17/21] Test that the OpenVINO runtime applies an explicit inference_precision On x86 CPUs OpenVINO takes the precision when the runtime loads a program, so lowering alone cannot show that an explicit inference_precision works. If the runtime dropped it, a bf16 program would quietly compute in float32 and every test would still pass. The float32 addmm test now also lowers the same 256x256 case with bf16 and checks that the result is not within 1e-4 of eager PyTorch. It runs where OpenVINO's own CPU default is not float32, like the float32 check. --- backends/openvino/tests/ops/test_addmm.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backends/openvino/tests/ops/test_addmm.py b/backends/openvino/tests/ops/test_addmm.py index 838f2321d7c..4a690e08b98 100644 --- a/backends/openvino/tests/ops/test_addmm.py +++ b/backends/openvino/tests/ops/test_addmm.py @@ -45,6 +45,14 @@ def test_addmm_computes_in_float32(self): self.atol = self.rtol = 1e-4 sample_input = tuple(torch.randn(256, 256) for _ in range(3)) self.execute_layer_test(self.create_model(), sample_input) + # On x86 only the runtime passes the precision on, so this fails if it + # drops an explicit one. + with self.assertRaisesRegex(AssertionError, "ref_output"): + self.execute_layer_test( + self.create_model(), + sample_input, + compile_specs=(CompileSpec("inference_precision", b"bf16"),), + ) def test_addmm_passes_the_precision_to_openvino(self): if not self.device.startswith("CPU"): From 59f34b1ac40fa494238049c2561c189181d93658 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 23:37:01 -0700 Subject: [PATCH 18/21] Reuse exir's in-place write check in the OpenVINO cumsum fold FoldConstantCumsumPass had its own check for buffers the graph writes to. It only looked at positional arguments of direct users, so a write through a view, a keyword argument or inside a higher-order op would have let it fold a buffer with its initial value. Today the only write is the positional copy_ the unlifted module adds, so nothing broke yet. Use exir's _nodes_written_in_place, which constant_prop_pass already uses and which covers those cases. --- .../_passes/fold_constant_cumsum_pass.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/backends/openvino/_passes/fold_constant_cumsum_pass.py b/backends/openvino/_passes/fold_constant_cumsum_pass.py index d517b04fe81..fee5f1f21d7 100644 --- a/backends/openvino/_passes/fold_constant_cumsum_pass.py +++ b/backends/openvino/_passes/fold_constant_cumsum_pass.py @@ -9,22 +9,11 @@ import torch from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.passes.constant_prop_pass import _nodes_written_in_place from torch.fx.passes.infra.pass_base import PassBase, PassResult from torch.utils import _pytree as pytree -def _is_written(node: torch.fx.Node) -> bool: - """Whether an op writes to node in place, as a mutated buffer is.""" - for user in node.users: - schema = getattr(user.target, "_schema", None) - if schema is None: - continue - for arg, value in zip(schema.arguments, user.args): - if value is node and arg.alias_info is not None and arg.alias_info.is_write: - return True - return False - - def _on_cpu(value): if isinstance(value, torch.Tensor): return value.cpu() @@ -36,12 +25,13 @@ def _on_cpu(value): def _constant_nodes(graph_module: torch.fx.GraphModule) -> set: """Nodes that depend only on constants. Graph order puts inputs first.""" constants = set() + written = _nodes_written_in_place(graph_module) for node in graph_module.graph.nodes: # Only tensors: a higher-order op such as cond reads its subgraphs, # which may hold a random op, through get_attr nodes too. if node.op == "get_attr": value = operator.attrgetter(node.target)(graph_module) - if isinstance(value, torch.Tensor) and not _is_written(node): + if isinstance(value, torch.Tensor) and node not in written: constants.add(node) elif ( node.op == "call_function" From 8d969271cdd7edf6901f8e0c389dca5b40d9a9d5 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 23:37:01 -0700 Subject: [PATCH 19/21] Base the OpenVINO reduction cast pass on PassBase CastReductionInputPass edits the graph in place and never calls ExportPass.call(), so it does not need ExportPass. Base it on PassBase, like FoldConstantCumsumPass. --- backends/openvino/_passes/cast_reduction_input_pass.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backends/openvino/_passes/cast_reduction_input_pass.py b/backends/openvino/_passes/cast_reduction_input_pass.py index 35a71e43c73..36e3f3795c0 100644 --- a/backends/openvino/_passes/cast_reduction_input_pass.py +++ b/backends/openvino/_passes/cast_reduction_input_pass.py @@ -6,7 +6,7 @@ import torch from executorch.exir.dialects._ops import ops as exir_ops -from executorch.exir.pass_base import ExportPass, PassResult +from torch.fx.passes.infra.pass_base import PassBase, PassResult REDUCTION_OPS = { exir_ops.edge.aten.cumsum.default, @@ -15,7 +15,7 @@ } -class CastReductionInputPass(ExportPass): +class CastReductionInputPass(PassBase): """Cast the input of cumsum and prod to the dtype of their result. PyTorch reduces bool and integer tensors in int64, but OpenVINO keeps a From f3e3b74b1d219a60c14889771ea765ff570645f2 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 23:37:15 -0700 Subject: [PATCH 20/21] Say which OpenVINO programs the float32 default applies to The Precision docs spoke of "a program exported before this default" running "with this runtime", which stops being clear once the change is in a release. Say instead that the default applies to every program lowered without inference_precision, including ones exported with an earlier ExecuTorch release. --- backends/openvino/README.md | 11 ++++++----- docs/source/build-run-openvino.md | 11 ++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/backends/openvino/README.md b/backends/openvino/README.md index 274182c94eb..40a245f12a0 100644 --- a/backends/openvino/README.md +++ b/backends/openvino/README.md @@ -81,11 +81,12 @@ The default also applies to a program exported in float16: on a CPU it computes in float32 unless you lower it with `inference_precision`. On x86 CPUs, OpenVINO applies the precision when the runtime loads a program. -So a program exported before this default also computes in float32 with this -runtime, which is slower and can use more memory. Export it again with -`inference_precision` set to `bf16` to get the old speed. On ARM CPUs, OpenVINO -applies the precision when the program is lowered, so such a program keeps -computing in float16 until it is exported again. +So a program lowered without `inference_precision` computes in float32, even +one exported with an ExecuTorch release that did not have this default. That +is slower and can use more memory. Export it again with `inference_precision` +set to `bf16` to get the earlier speed. On ARM CPUs, OpenVINO applies the +precision when the program is lowered, so a program exported with such an +earlier release keeps computing in float16 until it is exported again. OpenVINO's CPU plugin computes 64-bit integers in 32 bits. An integer result outside the int32 range wraps around without an error. This applies to every diff --git a/docs/source/build-run-openvino.md b/docs/source/build-run-openvino.md index c5d64da976c..c5f38a2d0d7 100644 --- a/docs/source/build-run-openvino.md +++ b/docs/source/build-run-openvino.md @@ -135,11 +135,12 @@ The default also applies to a program exported in float16: on a CPU it computes in float32 unless you lower it with `inference_precision`. On x86 CPUs, OpenVINO applies the precision when the runtime loads a program. -So a program exported before this default also computes in float32 with this -runtime, which is slower and can use more memory. Export it again with -`inference_precision` set to `bf16` to get the old speed. On ARM CPUs, OpenVINO -applies the precision when the program is lowered, so such a program keeps -computing in float16 until it is exported again. +So a program lowered without `inference_precision` computes in float32, even +one exported with an ExecuTorch release that did not have this default. That +is slower and can use more memory. Export it again with `inference_precision` +set to `bf16` to get the earlier speed. On ARM CPUs, OpenVINO applies the +precision when the program is lowered, so a program exported with such an +earlier release keeps computing in float16 until it is exported again. OpenVINO's CPU plugin computes 64-bit integers in 32 bits. An integer result outside the int32 range wraps around without an error. This applies to every From cc9b095925921cab6353e218fadea63da805e927 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Fri, 30 Oct 2026 00:16:56 -0700 Subject: [PATCH 21/21] Fold an OpenVINO cumsum over full_like of a static shape FoldConstantCumsumPass folds an integer cumsum over constants, because OpenVINO's CPU plugin gives wrong results for one from the second inference on. A cumsum over torch.ones_like(x) or zeros_like(x) reads the input, so it was not folded. But with a static shape OpenVINO folds the shape into a constant, and the cumsum then hits the same bug: masks built from it were wrong on a second call with more valid tokens. ones_like and zeros_like become full_like in the edge graph, which reads only the shape and dtype of its input. The pass now counts full_like of a static shape as a constant, and evaluates it from that shape. The attention mask test also builds its mask with ones_like, and fails without this change. --- .../_passes/fold_constant_cumsum_pass.py | 51 ++++++++++++++----- .../openvino/tests/ops/test_reductions.py | 18 +++++-- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/backends/openvino/_passes/fold_constant_cumsum_pass.py b/backends/openvino/_passes/fold_constant_cumsum_pass.py index fee5f1f21d7..fabc030b06a 100644 --- a/backends/openvino/_passes/fold_constant_cumsum_pass.py +++ b/backends/openvino/_passes/fold_constant_cumsum_pass.py @@ -14,6 +14,25 @@ from torch.utils import _pytree as pytree +def _reads_only_shape(node: torch.fx.Node) -> bool: + """full_like with a constant fill value reads only the shape and dtype of + its input. With a static shape its result is a constant, and OpenVINO + folds it into one too. + """ + if node.target != exir_ops.edge.aten.full_like.default: + return False + if any( + isinstance(a, torch.fx.Node) for a in (*node.args[1:], *node.kwargs.values()) + ): + return False + val = node.meta.get("val") + return isinstance(val, torch.Tensor) and all(isinstance(d, int) for d in val.shape) + + +def _inputs(node: torch.fx.Node) -> list: + return [] if _reads_only_shape(node) else node.all_input_nodes + + def _on_cpu(value): if isinstance(value, torch.Tensor): return value.cpu() @@ -33,11 +52,13 @@ def _constant_nodes(graph_module: torch.fx.GraphModule) -> set: value = operator.attrgetter(node.target)(graph_module) if isinstance(value, torch.Tensor) and node not in written: constants.add(node) - elif ( - node.op == "call_function" - and torch.Tag.nondeterministic_seeded - not in getattr(node.target, "tags", ()) - and all(n in constants for n in node.all_input_nodes) + elif node.op == "call_function" and ( + _reads_only_shape(node) + or ( + torch.Tag.nondeterministic_seeded + not in getattr(node.target, "tags", ()) + and all(n in constants for n in node.all_input_nodes) + ) ): constants.add(node) return constants @@ -51,24 +72,29 @@ def _evaluate(graph_module: torch.fx.GraphModule, targets: list) -> dict: node = stack.pop() if node not in needed: needed.add(node) - stack.extend(node.all_input_nodes) + stack.extend(_inputs(node)) order = [n for n in graph_module.graph.nodes if n in needed] - last_use = {i: n for n in order for i in n.all_input_nodes} + last_use = {i: n for n in order for i in _inputs(n)} values = {} for node in order: if node.op == "get_attr": values[node] = operator.attrgetter(node.target)(graph_module) continue - args, kwargs = torch.fx.node.map_arg( - (node.args, node.kwargs), lambda n: values[n] - ) + if _reads_only_shape(node): + like = node.args[0].meta["val"] + args = (torch.empty(like.shape, dtype=like.dtype), *node.args[1:]) + kwargs = dict(node.kwargs) + else: + args, kwargs = torch.fx.node.map_arg( + (node.args, node.kwargs), lambda n: values[n] + ) # The values are the same on any device, and lowering for OpenVINO # must not need the GPU a constant was made for. args, kwargs = pytree.tree_map(_on_cpu, (args, kwargs)) if kwargs.get("pin_memory"): kwargs = {**kwargs, "pin_memory": False} values[node] = node.target(*args, **kwargs) - for i in node.all_input_nodes: + for i in _inputs(node): if last_use[i] is node and i not in targets: del values[i] return values @@ -84,7 +110,8 @@ class FoldConstantCumsumPass(PassBase): the oldest supported OpenVINO release has the fix. Only these cumsums are folded; constants that feed no such cumsum, such as compressed weights, are left as they are. Runs on an unlifted module, where constants are get_attr - nodes; buffers the graph writes to are not constants. + nodes; buffers the graph writes to are not constants, and full_like of an + input with a static shape is one. """ def call(self, graph_module: torch.fx.GraphModule) -> PassResult: diff --git a/backends/openvino/tests/ops/test_reductions.py b/backends/openvino/tests/ops/test_reductions.py index 56ac7ea5907..66378bafb02 100644 --- a/backends/openvino/tests/ops/test_reductions.py +++ b/backends/openvino/tests/ops/test_reductions.py @@ -1,5 +1,6 @@ # mypy: disable-error-code=import-not-found +import itertools import unittest from unittest import mock @@ -65,12 +66,17 @@ def test_attention_masks_with_more_valid_tokens(self): class AttentionMasks(torch.nn.Module): """Masks and positions from padding, as pi0 builds them.""" - def __init__(self, dtype): + def __init__(self, dtype, from_input): super().__init__() self.dtype = dtype + self.from_input = from_input def forward(self, pad_masks): - att_masks = torch.tensor([0] * 765 + [1] * 51, dtype=torch.bool) + if self.from_input: + # Constant too, but only once OpenVINO folds the shape. + att_masks = torch.ones_like(pad_masks[0]) + else: + att_masks = torch.tensor([0] * 765 + [1] * 51, dtype=torch.bool) cumsum = torch.cumsum(att_masks[None, :], dim=1, dtype=self.dtype) att_2d = cumsum[:, None, :] <= cumsum[:, :, None] pad_2d = pad_masks[:, None, :] * pad_masks[:, :, None] @@ -79,11 +85,13 @@ def forward(self, pad_masks): def pad_masks(valid): return (torch.arange(816) < valid)[None, :] - for dtype in (torch.int64, torch.int32): - with self.subTest(dtype=dtype): + for dtype, from_input in itertools.product( + (torch.int64, torch.int32), (False, True) + ): + with self.subTest(dtype=dtype, from_input=from_input): # One loaded program, run again with more valid tokens. self.execute_layer_test( - AttentionMasks(dtype), + AttentionMasks(dtype, from_input), (pad_masks(300),), more_inputs=((pad_masks(700),),), )