Skip to content

Commit 0b72572

Browse files
[release/1.6] Fix OpenVINO scalars, precision, dynamic outputs and reductions (#24492)
Supersedes #24377 with a clean release pick from #24294. Fixes scalar tensors, float32 CPU defaults, dynamic output buffers and integer reductions. Includes the related #24396 runtime, folded-cumsum, parameter-count and CI runner follow-ups previously spread across #24377 and #24446. This pick contains the complete core OpenVINO fix and its required follow-ups. Bool-op handling and masked-read metadata are isolated in the next clean pick. The source float32 default can cost speed on CPUs with bfloat16 hardware; inference_precision=bf16 remains available. ## Scope and landing order Land after #24491 in the verified landing order. The OpenVINO pair follows the CUDA picks; these backend groups can otherwise be reviewed separately. This branch starts directly at release/1.6 commit `dfb78b9ecd933a5fee33ebc94f69ed4b71cbb1e7`. Its GitHub diff contains 16 files for this pick; it does not carry duplicate #24298 history or the excluded unmerged QNN work. Dependencies are documented here rather than embedded as other cherry-pick commits. ## Verification All changed Python files compile and pass Ruff F821 checks. All seven replacements merge cleanly in the documented sequence, including simulated squash merges. No QNN files are changed. The combined OpenVINO production code matches the previously reviewed final #24446 code; the bool test class is preserved unchanged. Native CUDA, OpenVINO and portable C++ suites were not run locally on this macOS host; PR CI remains required before landing. Prepared with OpenAI Codex.
1 parent 5253dfa commit 0b72572

16 files changed

Lines changed: 1187 additions & 54 deletions

‎.ci/scripts/test_openvino.sh‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,8 @@ source "$(dirname "${BASH_SOURCE[0]}")/utils.sh"
1212

1313
source openvino/setupvars.sh
1414
cd backends/openvino/tests
15-
python test_runner.py --test_type ops
16-
python test_runner.py --test_type models
15+
# Run the model tests even when an op test fails, and still fail the job.
16+
status=0
17+
python test_runner.py --test_type ops || status=1
18+
python test_runner.py --test_type models || status=1
19+
exit "${status}"

‎backends/openvino/README.md‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,63 @@ print(_get_registered_backend_names())
4848
# Should include 'OpenvinoBackend'
4949
```
5050

51+
## Precision
52+
53+
When the device is `CPU`, a program computes in float32 by default. Without this default,
54+
OpenVINO would compute in bfloat16 on x86 CPUs with bfloat16 units, which keeps only two to
55+
three significant digits, and in float16 on ARM CPUs, which keeps about three.
56+
Both are faster. To trade accuracy for speed, ask for the faster precision when
57+
lowering:
58+
59+
```python
60+
from executorch.backends.openvino.partitioner import OpenvinoPartitioner
61+
from executorch.exir.backend.compile_spec_schema import CompileSpec
62+
63+
OpenvinoPartitioner(
64+
[CompileSpec("device", b"CPU"), CompileSpec("inference_precision", b"bf16")]
65+
)
66+
```
67+
68+
On an ARM CPU, ask for `f16` instead: there float32 is about 1.8x slower than
69+
float16, as measured on an Orin Nano.
70+
71+
The value is OpenVINO's `INFERENCE_PRECISION_HINT`: `f32`, `f16` or `bf16`. An
72+
empty value means the default, and any other value fails at lowering. A CPU
73+
without float16 support computes an `f16` program in float32. OpenVINO has no
74+
bfloat16 on ARM CPUs, so there `bf16` keeps the ARM default, float16 where the
75+
CPU supports it. Which x86 CPUs compute in bfloat16 is up to OpenVINO; see its
76+
[Precision Control](https://docs.openvino.ai/2026/openvino-workflow/running-inference/optimize-inference/precision-control.html)
77+
page. Other devices, such as GPUs, keep OpenVINO's default unless the option is
78+
set.
79+
80+
The default also applies to a program exported in float16: on a CPU it computes
81+
in float32 unless you lower it with `inference_precision`.
82+
83+
On x86 CPUs, OpenVINO applies the precision when the runtime loads a program.
84+
So a program lowered without `inference_precision` computes in float32, even
85+
one exported with an ExecuTorch release that did not have this default. On an
86+
x86 CPU with bfloat16 units, float32 is slower and can use more memory; export it
87+
again with `inference_precision` set to `bf16` to get the earlier speed. Other
88+
x86 CPUs already computed such a program in float32. On ARM CPUs, OpenVINO applies the
89+
precision when the program is lowered, so a program exported with such an
90+
earlier release keeps computing in float16 until it is exported again.
91+
92+
OpenVINO's CPU plugin computes 64-bit integers in 32 bits. An integer result
93+
outside the int32 range wraps around without an error. This applies to every
94+
int64 tensor, including the int64 result of `cumsum` and `prod` over smaller
95+
integer types. In OpenVINO 2026.4.1, the CPU plugin also computes some int32 and
96+
int64 ops in float32. On x86 CPUs these are `sum`, `amax`, `amin`, `max` and
97+
`min`. On ARM CPUs they are element-wise ops such as `add`, `maximum` and
98+
`clamp`, and the same reductions along any dimension but the last. Once a value
99+
or a running total is above 16,777,216 (2^24) or below -16,777,216, such a
100+
result can be off, even inside the int32 range. For example, on x86 `sum` over a
101+
tensor that holds 16,777,216 and 1 gives 16,777,216, and so does `amax` over one
102+
that holds 16,777,217.
103+
104+
In OpenVINO 2026.4.1, the CPU plugin's `min(dim)` and `max(dim)` can return a
105+
later index on ties than PyTorch, which returns the first one. This happens with
106+
float32 as well as integer dtypes such as int32 and uint8.
107+
51108
## Directory Structure
52109

53110
```

‎backends/openvino/_passes/__init__.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44
# except in compliance with the License. See the license file found in the
55
# LICENSE file in the root directory of this source tree.
66

7+
from .cast_reduction_input_pass import CastReductionInputPass
78
from .decompose_floor_divide_pass import DecomposeFloorDividePass
9+
from .fold_constant_cumsum_pass import FoldConstantCumsumPass
810

9-
__all__ = ["DecomposeFloorDividePass"]
11+
__all__ = [
12+
"CastReductionInputPass",
13+
"DecomposeFloorDividePass",
14+
"FoldConstantCumsumPass",
15+
]
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
import torch
8+
from executorch.exir.dialects._ops import ops as exir_ops
9+
from torch.fx.passes.infra.pass_base import PassBase, PassResult
10+
11+
REDUCTION_OPS = {
12+
exir_ops.edge.aten.cumsum.default,
13+
exir_ops.edge.aten.prod.default,
14+
exir_ops.edge.aten.prod.dim_int,
15+
}
16+
17+
18+
class CastReductionInputPass(PassBase):
19+
"""Cast the input of cumsum and prod to the dtype of their result.
20+
21+
PyTorch reduces bool and integer tensors in int64, but OpenVINO keeps a
22+
narrower type for these two, though not for sum. A cumsum over a bool mask
23+
is computed in 8 bits and wraps after 255, and a cumsum or prod over int32
24+
returns int32.
25+
"""
26+
27+
def call(self, graph_module: torch.fx.GraphModule) -> PassResult:
28+
modified = False
29+
for node in graph_module.graph.nodes:
30+
if node.op != "call_function" or node.target not in REDUCTION_OPS:
31+
continue
32+
source = node.args[0]
33+
dtype = node.meta["val"].dtype
34+
if source.meta["val"].dtype == dtype:
35+
continue
36+
with graph_module.graph.inserting_before(node):
37+
cast = graph_module.graph.call_function(
38+
exir_ops.edge.aten._to_copy.default, (source,), {"dtype": dtype}
39+
)
40+
cast.meta["val"] = source.meta["val"].to(dtype)
41+
node.replace_input_with(source, cast)
42+
modified = True
43+
if modified:
44+
graph_module.recompile()
45+
return PassResult(graph_module, modified)
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
import logging
8+
import operator
9+
10+
import torch
11+
from executorch.exir.dialects._ops import ops as exir_ops
12+
from executorch.exir.passes.constant_prop_pass import _nodes_written_in_place
13+
from torch.fx.passes.infra.pass_base import PassBase, PassResult
14+
from torch.utils import _pytree as pytree
15+
16+
17+
def _reads_only_shape(node: torch.fx.Node) -> bool:
18+
"""full_like with a constant fill value reads only the shape and dtype of
19+
its input. With a static shape its result is a constant, and OpenVINO
20+
folds it into one too.
21+
"""
22+
if node.target != exir_ops.edge.aten.full_like.default:
23+
return False
24+
if any(
25+
isinstance(a, torch.fx.Node) for a in (*node.args[1:], *node.kwargs.values())
26+
):
27+
return False
28+
val = node.meta.get("val")
29+
return isinstance(val, torch.Tensor) and all(isinstance(d, int) for d in val.shape)
30+
31+
32+
def _inputs(node: torch.fx.Node) -> list:
33+
return [] if _reads_only_shape(node) else node.all_input_nodes
34+
35+
36+
def _on_cpu(value):
37+
if isinstance(value, torch.Tensor):
38+
return value.cpu()
39+
if isinstance(value, torch.device):
40+
return torch.device("cpu")
41+
return value
42+
43+
44+
def _constant_nodes(graph_module: torch.fx.GraphModule) -> set:
45+
"""Nodes that depend only on constants. Graph order puts inputs first."""
46+
constants = set()
47+
written = _nodes_written_in_place(graph_module)
48+
for node in graph_module.graph.nodes:
49+
# Only tensors: a higher-order op such as cond reads its subgraphs,
50+
# which may hold a random op, through get_attr nodes too.
51+
if node.op == "get_attr":
52+
value = operator.attrgetter(node.target)(graph_module)
53+
if isinstance(value, torch.Tensor) and node not in written:
54+
constants.add(node)
55+
elif node.op == "call_function" and (
56+
_reads_only_shape(node)
57+
or (
58+
torch.Tag.nondeterministic_seeded
59+
not in getattr(node.target, "tags", ())
60+
and all(n in constants for n in node.all_input_nodes)
61+
)
62+
):
63+
constants.add(node)
64+
return constants
65+
66+
67+
def _evaluate(
68+
graph_module: torch.fx.GraphModule, target: torch.fx.Node
69+
) -> torch.Tensor:
70+
"""Value of target, computing only what it reads, on the CPU."""
71+
needed = set()
72+
stack: list = [target]
73+
while stack:
74+
node = stack.pop()
75+
if node not in needed:
76+
needed.add(node)
77+
stack.extend(_inputs(node))
78+
order = [n for n in graph_module.graph.nodes if n in needed]
79+
last_use = {i: n for n in order for i in _inputs(n)}
80+
values = {}
81+
for node in order:
82+
if node.op == "get_attr":
83+
values[node] = operator.attrgetter(node.target)(graph_module)
84+
continue
85+
if _reads_only_shape(node):
86+
like = node.args[0].meta["val"]
87+
args = (
88+
torch.empty(like.shape, dtype=like.dtype, device="cpu"),
89+
*node.args[1:],
90+
)
91+
kwargs = dict(node.kwargs)
92+
else:
93+
args, kwargs = torch.fx.node.map_arg(
94+
(node.args, node.kwargs), lambda n: values[n]
95+
)
96+
# The values are the same on any device, and lowering for OpenVINO
97+
# must not need the GPU a constant was made for.
98+
args, kwargs = pytree.tree_map(_on_cpu, (args, kwargs))
99+
if kwargs.get("pin_memory"):
100+
kwargs = {**kwargs, "pin_memory": False}
101+
values[node] = node.target(*args, **kwargs)
102+
for i in _inputs(node):
103+
if last_use[i] is node:
104+
del values[i]
105+
return values[target]
106+
107+
108+
class FoldConstantCumsumPass(PassBase):
109+
"""Replace an integer cumsum that reads only constants with its result.
110+
111+
With an integer cumsum over a constant, OpenVINO's CPU plugin (2025.1 to
112+
2026.4) gives wrong results from the second inference on: masks built from
113+
it keep only what earlier calls allowed. pi0 builds its attention masks
114+
this way. The OpenVINO nightly no longer does this, so the pass can go once
115+
the oldest supported OpenVINO release has the fix. Only these cumsums are
116+
folded; constants that feed no such cumsum, such as compressed weights, are
117+
left as they are. Runs on an unlifted module, where constants are get_attr
118+
nodes; buffers the graph writes to are not constants, and full_like of an
119+
input with a static shape is one.
120+
"""
121+
122+
def call(self, graph_module: torch.fx.GraphModule) -> PassResult:
123+
graph = graph_module.graph
124+
constants = _constant_nodes(graph_module)
125+
cumsums = [
126+
n
127+
for n in graph.nodes
128+
if n in constants
129+
and n.target == exir_ops.edge.aten.cumsum.default
130+
and not n.meta["val"].dtype.is_floating_point
131+
and not n.meta["val"].dtype.is_complex
132+
]
133+
modified = False
134+
# One at a time, so a cumsum that cannot be evaluated does not keep
135+
# the others from being folded.
136+
for node in cumsums:
137+
try:
138+
value = _evaluate(graph_module, node)
139+
except RuntimeError as e:
140+
# Some ops have modes the CPU lacks.
141+
logging.warning(
142+
"OpenVINO: could not fold %s, so on a CPU some OpenVINO "
143+
"releases give wrong results for it from the second "
144+
"inference on: %s",
145+
node.name,
146+
e,
147+
)
148+
continue
149+
name = f"_folded_cumsum_{node.name}"
150+
while hasattr(graph_module, name):
151+
name += "_"
152+
graph_module.register_buffer(name, value)
153+
with graph.inserting_before(node):
154+
folded = graph.get_attr(name)
155+
folded.meta = node.meta.copy()
156+
node.replace_all_uses_with(folded)
157+
modified = True
158+
if modified:
159+
graph.eliminate_dead_code()
160+
graph_module.recompile()
161+
return PassResult(graph_module, modified)

0 commit comments

Comments
 (0)