Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .ci/scripts/setup-openvino.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ set -ex
source "$(dirname "${BASH_SOURCE[0]}")/utils.sh"

# Download and install OpenVINO from release packages
OPENVINO_VERSION="2025.3"
OPENVINO_BUILD="2025.3.0.19807.44526285f24"
OPENVINO_VERSION="2026.0"
OPENVINO_BUILD="2026.0.0.20965.c6d6a13a886"
OPENVINO_URL="https://storage.openvinotoolkit.org/repositories/openvino/packages/${OPENVINO_VERSION}/linux/openvino_toolkit_ubuntu22_${OPENVINO_BUILD}_x86_64.tgz"

curl -Lo /tmp/openvino_toolkit.tgz --retry 3 --fail ${OPENVINO_URL}
Expand Down
6 changes: 6 additions & 0 deletions .ci/scripts/test_backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ if [[ "$FLOW" == *arm* ]]; then
fi
fi

if [[ "$FLOW" == *openvino* ]]; then
# Setup OpenVINO environment
source .ci/scripts/setup-openvino.sh
EXTRA_BUILD_ARGS+=" -DEXECUTORCH_BUILD_OPENVINO=ON"
fi

if [[ $IS_MACOS -eq 1 ]]; then
SETUP_SCRIPT=.ci/scripts/setup-macos.sh
else
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/_test_backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
workflow_call:
inputs:
backend:
description: 'Backend to test (xnnpack, coreml, vulkan, qnn)'
description: 'Backend to test (xnnpack, coreml, vulkan, qnn, openvino)'
required: true
type: string
flows:
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/test-backend-openvino.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Test OpenVINO Backend

