Skip to content

Commit 74b5997

Browse files
Merge branch 'support_export' into gsoc2025
2 parents df1dc1d + cb7812a commit 74b5997

File tree

4 files changed

+348
-1
lines changed

4 files changed

+348
-1
lines changed

keras/src/export/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from keras.src.export.onnx import export_onnx
2+
from keras.src.export.openvino import export_openvino
23
from keras.src.export.saved_model import ExportArchive
34
from keras.src.export.saved_model import export_saved_model
45
from keras.src.export.tfsm_layer import TFSMLayer

keras/src/export/openvino.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from keras.src import backend
2+
from keras.src import tree
3+
from keras.src.export.export_utils import convert_spec_to_tensor
4+
from keras.src.export.export_utils import get_input_signature
5+
from keras.src.utils import io_utils
6+
7+
8+
def export_openvino(
9+
model, filepath, verbose=None, input_signature=None, **kwargs
10+
):
11+
"""Export the model as an OpenVINO IR artifact for inference.
12+
13+
This method exports the model to the OpenVINO IR format,
14+
which includes two files:
15+
a `.xml` file containing the model structure and a `.bin` file
16+
containing the weights.
17+
The exported model contains only the forward pass
18+
(i.e., the model's `call()` method), and can be deployed with the
19+
OpenVINO Runtime for fast inference on CPU and other Intel hardware.
20+
21+
Args:
22+
filepath: `str` or `pathlib.Path`. Path to the output `.xml` file.
23+
The corresponding `.bin` file will be saved alongside it.
24+
verbose: Optional `bool`. Whether to print a confirmation message
25+
after export. If `None`, it uses the default verbosity configured
26+
by the backend.
27+
input_signature: Optional. Specifies the shape and dtype of the
28+
model inputs. If not provided, it will be inferred.
29+
**kwargs: Additional keyword arguments (currently unused).
30+
"""
31+
assert filepath.endswith(".xml"), (
32+
"The OpenVINO export requires the filepath to end with '.xml'. "
33+
f"Got: {filepath}"
34+
)
35+
36+
import openvino as ov
37+
from openvino.runtime import opset14 as ov_opset
38+
39+
from keras.src.backend.openvino.core import OPENVINO_DTYPES
40+
from keras.src.backend.openvino.core import OpenVINOKerasTensor
41+
42+
actual_verbose = verbose if verbose is not None else True
43+
44+
if input_signature is None:
45+
input_signature = get_input_signature(model)
46+
if isinstance(input_signature, list) and len(input_signature) == 1:
47+
input_signature = input_signature[0]
48+
49+
sample_inputs = tree.map_structure(
50+
lambda x: convert_spec_to_tensor(x, replace_none_number=1),
51+
input_signature,
52+
)
53+
54+
if backend.backend() == "openvino":
55+
import inspect
56+
57+
def parameterize_inputs(inputs, prefix=""):
58+
if isinstance(inputs, (list, tuple)):
59+
return [
60+
parameterize_inputs(e, f"{prefix}{i}")
61+
for i, e in enumerate(inputs)
62+
]
63+
elif isinstance(inputs, dict):
64+
return {k: parameterize_inputs(v, k) for k, v in inputs.items()}
65+
elif isinstance(inputs, OpenVINOKerasTensor):
66+
ov_type = OPENVINO_DTYPES[str(inputs.dtype)]
67+
ov_shape = list(inputs.shape)
68+
param = ov_opset.parameter(shape=ov_shape, dtype=ov_type)
69+
param.set_friendly_name(prefix)
70+
return OpenVINOKerasTensor(param.output(0))
71+
else:
72+
raise TypeError(f"Unknown input type: {type(inputs)}")
73+
74+
params = parameterize_inputs(sample_inputs)
75+
signature = inspect.signature(model.call)
76+
if len(signature.parameters) > 1 and isinstance(params, (list, tuple)):
77+
outputs = model(*params)
78+
else:
79+
outputs = model(params)
80+
parameters = [p.output.get_node() for p in tree.flatten(params)]
81+
results = [ov_opset.result(r.output) for r in tree.flatten(outputs)]
82+
ov_model = ov.Model(results=results, parameters=parameters)
83+
flat_specs = tree.flatten(input_signature)
84+
for ov_input, spec in zip(ov_model.inputs, flat_specs):
85+
# Respect the dynamic axes from the original input signature.
86+
dynamic_shape_dims = [
87+
-1 if dim is None else dim for dim in spec.shape
88+
]
89+
dynamic_shape = ov.PartialShape(dynamic_shape_dims)
90+
ov_input.get_node().set_partial_shape(dynamic_shape)
91+
92+
elif backend.backend() == "tensorflow":
93+
import tempfile
94+
95+
with tempfile.TemporaryDirectory() as temp_dir:
96+
model.export(temp_dir, format="tf_saved_model")
97+
ov_model = ov.convert_model(temp_dir)
98+
else:
99+
raise NotImplementedError(
100+
"`export_openvino` is only compatible with OpenVINO and "
101+
"TensorFlow backends."
102+
)
103+
104+
ov.serialize(ov_model, filepath)
105+
106+
if actual_verbose:
107+
io_utils.print_msg(f"Saved OpenVINO IR at '{filepath}'.")

