diff --git a/src/bindings/python/src/openvino/__init__.py b/src/bindings/python/src/openvino/__init__.py index b5655a40ef9022..7f3d82756d4226 100644 --- a/src/bindings/python/src/openvino/__init__.py +++ b/src/bindings/python/src/openvino/__init__.py @@ -47,6 +47,7 @@ from openvino._pyopenvino import serialize from openvino._pyopenvino import shutdown from openvino._pyopenvino import save_model +from openvino._pyopenvino import read_tensor_data from openvino._pyopenvino import layout_helpers from openvino._pyopenvino import RemoteContext from openvino._pyopenvino import RemoteTensor diff --git a/src/bindings/python/src/pyopenvino/CMakeLists.txt b/src/bindings/python/src/pyopenvino/CMakeLists.txt index 1f8f62763592c4..11c04a6ed0490d 100644 --- a/src/bindings/python/src/pyopenvino/CMakeLists.txt +++ b/src/bindings/python/src/pyopenvino/CMakeLists.txt @@ -66,7 +66,7 @@ endif() target_include_directories(${PROJECT_NAME} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/..") -target_link_libraries(${PROJECT_NAME} PRIVATE openvino::core::dev openvino::runtime openvino::offline_transformations) +target_link_libraries(${PROJECT_NAME} PRIVATE openvino::core::dev openvino::runtime openvino::offline_transformations openvino::runtime::dev) set_target_properties(${PROJECT_NAME} PROPERTIES INTERPROCEDURAL_OPTIMIZATION_RELEASE ${ENABLE_LTO} diff --git a/src/bindings/python/src/pyopenvino/core/common.cpp b/src/bindings/python/src/pyopenvino/core/common.cpp index 8143e0f3c1f5c4..49da318f5909d9 100644 --- a/src/bindings/python/src/pyopenvino/core/common.cpp +++ b/src/bindings/python/src/pyopenvino/core/common.cpp @@ -8,6 +8,7 @@ #include "Python.h" #include "openvino/core/except.hpp" +#include "openvino/runtime/make_tensor.hpp" #include "openvino/util/common_util.hpp" #include "pyopenvino/core/remote_tensor.hpp" #include "pyopenvino/utils/utils.hpp" @@ -302,18 +303,29 @@ py::array array_from_tensor(ov::Tensor&& t, bool is_shared) { // Get actual dtype from OpenVINO type: auto ov_type = t.get_element_type(); auto dtype = Common::type_helpers::get_dtype(ov_type); + + auto data_ptr = std::as_const(t).data(); + auto is_read_only = ov::is_tensor_read_only(t); + // Return the array as a view: if (is_shared) { + py::array result; if (ov_type.bitwidth() < Common::values::min_bitwidth) { - return py::array(dtype, t.get_byte_size(), t.data(), py::cast(t)); + result = py::array(dtype, t.get_byte_size(), data_ptr, py::cast(t)); + } else { + result = py::array(dtype, t.get_shape(), t.get_strides(), data_ptr, py::cast(t)); + } + if (is_read_only) { + // Mark array as read-only + result.attr("flags").attr("writeable") = false; } - return py::array(dtype, t.get_shape(), t.get_strides(), t.data(), py::cast(t)); + return result; } // Return the array as a copy: if (ov_type.bitwidth() < Common::values::min_bitwidth) { - return py::array(dtype, t.get_byte_size(), t.data()); + return py::array(dtype, t.get_byte_size(), data_ptr); } - return py::array(dtype, t.get_shape(), t.get_strides(), t.data()); + return py::array(dtype, t.get_shape(), t.get_strides(), data_ptr); } py::array array_from_constant_copy(ov::op::v0::Constant&& c) { diff --git a/src/bindings/python/src/pyopenvino/pyopenvino.cpp b/src/bindings/python/src/pyopenvino/pyopenvino.cpp index 20879b46b511ef..43e5808ad21640 100644 --- a/src/bindings/python/src/pyopenvino/pyopenvino.cpp +++ b/src/bindings/python/src/pyopenvino/pyopenvino.cpp @@ -228,6 +228,41 @@ PYBIND11_MODULE(_pyopenvino, m) { regclass_graph_AttributeVisitor(m); regclass_graph_Output(m, std::string("")); regclass_Tensor(m); + + m.def( + "read_tensor_data", + [](const py::object& path, + const ov::element::Type& element_type, + const ov::PartialShape& shape, + std::size_t offset_in_bytes, + bool mmap) { + return ov::read_tensor_data(Common::utils::to_fs_path(path), element_type, shape, offset_in_bytes, mmap); + }, + py::arg("path"), + py::arg("element_type") = ov::element::u8, + py::arg("shape") = ov::PartialShape::dynamic(1), + py::arg("offset_in_bytes") = 0, + py::arg("mmap") = true, + R"( + Read a tensor content from a file. Only raw data is loaded. + + :param path: path to file to read + :type path: Union[str, bytes, pathlib.Path] + :param element_type: tensor element type (default: openvino.Type.u8) + :type element_type: openvino.Type + :param shape: shape for resulting tensor. If provided shape is static, specified number of elements is read only. + One of the dimensions can be dynamic. In this case it will be determined automatically based on the + length of the file content and `offset_in_bytes` (default: openvino.PartialShape.dynamic(1)). + :type shape: openvino.PartialShape + :param offset_in_bytes: read file starting from specified offset (default: 0) + :type offset_in_bytes: int + :param mmap: use mmap so real reads are deferred until tensor data is accessed. If used, the file should not be + modified until the returned tensor is destroyed (default: True) + :type mmap: bool + :return: resulting tensor + :rtype: openvino.Tensor + )"); + regclass_graph_descriptor_Tensor(m); // https://pybind11.readthedocs.io/en/stable/advanced/cast/stl.html#making-opaque-types py::bind_vector(m, "TensorVector"); diff --git a/src/bindings/python/tests/test_runtime/test_read_tensor_data.py b/src/bindings/python/tests/test_runtime/test_read_tensor_data.py new file mode 100644 index 00000000000000..4d4d81b265e51b --- /dev/null +++ b/src/bindings/python/tests/test_runtime/test_read_tensor_data.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2018-2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +import openvino as ov + + +def _write_bytes(path: Path, data: bytes) -> None: + path.write_bytes(data) + assert path.exists() + + +@pytest.mark.parametrize( + ("dtype", "ov_type"), + [ + (np.float32, ov.Type.f32), + (np.float64, ov.Type.f64), + (np.int8, ov.Type.i8), + (np.int16, ov.Type.i16), + (np.int32, ov.Type.i32), + (np.int64, ov.Type.i64), + (np.uint8, ov.Type.u8), + (np.uint16, ov.Type.u16), + (np.uint32, ov.Type.u32), + (np.uint64, ov.Type.u64), + ], +) +@pytest.mark.parametrize("mmap", [True, False]) +def test_read_tensor_data_typed(tmp_path: Path, dtype: np.dtype, ov_type: ov.Type, mmap: bool) -> None: + shape = (10, 20, 3, 2) + data = np.random.randint(0, 100, size=np.prod(shape)).astype(dtype, copy=False).reshape(shape) + path = tmp_path / "tensor.bin" + data.tofile(path) + + tensor = ov.read_tensor_data(path, element_type=ov_type, shape=ov.PartialShape(list(shape)), mmap=mmap) + assert tensor.get_shape() == list(shape) + assert tensor.get_element_type() == ov_type + assert np.array_equal(tensor.data, data) + + +@pytest.mark.parametrize("mmap", [True, False]) +def test_read_tensor_data_string_tensor_throws(tmp_path: Path, mmap: bool) -> None: + path = tmp_path / "tensor.bin" + _write_bytes(path, b"abc") + with pytest.raises(RuntimeError): + ov.read_tensor_data(path, element_type=ov.Type.string, mmap=mmap) + + +@pytest.mark.parametrize("mmap", [True, False]) +def test_read_tensor_data_with_offset(tmp_path: Path, mmap: bool) -> None: + shape = (1, 2, 3, 4) + data = np.random.uniform(0.0, 1.0, size=np.prod(shape)).astype(np.float32).reshape(shape) + dummy = np.array([0.0], dtype=np.float32) + + path = tmp_path / "tensor.bin" + _write_bytes(path, dummy.tobytes() + data.tobytes()) + + tensor = ov.read_tensor_data( + path, + element_type=ov.Type.f32, + shape=ov.PartialShape(list(shape)), + offset_in_bytes=dummy.nbytes, + mmap=mmap, + ) + assert tensor.get_shape() == list(shape) + assert np.array_equal(tensor.data, data) + + +@pytest.mark.parametrize("mmap", [True, False]) +def test_read_tensor_data_small_file_throws(tmp_path: Path, mmap: bool) -> None: + shape = (1, 2, 3, 4) + data = np.random.uniform(0.0, 1.0, size=np.prod(shape)).astype(np.float32) + path = tmp_path / "tensor.bin" + data.tofile(path) + + too_big_shape = ov.PartialShape([10, 2, 3, 4]) + with pytest.raises(RuntimeError): + ov.read_tensor_data(path, element_type=ov.Type.f32, shape=too_big_shape, mmap=mmap) + + +@pytest.mark.parametrize("mmap", [True, False]) +def test_read_tensor_data_too_big_offset_throws(tmp_path: Path, mmap: bool) -> None: + shape = (1, 2, 3, 4) + data = np.random.uniform(0.0, 1.0, size=np.prod(shape)).astype(np.float32) + path = tmp_path / "tensor.bin" + data.tofile(path) + + file_size = path.stat().st_size + assert file_size == data.nbytes + + with pytest.raises(RuntimeError): + ov.read_tensor_data( + path, + element_type=ov.Type.f32, + shape=ov.PartialShape(list(shape)), + offset_in_bytes=1, + mmap=mmap) + + with pytest.raises(RuntimeError): + ov.read_tensor_data( + path, + element_type=ov.Type.f32, + shape=ov.PartialShape(list(shape)), + offset_in_bytes=file_size, + mmap=mmap, + ) + + with pytest.raises(RuntimeError): + ov.read_tensor_data( + path, + element_type=ov.Type.f32, + shape=ov.PartialShape(list(shape)), + offset_in_bytes=file_size + 1, + mmap=mmap, + ) + + +def test_read_tensor_data_default_all_args(tmp_path: Path) -> None: + """Test main use case: only path specified, all other args use defaults.""" + data = np.arange(24, dtype=np.uint8) + path = tmp_path / "tensor.bin" + data.tofile(path) + + # Main use case - only specify path + tensor = ov.read_tensor_data(path) + + assert isinstance(tensor, ov.Tensor) + assert tensor.get_element_type() == ov.Type.u8 # default element type + assert tensor.get_shape() == [data.size] # dynamic shape inferred from file + assert not tensor.data.flags.writeable # read-only + assert np.array_equal(tensor.data, data) + + +@pytest.mark.parametrize("mmap", [True, False]) +def test_read_tensor_data_dynamic_shape(tmp_path: Path, mmap: bool) -> None: + shape = (1, 2, 3, 4) + data = np.random.uniform(0.0, 1.0, size=np.prod(shape)).astype(np.float32) + path = tmp_path / "tensor.bin" + data.tofile(path) + + tensor = ov.read_tensor_data(path, element_type=ov.Type.f32, shape=ov.PartialShape.dynamic(1), mmap=mmap) + assert tensor.get_shape() == [data.size] + assert np.array_equal(tensor.data, data) + + # default element type is u8 and default shape is dynamic(1) + tensor_u8 = ov.read_tensor_data(path, mmap=mmap) + expected_u8 = np.fromfile(path, dtype=np.uint8) + assert tensor_u8.get_shape() == [expected_u8.size] + assert tensor_u8.get_element_type() == ov.Type.u8 + assert np.array_equal(tensor_u8.data, expected_u8) + + +@pytest.mark.parametrize("mmap", [True, False]) +def test_read_tensor_data_1_dynamic_dimension(tmp_path: Path, mmap: bool) -> None: + # Last dimension is inferred from file size + shape = [1, 2, 3, 4] + data = np.random.uniform(0.0, 1.0, size=np.prod(shape)).astype(np.float32) + path = tmp_path / "tensor.bin" + data.tofile(path) + + shape_with_dynamic_last = ov.PartialShape([1, 2, 3, -1]) + tensor = ov.read_tensor_data(path, element_type=ov.Type.f32, shape=shape_with_dynamic_last, mmap=mmap) + assert tensor.get_shape()[-1] == shape[-1] + assert np.array_equal(tensor.data, data.reshape(shape)) + + +@pytest.mark.parametrize("mmap", [True, False]) +def test_read_tensor_data_wrong_dynamic_shape_throws(tmp_path: Path, mmap: bool) -> None: + shape = [1, 2, 3, 4] + data = np.random.uniform(0.0, 1.0, size=np.prod(shape)).astype(np.float32) + path = tmp_path / "tensor.bin" + data.tofile(path) + + wrong_shape = ov.PartialShape([1, 2, 100, -1]) + with pytest.raises(RuntimeError): + ov.read_tensor_data(path, element_type=ov.Type.f32, shape=wrong_shape, mmap=mmap) + + +@pytest.mark.parametrize("mmap", [True, False]) +def test_read_tensor_data_type_doesnt_fit_file_size(tmp_path: Path, mmap: bool) -> None: + path = tmp_path / "tensor.bin" + # 3 bytes: not divisible by sizeof(float) + _write_bytes(path, b"abc") + with pytest.raises(RuntimeError): + ov.read_tensor_data(path, element_type=ov.Type.f32, mmap=mmap) + + +@pytest.mark.parametrize("mmap", [True, False]) +def test_read_tensor_data_null_shape_throws(tmp_path: Path, mmap: bool) -> None: + shape = [1, 2, 3, 4] + data = np.random.uniform(0.0, 1.0, size=np.prod(shape)).astype(np.float32) + path = tmp_path / "tensor.bin" + data.tofile(path) + + # One null dimension (0) and one dynamic dimension + null_shape = ov.PartialShape([0, ov.Dimension.dynamic(), 3, 4]) + with pytest.raises(RuntimeError): + ov.read_tensor_data(path, element_type=ov.Type.f32, shape=null_shape, mmap=mmap) + + +@pytest.mark.parametrize("mmap", [True, False]) +def test_read_tensor_data_returns_readonly_array(tmp_path: Path, mmap: bool) -> None: + """Test that tensors from read_tensor_data have read-only numpy arrays.""" + shape = (2, 3, 4) + data = np.random.uniform(0.0, 1.0, size=np.prod(shape)).astype(np.float32).reshape(shape) + path = tmp_path / "tensor.bin" + data.tofile(path) + + tensor = ov.read_tensor_data(path, element_type=ov.Type.f32, shape=ov.PartialShape(list(shape)), mmap=mmap) + + # Verify the numpy array is read-only + assert not tensor.data.flags.writeable + + # Verify attempting to write raises an error + with pytest.raises(ValueError, match="read-only"): + tensor.data[0, 0, 0] = 999.0 diff --git a/src/inference/dev_api/openvino/runtime/make_tensor.hpp b/src/inference/dev_api/openvino/runtime/make_tensor.hpp index 725c2c41f0f16e..9647da56bf0d5c 100644 --- a/src/inference/dev_api/openvino/runtime/make_tensor.hpp +++ b/src/inference/dev_api/openvino/runtime/make_tensor.hpp @@ -9,10 +9,10 @@ #pragma once +#include "openvino/runtime/allocator.hpp" #include "openvino/runtime/common.hpp" #include "openvino/runtime/itensor.hpp" #include "openvino/runtime/so_ptr.hpp" -#include "openvino/runtime/allocator.hpp" namespace ov { @@ -97,4 +97,15 @@ OPENVINO_RUNTIME_API ov::SoPtr get_tensor_impl(const ov::Tensor& te */ OPENVINO_RUNTIME_API size_t get_tensor_data_offset(const ov::ITensor& tensor); +/** + * @brief Checks if the tensor is read-only + * + * @details A tensor is considered read-only if it was created as a view tensor from a const pointer + * using the make_tensor() function that accepts const void* host_ptr. + * + * @param tensor OpenVINO Tensor to check + * + * @return true if the tensor is read-only, false otherwise + */ +OPENVINO_RUNTIME_API bool is_tensor_read_only(const ov::Tensor& tensor); } // namespace ov diff --git a/src/inference/src/dev/make_tensor.cpp b/src/inference/src/dev/make_tensor.cpp index b51257b0f62a6e..95d986992983ff 100644 --- a/src/inference/src/dev/make_tensor.cpp +++ b/src/inference/src/dev/make_tensor.cpp @@ -623,4 +623,11 @@ size_t get_tensor_data_offset(const ov::ITensor& tensor) { return 0; } +bool is_tensor_read_only(const ov::Tensor& tensor) { + auto impl = get_tensor_impl(tensor); + if (std::dynamic_pointer_cast(impl._ptr)) { + return true; + } + return false; +} } // namespace ov diff --git a/tools/benchmark_tool/openvino/__init__.py b/tools/benchmark_tool/openvino/__init__.py index b5655a40ef9022..7f3d82756d4226 100644 --- a/tools/benchmark_tool/openvino/__init__.py +++ b/tools/benchmark_tool/openvino/__init__.py @@ -47,6 +47,7 @@ from openvino._pyopenvino import serialize from openvino._pyopenvino import shutdown from openvino._pyopenvino import save_model +from openvino._pyopenvino import read_tensor_data from openvino._pyopenvino import layout_helpers from openvino._pyopenvino import RemoteContext from openvino._pyopenvino import RemoteTensor diff --git a/tools/ovc/openvino/__init__.py b/tools/ovc/openvino/__init__.py index b5655a40ef9022..7f3d82756d4226 100644 --- a/tools/ovc/openvino/__init__.py +++ b/tools/ovc/openvino/__init__.py @@ -47,6 +47,7 @@ from openvino._pyopenvino import serialize from openvino._pyopenvino import shutdown from openvino._pyopenvino import save_model +from openvino._pyopenvino import read_tensor_data from openvino._pyopenvino import layout_helpers from openvino._pyopenvino import RemoteContext from openvino._pyopenvino import RemoteTensor