on:
schedule:
- cron: 0 2 * * *
push:
branches:
- release/*
tags:
- ciflow/nightly/*
pull_request:
paths:
- .github/workflows/test-backend-openvino.yml
- .github/workflows/_test_backend.yml
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pull_request.paths only watches workflow files, so changes to the OpenVINO backend/test harness (e.g. backends/openvino/**, .ci/scripts/**, backends/test/**) won’t trigger this workflow on PRs. This makes it easy for OpenVINO regressions to merge without any PR signal. Consider expanding paths similarly to other backend workflows (e.g. xnnpack) to include the relevant backend + harness directories.

Suggested change
- .github/workflows/_test_backend.yml
- .github/workflows/_test_backend.yml
- backends/openvino/**
- .ci/scripts/**
- backends/test/**

Copilot uses AI. Check for mistakes.
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}--${{ github.event.pull_request.number || github.sha }}-${{ github.event_name == 'workflow_dispatch' }}
cancel-in-progress: true

jobs:
test-openvino:
uses: ./.github/workflows/_test_backend.yml
with:
backend: openvino
flows: '["openvino"]'
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This workflow only runs the openvino flow, but the PR also registers an openvino_int8 flow in the test harness. If INT8 is intended to be supported/kept green, consider adding it to the flows list here so it gets CI coverage (or document why it’s intentionally excluded).

Suggested change
flows: '["openvino"]'
flows: '["openvino", "openvino_int8"]'

Copilot uses AI. Check for mistakes.
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
timeout: 120
run-linux: true
9 changes: 9 additions & 0 deletions backends/openvino/_passes/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Copyright (c) Intel Corporation
#
# Licensed under the BSD License (the "License"); you may not use this file
# except in compliance with the License. See the license file found in the
# LICENSE file in the root directory of this source tree.

from .decompose_floor_divide_pass import DecomposeFloorDividePass

__all__ = ["DecomposeFloorDividePass"]
106 changes: 106 additions & 0 deletions backends/openvino/_passes/decompose_floor_divide_pass.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Copyright (c) Intel Corporation
#
# Licensed under the BSD License (the "License"); you may not use this file
# except in compliance with the License. See the license file 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

# Ops to match
DIV_TENSOR_MODE_OPS = {
exir_ops.edge.aten.div.Tensor_mode,
torch.ops.aten.div.Tensor_mode,
}

# Replacement op sets per dialect
EDGE_OPS = {
"div": exir_ops.edge.aten.div.Tensor,
"floor": exir_ops.edge.aten.floor.default,
"to_copy": exir_ops.edge.aten._to_copy.default,
}

ATEN_OPS = {
"div": torch.ops.aten.div.Tensor,
"floor": torch.ops.aten.floor.default,
"to_copy": torch.ops.aten._to_copy.default,
}


def _get_opset(op):
if op is exir_ops.edge.aten.div.Tensor_mode:
return EDGE_OPS
if op is torch.ops.aten.div.Tensor_mode:
return ATEN_OPS
raise RuntimeError(f"Unexpected op: {op}")


def _node_dtype(node):
"""Return the dtype of a graph node's output, or None if unknown."""
if isinstance(node, torch.fx.Node):
val = node.meta.get("val")
if val is not None:
return val.dtype
return None


class DecomposeFloorDividePass(ExportPass):
"""Decompose div with rounding_mode='floor' for correct semantics.

ExecuTorch decomposes floor_divide into aten.div.Tensor_mode with
rounding_mode='floor'. OpenVINO implements this with truncation-toward-zero
semantics instead of PyTorch's floor-toward-negative-infinity.

For float inputs, replaces div(x, y, rounding_mode='floor') with
floor(div(x, y)).

For integer inputs, OpenVINO's integer division truncates toward zero, so
floor(int_div(x, y)) still gives truncation semantics. Instead we cast to
float32, divide, floor, then cast back:
_to_copy(floor(div(_to_copy(x, float32), _to_copy(y, float32))), int_dtype)
"""

def call(self, graph_module: torch.fx.GraphModule) -> PassResult:
graph = graph_module.graph

for node in list(graph.nodes):
if node.op != "call_function":
continue
if node.target not in DIV_TENSOR_MODE_OPS:
continue

rounding_mode = node.kwargs.get("rounding_mode")
if rounding_mode != "floor":
continue

opset = _get_opset(node.target)
a, b = node.args[0], node.args[1]

a_dtype = _node_dtype(a)
is_integer = a_dtype is not None and not a_dtype.is_floating_point

with graph.inserting_before(node):
if is_integer:
a_f = graph.call_function(
opset["to_copy"], (a,), {"dtype": torch.float32}
)
b_f = graph.call_function(
opset["to_copy"], (b,), {"dtype": torch.float32}
)
div_node = graph.call_function(opset["div"], (a_f, b_f))
floored = graph.call_function(opset["floor"], (div_node,))
result = graph.call_function(
opset["to_copy"], (floored,), {"dtype": a_dtype}
)
Comment on lines +77 to +95
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DecomposeFloorDividePass decides the integer-vs-float rewrite based only on a's dtype and then casts the result back to a_dtype. This is incorrect for mixed-dtype cases where div would promote to a floating output (e.g. int / float) and also risks wrong semantics when dtype metadata is missing. Consider using the node's output dtype (node.meta['val'].dtype) or torch.result_type(a_val, b_val) to choose the rewrite, and only cast back when the output dtype is integral.

Copilot uses AI. Check for mistakes.
else:
div_node = graph.call_function(opset["div"], (a, b))
result = graph.call_function(opset["floor"], (div_node,))

node.replace_all_uses_with(result)
graph.erase_node(node)

graph.eliminate_dead_code()
graph_module.recompile()
graph_module = super().call(graph_module).graph_module
return PassResult(graph_module, True)
Comment on lines +103 to +106
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pass always returns PassResult(..., True) even if it didn't rewrite any nodes. That can cause unnecessary downstream work and makes pass pipelines harder to reason about. Track whether any div.Tensor_mode(..., rounding_mode='floor') nodes were replaced and return changed=False when none were found.

Copilot uses AI. Check for mistakes.
12 changes: 6 additions & 6 deletions backends/openvino/preprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@

from typing import final, List

from executorch.backends.openvino._passes import DecomposeFloorDividePass

from executorch.exir.backend.backend_details import (
BackendDetails,
ExportedProgram,
PreprocessResult,
)
from executorch.exir.backend.compile_spec_schema import CompileSpec

from executorch.exir.passes.memory_format_ops_pass import DimOrderOpsRevertPass
from openvino.frontend.pytorch.torchdynamo.compile import ( # type: ignore[import-untyped]
openvino_compile,
Expand All @@ -38,11 +39,10 @@ def preprocess(
Returns:
PreprocessResult: The result of preprocessing, including the compiled model bytes.
"""
transformed_ep = DimOrderOpsRevertPass()(edge_program.graph_module)

# Update the edge_program with the transformed graph
if transformed_ep and transformed_ep.graph_module:
edge_program._graph_module = transformed_ep.graph_module
for pass_cls in [DimOrderOpsRevertPass, DecomposeFloorDividePass]:
result = pass_cls()(edge_program.graph_module)
if result and result.graph_module:
edge_program._graph_module = result.graph_module
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DecomposeFloorDividePass is now part of the OpenVINO preprocess pipeline, but there are no OpenVINO tests asserting correct floor-division semantics (especially for negative values where trunc vs floor differs). Please add a targeted unit/op test under backends/openvino/tests/ops that exercises torch.div(..., rounding_mode='floor') / torch.floor_divide on negative integer inputs and verifies OpenVINO output matches PyTorch.

Suggested change
edge_program._graph_module = result.graph_module
edge_program.graph_module = result.graph_module

Copilot uses AI. Check for mistakes.

input_names = edge_program.graph_signature.user_inputs
args = []
Expand Down
2 changes: 2 additions & 0 deletions backends/openvino/runtime/OpenvinoBackend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ ov::element::Type OpenvinoBackend::convert_to_openvino_type(
switch (scalar_type) {
case exa::ScalarType::Float:
return ov::element::f32;
case exa::ScalarType::Double:
return ov::element::f64;
case exa::ScalarType::Half:
return ov::element::f16;
case exa::ScalarType::Int:
Expand Down
2 changes: 1 addition & 1 deletion backends/openvino/scripts/openvino_build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ build_python_enabled() {
export CMAKE_BUILD_ARGS="--target openvino_backend"

# Build the package
./install_executorch.sh --use-pt-pinned-commit
./install_executorch.sh --minimal
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switching from --use-pt-pinned-commit to --minimal changes the PyTorch selection behavior (defaulting back to nightly) and can make OpenVINO builds/tests less reproducible. If the goal is to reduce dependencies, consider keeping the pinned PyTorch option while also using minimal installs (e.g., pass both flags or make it configurable).

Copilot uses AI. Check for mistakes.
}

main() {
Expand Down
19 changes: 19 additions & 0 deletions backends/openvino/test/tester/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Copyright (c) Intel Corporation
#
# Licensed under the BSD License (the "License"); you may not use this file
# except in compliance with the License. See the license file found in the
# LICENSE file in the root directory of this source tree.

from executorch.backends.openvino.test.tester.tester import (
OpenVINOTester,
Partition,
Quantize,
ToEdgeTransformAndLower,
)

__all__ = [
"OpenVINOTester",
"Partition",
"Quantize",
"ToEdgeTransformAndLower",
]
94 changes: 94 additions & 0 deletions backends/openvino/test/tester/tester.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Copyright (c) Intel Corporation
#
# Licensed under the BSD License (the "License"); you may not use this file
# except in compliance with the License. See the license file found in the
# LICENSE file in the root directory of this source tree.

import functools
from typing import Any, List, Optional, Sequence, Tuple

import executorch
import executorch.backends.test.harness.stages as BaseStages
import torch

from executorch.backends.openvino.partitioner import OpenvinoPartitioner
from executorch.backends.openvino.quantizer.quantizer import OpenVINOQuantizer
from executorch.backends.test.harness import Tester as TesterBase
from executorch.backends.test.harness.stages import StageType
from executorch.exir import EdgeCompileConfig
from executorch.exir.backend.backend_details import CompileSpec
from executorch.exir.backend.partitioner import Partitioner


class Quantize(BaseStages.Quantize):
def __init__(
self,
calibrate: bool = True,
calibration_samples: Optional[Sequence[Any]] = None,
is_qat=False,
):
super().__init__(
quantizer=OpenVINOQuantizer(),
calibrate=calibrate,
calibration_samples=calibration_samples,
is_qat=is_qat,
fold_quantize=False,
)


class ToEdgeTransformAndLower(BaseStages.ToEdgeTransformAndLower):
def __init__(
self,
partitioners: Optional[List[Partitioner]] = None,
edge_compile_config: Optional[EdgeCompileConfig] = None,
compile_specs: Optional[List[CompileSpec]] = None,
):
compile_specs = compile_specs or [CompileSpec("device", b"CPU")]
super().__init__(
default_partitioner_cls=lambda: OpenvinoPartitioner(compile_specs), # type: ignore[arg-type]
partitioners=partitioners,
edge_compile_config=edge_compile_config
or EdgeCompileConfig(_check_ir_validity=False),
)


class Partition(BaseStages.Partition):
def __init__(
self,
partitioner: Optional[Partitioner] = None,
compile_specs: Optional[List[CompileSpec]] = None,
):
super().__init__(
partitioner=partitioner or OpenvinoPartitioner(compile_specs or []),
)


class OpenVINOTester(TesterBase):
def __init__(
self,
module: torch.nn.Module,
example_inputs: Tuple[torch.Tensor],
dynamic_shapes: Optional[Tuple[Any]] = None,
compile_specs: Optional[List[CompileSpec]] = None,
):
compile_specs = compile_specs or [CompileSpec("device", b"CPU")]
stage_classes = (
executorch.backends.test.harness.Tester.default_stage_classes()
| {
StageType.PARTITION: functools.partial(
Partition, compile_specs=compile_specs
),
StageType.QUANTIZE: Quantize,
StageType.TO_EDGE_TRANSFORM_AND_LOWER: functools.partial(
ToEdgeTransformAndLower,
compile_specs=compile_specs,
),
}
)

super().__init__(
module=module,
stage_classes=stage_classes,
example_inputs=example_inputs,
dynamic_shapes=dynamic_shapes,
)
4 changes: 3 additions & 1 deletion backends/test/harness/stages/quantize.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ def __init__(
calibration_samples: Optional[Sequence[Any]] = None,
is_qat: Optional[bool] = False,
set_global: bool = True,
fold_quantize: bool = True,
):
self.quantizer = quantizer
self.quantization_config = quantization_config
self.calibrate = calibrate
self.calibration_samples = calibration_samples
self.fold_quantize = fold_quantize

if self.quantization_config is not None and set_global:
self.quantizer.set_global(self.quantization_config)
Expand Down Expand Up @@ -66,7 +68,7 @@ def run(
else:
prepared(*inputs)

converted = convert_pt2e(prepared)
converted = convert_pt2e(prepared, fold_quantize=self.fold_quantize)
DuplicateDynamicQuantChainPass()(converted)

self.converted_graph = converted
Expand Down
6 changes: 5 additions & 1 deletion backends/test/suite/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,11 @@ def lower_and_run_model(
ids=str,
)
def test_runner(request):
return TestRunner(request.param, request.node.name, request.node.originalname)
flow = request.param
test_name = request.node.originalname or request.node.name
if flow.should_skip_test(test_name):
pytest.skip(f"Test '{test_name}' matches skip_patterns for flow '{flow.name}'")
return TestRunner(flow, request.node.name, request.node.originalname)


@pytest.hookimpl(optionalhook=True)
Expand Down
Loading
Loading