Skip to content

Commit a5ee876

Browse files
committed
Implement numpy.view for OpenVINO backend
1 parent d8e68b0 commit a5ee876

File tree

2 files changed

+189
-5
lines changed

2 files changed

+189
-5
lines changed

keras/src/backend/openvino/excluded_concrete_tests.txt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,6 @@ NumpyDtypeTest::test_nanargmax
323323
NumpyDtypeTest::test_nanargmin
324324
NumpyDtypeTest::test_power
325325
NumpyDtypeTest::test_sinc
326-
NumpyDtypeTest::test_view
327326
NumpyOneInputOpsCorrectnessTest::test_array
328327
NumpyOneInputOpsCorrectnessTest::test_conj
329328
NumpyOneInputOpsCorrectnessTest::test_imag
@@ -334,11 +333,8 @@ NumpyOneInputOpsCorrectnessTest::test_real
334333
NumpyOneInputOpsCorrectnessTest::test_reshape
335334
NumpyOneInputOpsCorrectnessTest::test_sinc
336335
NumpyOneInputOpsCorrectnessTest::test_vectorize
337-
NumpyOneInputOpsCorrectnessTest::test_view
338336
NumpyOneInputOpsDynamicShapeTest::test_sinc
339-
NumpyOneInputOpsDynamicShapeTest::test_view
340337
NumpyOneInputOpsStaticShapeTest::test_sinc
341-
NumpyOneInputOpsStaticShapeTest::test_view
342338
OptimizerTest::test_constraints_are_applied
343339
OptimizerTest::test_ema
344340
OptimizerTest::test_gradient_accumulation

keras/src/backend/openvino/numpy.py

Lines changed: 189 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,195 @@ def array(x, dtype=None):
612612

613613