keras/src/export/openvino_test.py

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import os
2+
3+
import numpy as np
4+
import pytest
5+
from absl.testing import parameterized
6+
7+
from keras.src import backend
8+
from keras.src import layers
9+
from keras.src import models
10+
from keras.src import ops
11+
from keras.src import testing
12+
from keras.src import tree
13+
from keras.src.export import openvino
14+
from keras.src.saving import saving_lib
15+
from keras.src.testing.test_utils import named_product
16+
17+
try:
18+
import openvino as ov
19+
except ImportError:
20+
ov = None
21+
22+
23+
class CustomModel(models.Model):
24+
def __init__(self, layer_list):
25+
super().__init__()
26+
self.layer_list = layer_list
27+
28+
def call(self, input):
29+
output = input
30+
for layer in self.layer_list:
31+
output = layer(output)
32+
return output
33+
34+
35+
def get_model(type="sequential", input_shape=(10,), layer_list=None):
36+
layer_list = layer_list or [
37+
layers.Dense(10, activation="relu"),
38+
layers.BatchNormalization(),
39+
layers.Dense(1, activation="sigmoid"),
40+
]
41+
if type == "sequential":
42+
return models.Sequential(layer_list)
43+
elif type == "functional":
44+
input = output = tree.map_shape_structure(layers.Input, input_shape)
45+
for layer in layer_list:
46+
output = layer(output)
47+
return models.Model(inputs=input, outputs=output)
48+
elif type == "subclass":
49+
return CustomModel(layer_list)
50+
elif type == "lstm":
51+
# https://github.com/keras-team/keras/issues/21390
52+
inputs = layers.Input((4, 10))
53+
x = layers.Bidirectional(
54+
layers.LSTM(
55+
10,
56+
kernel_initializer="he_normal",
57+
return_sequences=True,
58+
kernel_regularizer=None,
59+
),
60+
merge_mode="sum",
61+
)(inputs)
62+
outputs = layers.Bidirectional(
63+
layers.LSTM(
64+
10,
65+
kernel_initializer="he_normal",
66+
return_sequences=True,
67+
kernel_regularizer=None,
68+
),
69+
merge_mode="concat",
70+
)(x)
71+
return models.Model(inputs=inputs, outputs=outputs)
72+
73+
74+
@pytest.mark.skipif(ov is None, reason="OpenVINO is not installed")
75+
@pytest.mark.skipif(
76+
backend.backend() not in ("tensorflow", "openvino"),
77+
reason=(
78+
"`export_openvino` only currently supports"
79+
"the tensorflow and openvino backends."
80+
),
81+
)
82+
@pytest.mark.skipif(
83+
testing.tensorflow_uses_gpu(), reason="Leads to core dumps on CI"
84+
)
85+
class ExportOpenVINOTest(testing.TestCase):
86+
@parameterized.named_parameters(
87+
named_product(
88+
model_type=["sequential", "functional", "subclass", "lstm"]
89+
)
90+
)
91+
def test_standard_model_export(self, model_type):
92+
if model_type == "lstm":
93+
self.skipTest(
94+
"LSTM export not supported - unimplemented QR operation"
95+
)
96+
97+
temp_filepath = os.path.join(self.get_temp_dir(), "exported_model.xml")
98+
model = get_model(model_type)
99+
batch_size = 3
100+
if model_type == "lstm":
101+
ref_input = np.random.normal(size=(batch_size, 4, 10))
102+
else:
103+
ref_input = np.random.normal(size=(batch_size, 10))
104+
ref_input = ref_input.astype("float32")
105+
ref_output = model(ref_input)
106+
107+
openvino.export_openvino(model, temp_filepath)
108+
109+
# Load and run inference with OpenVINO
110+
core = ov.Core()
111+
ov_model = core.read_model(temp_filepath)
112+
compiled_model = core.compile_model(ov_model, "CPU")
113+
114+
ov_output = compiled_model([ref_input])[compiled_model.output(0)]
115+
116+
self.assertAllClose(ref_output, ov_output)
117+
118+
larger_input = np.concatenate([ref_input, ref_input], axis=0)
119+
compiled_model([larger_input])
120+
121+
@parameterized.named_parameters(
122+
named_product(struct_type=["tuple", "array", "dict"])
123+
)
124+
def test_model_with_input_structure(self, struct_type):
125+
class TupleModel(models.Model):
126+
def call(self, inputs):
127+
x, y = inputs
128+
return ops.add(x, y)
129+
130+
class ArrayModel(models.Model):
131+
def call(self, inputs):
132+
x = inputs[0]
133+
y = inputs[1]
134+
return ops.add(x, y)
135+
136+
class DictModel(models.Model):
137+
def call(self, inputs):
138+
x = inputs["x"]
139+
y = inputs["y"]
140+
return ops.add(x, y)
141+
142+
batch_size = 3
143+
ref_input = np.random.normal(size=(batch_size, 10)).astype("float32")
144+
if struct_type == "tuple":
145+
model = TupleModel()
146+
ref_input = (ref_input, ref_input * 2)
147+
elif struct_type == "array":
148+
model = ArrayModel()
149+
ref_input = [ref_input, ref_input * 2]
150+
elif struct_type == "dict":
151+
model = DictModel()
152+
ref_input = {"x": ref_input, "y": ref_input * 2}
153+
154+
temp_filepath = os.path.join(self.get_temp_dir(), "exported_model.xml")
155+
ref_output = model(tree.map_structure(ops.convert_to_tensor, ref_input))
156+
157+
openvino.export_openvino(model, temp_filepath)
158+
159+
# Load and run inference with OpenVINO
160+
core = ov.Core()
161+
ov_model = core.read_model(temp_filepath)
162+
compiled_model = core.compile_model(ov_model, "CPU")
163+
164+
if isinstance(ref_input, dict):
165+
ov_inputs = [ref_input[key] for key in ref_input.keys()]
166+
else:
167+
ov_inputs = list(ref_input)
168+
169+
ov_output = compiled_model(ov_inputs)[compiled_model.output(0)]
170+
self.assertAllClose(ref_output, ov_output)
171+
172+
# Test with keras.saving_lib
173+
temp_filepath = os.path.join(
174+
self.get_temp_dir(), "exported_model.keras"
175+
)
176+
saving_lib.save_model(model, temp_filepath)
177+
revived_model = saving_lib.load_model(
178+
temp_filepath,
179+
{
180+
"TupleModel": TupleModel,
181+
"ArrayModel": ArrayModel,
182+
"DictModel": DictModel,
183+
},
184+
)
185+
self.assertAllClose(ref_output, revived_model(ref_input))
186+
temp_filepath = os.path.join(self.get_temp_dir(), "exported_model2.xml")
187+
openvino.export_openvino(revived_model, temp_filepath)
188+
189+
bigger_ref_input = tree.map_structure(
190+
lambda x: np.concatenate([x, x], axis=0), ref_input
191+
)
192+
if isinstance(bigger_ref_input, dict):
193+
bigger_ov_inputs = [
194+
bigger_ref_input[key] for key in bigger_ref_input.keys()
195+
]
196+
else:
197+
bigger_ov_inputs = list(bigger_ref_input)
198+
compiled_model(bigger_ov_inputs)
199+
200+
def test_model_with_multiple_inputs(self):
201+
class TwoInputsModel(models.Model):
202+
def call(self, x, y):
203+
return x + y
204+
205+
def build(self, y_shape, x_shape):
206+
self.built = True
207+
208+
temp_filepath = os.path.join(self.get_temp_dir(), "exported_model.xml")
209+
model = TwoInputsModel()
210+
batch_size = 3
211+
ref_input_x = np.random.normal(size=(batch_size, 10)).astype("float32")
212+
ref_input_y = np.random.normal(size=(batch_size, 10)).astype("float32")
213+
ref_output = model(ref_input_x, ref_input_y)
214+
215+
openvino.export_openvino(model, temp_filepath)
216+
217+
# Load and run inference with OpenVINO
218+
core = ov.Core()
219+
ov_model = core.read_model(temp_filepath)
220+
compiled_model = core.compile_model(ov_model, "CPU")
221+
222+
ov_output = compiled_model([ref_input_x, ref_input_y])[
223+
compiled_model.output(0)
224+
]
225+
self.assertAllClose(ref_output, ov_output)
226+
larger_input_x = np.concatenate([ref_input_x, ref_input_x], axis=0)
227+
larger_input_y = np.concatenate([ref_input_y, ref_input_y], axis=0)
228+
compiled_model([larger_input_x, larger_input_y])
229+
larger_input_y = np.concatenate([ref_input_y, ref_input_y], axis=0)
230+
compiled_model([larger_input_x, larger_input_y])

keras/src/models/model.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -595,9 +595,10 @@ def export(
595595
```
596596
"""
597597
from keras.src.export import export_onnx
598+
from keras.src.export import export_openvino
598599
from keras.src.export import export_saved_model
599600

600-
available_formats = ("tf_saved_model", "onnx")
601+
available_formats = ("tf_saved_model", "onnx", "openvino")
601602
if format not in available_formats:
602603
raise ValueError(
603604
f"Unrecognized format={format}. Supported formats are: "
@@ -620,6 +621,14 @@ def export(
620621
input_signature=input_signature,
621622
**kwargs,
622623
)
624+
elif format == "openvino":
625+
export_openvino(
626+
self,
627+
filepath,
628+
verbose,
629+
input_signature=input_signature,
630+
**kwargs,
631+
)
623632

624633
@classmethod
625634
def from_config(cls, config, custom_objects=None):

0 commit comments

Comments
 (0)