614614
def view(x, dtype=None):
615-
raise NotImplementedError("`view` is not supported with openvino backend")
615+
"""Reinterpret the bytes of a tensor as a different dtype.
616+
617+
Three execution paths:
618+
1. **NumPy fast path** — plain ``np.ndarray`` inputs are viewed
619+
directly and re-wrapped as an OpenVINO constant.
620+
2. **Constant folding** — if the OpenVINO subgraph backing *x*
621+
is parameter-free (e.g. a ``Constant`` node *or* an expression
622+
such as ``Broadcast``), it is compiled on CPU and the resulting
623+
NumPy array is viewed.
624+
3. **Symbolic bitwise decomposition** — for non-constant,
625+
integer-to-integer type changes the bit pattern is preserved
626+
using shift / mask / OR operations in the OpenVINO graph.
627+
Float ↔ int reinterpretation on symbolic tensors is not
628+
supported because OpenVINO lacks a bitcast op.
629+
"""
630+
from keras.src import backend
631+
632+
new_dtype = backend.standardize_dtype(dtype) if dtype else None
633+
634+
# Fast path: plain numpy/scalar inputs
635+
if isinstance(x, np.ndarray):
636+
if new_dtype is None:
637+
return OpenVINOKerasTensor(ov_opset.constant(x).output(0))
638+
return OpenVINOKerasTensor(
639+
ov_opset.constant(x.view(np.dtype(new_dtype))).output(0)
640+
)
641+
642+
x_ov = get_ov_output(x)
643+
old_ov_type = x_ov.get_element_type()
644+
old_dtype = ov_to_keras_type(old_ov_type)
645+
646+
if new_dtype is None:
647+
new_dtype = old_dtype
648+
new_ov_type = OPENVINO_DTYPES[new_dtype]
649+
650+
if old_ov_type == new_ov_type:
651+
return OpenVINOKerasTensor(x_ov)
652+
653+
old_itemsize = old_ov_type.size
654+
new_itemsize = new_ov_type.size
655+
656+
# Constant folding: evaluate parameter-free subgraphs on CPU.
657+
# Uses raw bytes + ov.Tensor to avoid numpy dtype issues
658+
# (e.g. bfloat16 is not a standard numpy dtype).
659+
try:
660+
node = x_ov.get_node()
661+
if node.get_type_name() == "Constant":
662+
np_data = node.data
663+
else:
664+
ov_model = ov.Model(results=[x_ov], parameters=[])
665+
compiled = ov.compile_model(ov_model, "CPU")
666+
np_data = compiled({})[0]
667+
old_shape = np_data.shape
668+
new_last = old_shape[-1] * old_itemsize // new_itemsize
669+
new_shape = list(old_shape[:-1]) + [new_last]
670+
raw = np.frombuffer(np_data.tobytes(), dtype=np.uint8)
671+
result_tensor = ov.Tensor(new_ov_type, new_shape)
672+
np.copyto(
673+
np.frombuffer(result_tensor.data, dtype=np.uint8),
674+
raw,
675+
)
676+
return OpenVINOKerasTensor(ov_opset.constant(result_tensor).output(0))
677+
except Exception:
678+
pass
679+
680+
# Non-constant tensors: only integer↔integer is supported
681+
if not (old_ov_type.is_integral() and new_ov_type.is_integral()):
682+
raise NotImplementedError(
683+
f"`view` from {old_dtype} to {new_dtype} is not supported "
684+
"for non-constant tensors with the OpenVINO backend "
685+
"(no bitcast operation available in OpenVINO opset)."
686+
)
687+
688+
if old_itemsize == new_itemsize:
689+
# Same-width signed↔unsigned: convert preserves bit pattern
690+
return OpenVINOKerasTensor(
691+
ov_opset.convert(x_ov, new_ov_type).output(0)
692+
)
693+
elif old_itemsize > new_itemsize:
694+
return _view_int_expand(x_ov, new_ov_type, old_itemsize, new_itemsize)
695+
else:
696+
return _view_int_contract(x_ov, new_ov_type, old_itemsize, new_itemsize)
697+
698+
699+
def _split_shape_leading_last(x):
700+
"""Return (leading_dims, last_dim) from the shape of *x*.
701+
702+
``leading_dims`` contains all dimensions except the last one,
703+
``last_dim`` is a rank-1 tensor with the single last dimension.
704+
Both are ``i64`` OpenVINO outputs.
705+
"""
706+
shape = ov_opset.shape_of(x, "i64").output(0)
707+
leading = ov_opset.slice(
708+
shape,
709+
ov_opset.constant([0], Type.i64).output(0),
710+
ov_opset.constant([-1], Type.i64).output(0),
711+
ov_opset.constant([1], Type.i64).output(0),
712+
ov_opset.constant([0], Type.i64).output(0),
713+
).output(0)
714+
last_dim = ov_opset.gather(
715+
shape,
716+
ov_opset.constant([-1], Type.i64).output(0),
717+
ov_opset.constant(0, Type.i64).output(0),
718+
).output(0)
719+
return leading, last_dim
720+
721+
722+
def _view_int_expand(x, new_ov_type, old_itemsize, new_itemsize):
723+
"""View a larger int as smaller ints (e.g. int32 → uint8)."""
724+
ratio = old_itemsize // new_itemsize
725+
_unsigned = {1: Type.u8, 2: Type.u16, 4: Type.u32, 8: Type.u64}
726+
src_uint_type = _unsigned[old_itemsize]
727+
dst_uint_type = _unsigned[new_itemsize]
728+
bits_per_elem = new_itemsize * 8
729+
mask_val = (1 << bits_per_elem) - 1
730+
731+
x_uint = ov_opset.convert(x, src_uint_type).output(0)
732+
mask = ov_opset.constant(mask_val, src_uint_type).output(0)
733+
734+
byte_parts = []
735+
for i in range(ratio):
736+
shift = ov_opset.constant(i * bits_per_elem, src_uint_type).output(0)
737+
shifted = ov_opset.bitwise_right_shift(x_uint, shift).output(0)
738+
masked = ov_opset.bitwise_and(shifted, mask).output(0)
739+
part = ov_opset.convert(masked, dst_uint_type).output(0)
740+
part = ov_opset.unsqueeze(part, ov_opset.constant(-1, Type.i32)).output(
741+
0
742+
)
743+
byte_parts.append(part)
744+
745+
# Concat along last axis: [..., N, ratio]
746+
concat_result = ov_opset.concat(byte_parts, axis=-1).output(0)
747+
748+
# Reshape [..., N, ratio] → [..., N*ratio]
749+
leading, last_dim = _split_shape_leading_last(x)
750+
new_last = ov_opset.multiply(
751+
last_dim, ov_opset.constant([ratio], Type.i64).output(0)
752+
).output(0)
753+
new_shape = ov_opset.concat([leading, new_last], axis=0).output(0)
754+
result = ov_opset.reshape(concat_result, new_shape, False).output(0)
755+
756+
if dst_uint_type != new_ov_type:
757+
result = ov_opset.convert(result, new_ov_type).output(0)
758+
return OpenVINOKerasTensor(result)
759+
760+
761+
def _view_int_contract(x, new_ov_type, old_itemsize, new_itemsize):
762+
"""View smaller ints as a larger int (e.g. uint8 → int32)."""
763+
ratio = new_itemsize // old_itemsize
764+
_unsigned = {1: Type.u8, 2: Type.u16, 4: Type.u32, 8: Type.u64}
765+
src_uint_type = _unsigned[old_itemsize]
766+
dst_uint_type = _unsigned[new_itemsize]
767+
bits_per_elem = old_itemsize * 8
768+
769+
x_uint = ov_opset.convert(x, src_uint_type).output(0)
770+
771+
# Reshape [..., N] → [..., N//ratio, ratio]
772+
leading, last_dim = _split_shape_leading_last(x)
773+
grouped_last = ov_opset.divide(
774+
last_dim, ov_opset.constant([ratio], Type.i64).output(0)
775+
).output(0)
776+
ratio_dim = ov_opset.constant([ratio], Type.i64).output(0)
777+
inter_shape = ov_opset.concat(
778+
[leading, grouped_last, ratio_dim], axis=0
779+
).output(0)
780+
reshaped = ov_opset.reshape(x_uint, inter_shape, False).output(0)
781+
782+
# Combine bytes: gather each position, shift, OR
783+
last_axis = ov_opset.constant(-1, Type.i64).output(0)
784+
idx = ov_opset.constant([0], Type.i64).output(0)
785+
byte_0 = ov_opset.gather(reshaped, idx, last_axis).output(0)
786+
byte_0 = ov_opset.squeeze(byte_0, ov_opset.constant([-1], Type.i32)).output(
787+
0
788+
)
789+
result = ov_opset.convert(byte_0, dst_uint_type).output(0)
790+
for i in range(1, ratio):
791+
idx = ov_opset.constant([i], Type.i64).output(0)
792+
byte_i = ov_opset.gather(reshaped, idx, last_axis).output(0)
793+
byte_i = ov_opset.squeeze(
794+
byte_i, ov_opset.constant([-1], Type.i32)
795+
).output(0)
796+
byte_i = ov_opset.convert(byte_i, dst_uint_type).output(0)
797+
shift = ov_opset.constant(i * bits_per_elem, dst_uint_type).output(0)
798+
byte_i = ov_opset.bitwise_left_shift(byte_i, shift).output(0)
799+
result = ov_opset.bitwise_or(result, byte_i).output(0)
800+
801+
if dst_uint_type != new_ov_type:
802+
result = ov_opset.convert(result, new_ov_type).output(0)
803+
return OpenVINOKerasTensor(result)
616804

617805

618806
def average(x, axis=None, weights=None):

0 commit comments

Comments
 (0)