项目文件夹

文件
2026-07-13 13:17:40 +08:00

1472 行
53 KiB
Python

import itertools
import threading
from unittest.mock import patch
import numpy as np
import pandas as pd
import pyarrow as pa
import pytest
from packaging.version import parse as parse_version
from ray.data._internal.arrow_ops.transform_pyarrow import combine_chunked_array
from ray.data._internal.tensor_extensions.arrow import (
ArrowConversionError,
ArrowTensorArray,
ArrowTensorType,
ArrowTensorTypeV2,
ArrowVariableShapedTensorArray,
ArrowVariableShapedTensorType,
FixedShapeTensorFormat,
FixedShapeTensorType,
_are_contiguous_1d_views,
_concat_ndarrays,
_extension_array_concat_supported,
concat_tensor_arrays,
create_arrow_fixed_shape_tensor_type,
fixed_shape_extension_scalar_to_ndarray,
unify_tensor_arrays,
)
from ray.data._internal.tensor_extensions.pandas import TensorArray, TensorDtype
from ray.data._internal.tensor_extensions.utils import (
create_ragged_ndarray,
)
from ray.data._internal.utils.arrow_utils import get_pyarrow_version
@pytest.mark.parametrize(
"values",
[
[np.zeros((3, 1)), np.zeros((3, 2))],
[np.zeros((3,))],
],
)
def test_create_ragged_ndarray(values, tensor_format_context):
ragged_array = create_ragged_ndarray(values)
assert len(ragged_array) == len(values)
for actual_array, expected_array in zip(ragged_array, values):
np.testing.assert_array_equal(actual_array, expected_array)
def test_tensor_array_validation():
# Test unknown input type raises TypeError.
with pytest.raises(TypeError):
TensorArray(object())
# Test non-primitive element raises TypeError.
with pytest.raises(TypeError):
TensorArray(np.array([object(), object()]))
with pytest.raises(TypeError):
TensorArray([object(), object()])
def test_pandas_to_arrow_fixed_shape_tensor_conversion(tensor_format_context):
# First, convert Pandas serise w/ nulls to numpy
array = pd.Series([1, 2, 3, None], dtype=pd.Int64Dtype).to_numpy().reshape((2, 2))
# First, check on singular tensor of shape (2, 2, 2)
input_tensor = np.stack([array, array])
pa_tensor = ArrowTensorArray.from_numpy(input_tensor)
res_tensor = pa_tensor.to_numpy_ndarray()
np.testing.assert_array_equal(res_tensor, np.stack([array.astype(np.float64)] * 2))
# Next, check "ragged" tensor
# - Outermost ndarray is of shape (2,) (dtype='O')
# - Internal ndarrays are homogeneously shaped (2, 2) (dtype='O')
input_tensor = create_ragged_ndarray([array, array])
pa_tensor = ArrowTensorArray.from_numpy(input_tensor)
res_tensor = pa_tensor.to_numpy_ndarray()
np.testing.assert_array_equal(res_tensor, np.stack([array.astype(np.float64)] * 2))
def test_pandas_to_arrow_var_shape_tensor_conversion():
# First, convert Pandas series w/ nulls to numpy
array = pd.Series([1, 2, 3, None], dtype=pd.Int64Dtype).to_numpy()
input_tensor = create_ragged_ndarray([array.reshape(1, 4), array.reshape((2, 2))])
# For ragged arrays, we need to convert each element individually
expected_np_tensor = create_ragged_ndarray(
[t.astype(np.float64) for t in input_tensor]
)
pa_tensor = ArrowVariableShapedTensorArray.from_numpy(input_tensor)
res_tensor = pa_tensor.to_numpy()
assert len(res_tensor) == len(expected_np_tensor)
for actual, expected in zip(res_tensor, expected_np_tensor):
np.testing.assert_array_equal(actual, expected)
def test_arrow_scalar_tensor_array_roundtrip(tensor_format_context):
tensor_format = tensor_format_context
arr = np.arange(1000).reshape((10, 1, 100))
ata = ArrowTensorArray.from_numpy(arr)
assert isinstance(ata.type, tensor_format.to_type())
assert len(ata) == len(arr)
if tensor_format == FixedShapeTensorFormat.ARROW_NATIVE:
out = ata.to_numpy_ndarray()
else:
out = ata.to_numpy()
np.testing.assert_array_equal(out, arr)
def test_arrow_scalar_tensor_array_roundtrip_boolean(tensor_format_context):
arr = np.array([True, False, False, True])
ata = ArrowTensorArray.from_numpy(arr)
assert isinstance(ata.type, pa.DataType)
assert len(ata) == len(arr)
# Zero-copy is not possible since Arrow bitpacks boolean arrays while NumPy does
# not.
out = ata.to_numpy(zero_copy_only=False)
np.testing.assert_array_equal(out, arr)
def test_scalar_tensor_array_roundtrip(tensor_format_context):
tensor_format = tensor_format_context
arr = np.arange(1000).reshape(10, 1, 100)
ta = TensorArray(arr)
assert isinstance(ta.dtype, TensorDtype)
assert len(ta) == len(arr)
out = ta.to_numpy()
np.testing.assert_array_equal(out, arr)
# Check Arrow conversion.
ata = ta.__arrow_array__()
assert isinstance(ata.type, pa.DataType)
assert len(ata) == len(arr)
if tensor_format == FixedShapeTensorFormat.ARROW_NATIVE:
out = ata.to_numpy_ndarray()
else:
out = ata.to_numpy()
np.testing.assert_array_equal(out, arr)
def test_arrow_variable_shaped_tensor_array_validation(tensor_format_context):
# Test tensor elements with differing dimensions raises ValueError.
with pytest.raises(ValueError):
ArrowVariableShapedTensorArray.from_numpy([np.ones((2, 2)), np.ones((3, 3, 3))])
# Test arbitrary object raises ValueError.
with pytest.raises(ValueError):
ArrowVariableShapedTensorArray.from_numpy(object())
# Test empty array raises ValueError.
with pytest.raises(ValueError):
ArrowVariableShapedTensorArray.from_numpy(np.array([]))
# Test deeply ragged tensor raises ValueError.
with pytest.raises(ValueError):
ArrowVariableShapedTensorArray.from_numpy(
np.array(
[
np.array(
[
np.array([1, 2]),
np.array([3, 4, 5]),
],
dtype=object,
),
np.array(
[
np.array([5, 6, 7, 8]),
],
dtype=object,
),
np.array(
[
np.array([5, 6, 7, 8]),
np.array([5, 6, 7, 8]),
np.array([5, 6, 7, 8]),
],
dtype=object,
),
],
dtype=object,
)
)
def test_arrow_variable_shaped_tensor_array_roundtrip(restore_data_context):
shapes = [(2, 2), (3, 3), (4, 4)]
cumsum_sizes = np.cumsum([0] + [np.prod(shape) for shape in shapes[:-1]])
arrs = [
np.arange(offset, offset + np.prod(shape)).reshape(shape)
for offset, shape in zip(cumsum_sizes, shapes)
]
arr = create_ragged_ndarray(arrs)
ata = ArrowVariableShapedTensorArray.from_numpy(arr)
assert isinstance(ata.type, ArrowVariableShapedTensorType)
assert len(ata) == len(arr)
out = ata.to_numpy()
for o, a in zip(out, arr):
np.testing.assert_array_equal(o, a)
def test_arrow_variable_shaped_tensor_array_roundtrip_boolean(restore_data_context):
arr = np.array(
[[True, False], [False, False, True], [False], [True, True, False, True]],
dtype=object,
)
ata = ArrowVariableShapedTensorArray.from_numpy(arr)
assert isinstance(ata.type, ArrowVariableShapedTensorType)
assert len(ata) == len(arr)
out = ata.to_numpy()
for o, a in zip(out, arr):
np.testing.assert_array_equal(o, a)
def test_arrow_variable_shaped_tensor_array_roundtrip_contiguous_optimization(
restore_data_context,
):
# Test that a roundtrip on slices of an already-contiguous 1D base array does not
# create any unnecessary copies.
base = np.arange(6)
base_address = base.__array_interface__["data"][0]
arr = np.array([base[:2], base[2:]], dtype=object)
ata = ArrowVariableShapedTensorArray.from_numpy(arr)
assert isinstance(ata.type, ArrowVariableShapedTensorType)
assert len(ata) == len(arr)
assert ata.storage.field("data").buffers()[3].address == base_address
out = ata.to_numpy()
for o, a in zip(out, arr):
assert o.base.address == base_address
np.testing.assert_array_equal(o, a)
def test_arrow_variable_shaped_tensor_array_slice(restore_data_context):
shapes = [(2, 2), (3, 3), (4, 4)]
cumsum_sizes = np.cumsum([0] + [np.prod(shape) for shape in shapes[:-1]])
arrs = [
np.arange(offset, offset + np.prod(shape)).reshape(shape)
for offset, shape in zip(cumsum_sizes, shapes)
]
arr = np.array(arrs, dtype=object)
ata = ArrowVariableShapedTensorArray.from_numpy(arr)
assert isinstance(ata.type, ArrowVariableShapedTensorType)
assert len(ata) == len(arr)
indices = [0, 1, 2]
for i in indices:
np.testing.assert_array_equal(ata[i], arr[i])
slices = [
slice(0, 1),
slice(1, 2),
slice(2, 3),
slice(0, 2),
slice(1, 3),
slice(0, 3),
]
for slice_ in slices:
ata_slice = ata[slice_]
ata_slice_np = ata_slice.to_numpy()
arr_slice = arr[slice_]
# Check for equivalent dtypes and shapes.
assert ata_slice_np.dtype == arr_slice.dtype
assert ata_slice_np.shape == arr_slice.shape
# Iteration over tensor array slices triggers NumPy conversion.
for o, e in zip(ata_slice, arr_slice):
np.testing.assert_array_equal(o, e)
def test_arrow_variable_shaped_bool_tensor_array_slice(restore_data_context):
arr = np.array(
[
[True],
[True, False],
[False, True, False],
],
dtype=object,
)
ata = ArrowVariableShapedTensorArray.from_numpy(arr)
assert isinstance(ata.type, ArrowVariableShapedTensorType)
assert len(ata) == len(arr)
indices = [0, 1, 2]
for i in indices:
np.testing.assert_array_equal(ata[i], arr[i])
slices = [
slice(0, 1),
slice(1, 2),
slice(2, 3),
slice(0, 2),
slice(1, 3),
slice(0, 3),
]
for slice_ in slices:
ata_slice = ata[slice_]
ata_slice_np = ata_slice.to_numpy()
arr_slice = arr[slice_]
# Check for equivalent dtypes and shapes.
assert ata_slice_np.dtype == arr_slice.dtype
assert ata_slice_np.shape == arr_slice.shape
# Iteration over tensor array slices triggers NumPy conversion.
for o, e in zip(ata_slice, arr_slice):
np.testing.assert_array_equal(o, e)
def test_arrow_variable_shaped_string_tensor_array_slice(restore_data_context):
arr = np.array(
[
["Philip", "J", "Fry"],
["Leela", "Turanga"],
["Professor", "Hubert", "J", "Farnsworth"],
["Lrrr"],
],
dtype=object,
)
ata = ArrowVariableShapedTensorArray.from_numpy(arr)
assert isinstance(ata.type, ArrowVariableShapedTensorType)
assert len(ata) == len(arr)
indices = [0, 1, 2, 3]
for i in indices:
np.testing.assert_array_equal(ata[i], arr[i])
slices = [
slice(0, 1),
slice(1, 2),
slice(2, 3),
slice(3, 4),
slice(0, 2),
slice(1, 3),
slice(2, 4),
slice(0, 3),
slice(1, 4),
slice(0, 4),
]
for slice_ in slices:
ata_slice = ata[slice_]
ata_slice_np = ata_slice.to_numpy()
arr_slice = arr[slice_]
# Check for equivalent dtypes and shapes.
assert ata_slice_np.dtype == arr_slice.dtype
assert ata_slice_np.shape == arr_slice.shape
# Iteration over tensor array slices triggers NumPy conversion.
for o, e in zip(ata_slice, arr_slice):
np.testing.assert_array_equal(o, e)
def test_variable_shaped_tensor_array_roundtrip(restore_data_context):
shapes = [(2, 2), (3, 3), (4, 4)]
cumsum_sizes = np.cumsum([0] + [np.prod(shape) for shape in shapes[:-1]])
arrs = [
np.arange(offset, offset + np.prod(shape)).reshape(shape)
for offset, shape in zip(cumsum_sizes, shapes)
]
arr = np.array(arrs, dtype=object)
ta = TensorArray(arr)
assert isinstance(ta.dtype, TensorDtype)
assert len(ta) == len(arr)
out = ta.to_numpy()
for o, a in zip(out, arr):
np.testing.assert_array_equal(o, a)
# Check Arrow conversion.
ata = ta.__arrow_array__()
assert isinstance(ata.type, ArrowVariableShapedTensorType)
assert len(ata) == len(arr)
out = ata.to_numpy()
for o, a in zip(out, arr):
np.testing.assert_array_equal(o, a)
def test_variable_shaped_tensor_array_slice(restore_data_context):
shapes = [(2, 2), (3, 3), (4, 4)]
cumsum_sizes = np.cumsum([0] + [np.prod(shape) for shape in shapes[:-1]])
arrs = [
np.arange(offset, offset + np.prod(shape)).reshape(shape)
for offset, shape in zip(cumsum_sizes, shapes)
]
arr = np.array(arrs, dtype=object)
ta = TensorArray(arr)
assert isinstance(ta.dtype, TensorDtype)
assert len(ta) == len(arr)
indices = [0, 1, 2]
for i in indices:
np.testing.assert_array_equal(ta[i], arr[i])
slices = [
slice(0, 1),
slice(1, 2),
slice(2, 3),
slice(0, 2),
slice(1, 3),
slice(0, 3),
]
for slice_ in slices:
for o, e in zip(ta[slice_], arr[slice_]):
np.testing.assert_array_equal(o, e)
def test_tensor_array_ops(tensor_format_context):
outer_dim = 3
inner_shape = (2, 2, 2)
shape = (outer_dim,) + inner_shape
num_items = np.prod(np.array(shape))
arr = np.arange(num_items).reshape(shape)
df = pd.DataFrame({"one": [1, 2, 3], "two": TensorArray(arr)})
def apply_arithmetic_ops(arr):
return 2 * (arr + 1) / 3
def apply_comparison_ops(arr):
return arr % 2 == 0
def apply_logical_ops(arr):
return arr & (3 * arr) | (5 * arr)
# Op tests, using NumPy as the groundtruth.
np.testing.assert_equal(apply_arithmetic_ops(arr), apply_arithmetic_ops(df["two"]))
np.testing.assert_equal(apply_comparison_ops(arr), apply_comparison_ops(df["two"]))
np.testing.assert_equal(apply_logical_ops(arr), apply_logical_ops(df["two"]))
def test_tensor_array_array_protocol(tensor_format_context):
outer_dim = 3
inner_shape = (2, 2, 2)
shape = (outer_dim,) + inner_shape
num_items = np.prod(np.array(shape))
arr = np.arange(num_items).reshape(shape)
t_arr = TensorArray(arr)
np.testing.assert_array_equal(
np.asarray(t_arr, dtype=np.float32), arr.astype(np.float32)
)
t_arr_elem = t_arr[0]
np.testing.assert_array_equal(
np.asarray(t_arr_elem, dtype=np.float32), arr[0].astype(np.float32)
)
def test_tensor_array_dataframe_repr(tensor_format_context):
outer_dim = 3
inner_shape = (2, 2)
shape = (outer_dim,) + inner_shape
num_items = np.prod(np.array(shape))
arr = np.arange(num_items).reshape(shape)
t_arr = TensorArray(arr)
df = pd.DataFrame({"a": t_arr})
expected_repr = """ a
0 [[ 0, 1], [ 2, 3]]
1 [[ 4, 5], [ 6, 7]]
2 [[ 8, 9], [10, 11]]"""
assert repr(df) == expected_repr
def test_tensor_array_scalar_cast(tensor_format_context):
outer_dim = 3
inner_shape = (1,)
shape = (outer_dim,) + inner_shape
num_items = np.prod(np.array(shape))
arr = np.arange(num_items).reshape(shape)
t_arr = TensorArray(arr)
for t_arr_elem, arr_elem in zip(t_arr, arr):
assert float(t_arr_elem) == float(arr_elem)
arr = np.arange(1).reshape((1, 1, 1))
t_arr = TensorArray(arr)
assert float(t_arr) == float(arr)
def test_tensor_array_reductions(tensor_format_context):
outer_dim = 3
inner_shape = (2, 2, 2)
shape = (outer_dim,) + inner_shape
num_items = np.prod(np.array(shape))
arr = np.arange(num_items).reshape(shape)
df = pd.DataFrame({"one": list(range(outer_dim)), "two": TensorArray(arr)})
# Reduction tests, using NumPy as the groundtruth.
for name, reducer in TensorArray.SUPPORTED_REDUCERS.items():
np_kwargs = {}
if name in ("std", "var"):
# Pandas uses a ddof default of 1 while NumPy uses 0.
# Give NumPy a ddof kwarg of 1 in order to ensure equivalent
# standard deviation calculations.
np_kwargs["ddof"] = 1
np.testing.assert_equal(df["two"].agg(name), reducer(arr, axis=0, **np_kwargs))
@pytest.mark.parametrize("shape", [(2, 0), (2, 5, 0), (0, 5), (0, 0)])
def test_zero_length_arrow_tensor_array_roundtrip(tensor_format_context, shape):
arr = np.empty(shape, dtype=np.int8)
t_arr = ArrowTensorArray.from_numpy(arr)
assert len(t_arr) == len(arr)
out = t_arr.to_numpy_ndarray()
np.testing.assert_array_equal(out, arr)
@pytest.mark.parametrize("chunked", [False, True])
def test_arrow_tensor_array_getitem(chunked, tensor_format_context):
tensor_format = tensor_format_context
outer_dim = 3
inner_shape = (2, 2, 2)
shape = (outer_dim,) + inner_shape
num_items = np.prod(np.array(shape))
arr = np.arange(num_items).reshape(shape)
t_arr = ArrowTensorArray.from_numpy(arr)
if chunked:
t_arr = pa.chunked_array(t_arr)
pyarrow_version = get_pyarrow_version()
if (
chunked
and pyarrow_version >= parse_version("8.0.0")
and pyarrow_version < parse_version("9.0.0")
):
for idx in range(outer_dim):
item = t_arr[idx]
assert isinstance(item, pa.ExtensionScalar)
item = item.type._extension_scalar_to_ndarray(item)
np.testing.assert_array_equal(item, arr[idx])
else:
for idx in range(outer_dim):
item = t_arr[idx]
if pyarrow_version >= parse_version("16.0.0"):
# Returns native FixedShapeTensorScalar
np.testing.assert_array_equal(item.to_numpy(), arr[idx])
else:
# Returns an ExtensionScalar, item.type: FixedShapeTensorType
np.testing.assert_array_equal(
fixed_shape_extension_scalar_to_ndarray(item), arr[idx]
)
# NOTE: In addition we verify that for existing ``ArrowTensorScalar``
# implements `__array__` method therefore implementing Numpy
# array protocol
if tensor_format != FixedShapeTensorFormat.ARROW_NATIVE:
np.testing.assert_array_equal(item, arr[idx])
# Test __iter__.
for t_subarr, subarr in zip(t_arr, arr):
if pyarrow_version >= parse_version("16.0.0"):
# Returns native FixedShapeTensorScalar
np.testing.assert_array_equal(t_subarr.to_numpy(), subarr)
else:
# Returns an ExtensionScalar
np.testing.assert_array_equal(
fixed_shape_extension_scalar_to_ndarray(t_subarr), subarr
)
# Test to_pylist.
# Note: With tensor_format_context fixture, ARROW_NATIVE is only tested when
# FixedShapeTensorType is available (PyArrow >= 12), so no fallback needed.
if tensor_format == FixedShapeTensorFormat.ARROW_NATIVE:
np.testing.assert_array_equal(t_arr.to_pylist(), arr.reshape(outer_dim, -1))
else:
np.testing.assert_array_equal(t_arr.to_pylist(), list(arr))
# Test slicing and indexing.
t_arr2 = t_arr[1:]
if chunked:
# For extension arrays, ChunkedArray.to_numpy() concatenates chunk storage
# arrays and calls to_numpy() on the resulting array, which returns the wrong
# ndarray.
# TODO(Clark): Fix this in Arrow by (1) providing an ExtensionArray hook for
# concatenation, and (2) using that + a to_numpy() call on the resulting
# ExtensionArray.
t_arr2_npy = t_arr2.chunk(0).to_numpy_ndarray()
else:
t_arr2_npy = t_arr2.to_numpy_ndarray()
np.testing.assert_array_equal(t_arr2_npy, arr[1:])
if (
chunked
and pyarrow_version >= parse_version("8.0.0")
and pyarrow_version < parse_version("9.0.0")
and tensor_format != FixedShapeTensorFormat.ARROW_NATIVE
):
for idx in range(1, outer_dim):
item = t_arr2[idx - 1]
assert isinstance(item, pa.ExtensionScalar)
item = item.type._extension_scalar_to_ndarray(item)
np.testing.assert_array_equal(item, arr[idx])
else:
for idx in range(1, outer_dim):
item = t_arr2[idx - 1]
if pyarrow_version >= parse_version("16.0.0"):
# Returns native FixedShapeTensorScalar
np.testing.assert_array_equal(item.to_numpy(), arr[idx])
else:
# Returns an ExtensionScalar
np.testing.assert_array_equal(
fixed_shape_extension_scalar_to_ndarray(item), arr[idx]
)
@pytest.mark.parametrize("chunked", [False, True])
def test_arrow_variable_shaped_tensor_array_getitem(chunked, tensor_format_context):
shapes = [(2, 2), (3, 3), (4, 4)]
outer_dim = len(shapes)
cumsum_sizes = np.cumsum([0] + [np.prod(shape) for shape in shapes[:-1]])
arrs = [
np.arange(offset, offset + np.prod(shape)).reshape(shape)
for offset, shape in zip(cumsum_sizes, shapes)
]
arr = np.array(arrs, dtype=object)
t_arr = ArrowVariableShapedTensorArray.from_numpy(arr)
if chunked:
t_arr = pa.chunked_array(t_arr)
pyarrow_version = get_pyarrow_version()
if (
chunked
and pyarrow_version >= parse_version("8.0.0")
and pyarrow_version < parse_version("9.0.0")
):
for idx in range(outer_dim):
item = t_arr[idx]
assert isinstance(item, pa.ExtensionScalar)
item = item.type._extension_scalar_to_ndarray(item)
np.testing.assert_array_equal(item, arr[idx])
else:
for idx in range(outer_dim):
np.testing.assert_array_equal(t_arr[idx], arr[idx])
# Test __iter__.
for t_subarr, subarr in zip(t_arr, arr):
np.testing.assert_array_equal(t_subarr, subarr)
# Test to_pylist.
for t_subarr, subarr in zip(t_arr.to_pylist(), list(arr)):
np.testing.assert_array_equal(t_subarr, subarr)
# Test slicing and indexing.
t_arr2 = t_arr[1:]
if chunked:
# For extension arrays, ChunkedArray.to_numpy() concatenates chunk storage
# arrays and calls to_numpy() on the resulting array, which returns the wrong
# ndarray.
# TODO(Clark): Fix this in Arrow by (1) providing an ExtensionArray hook for
# concatenation, and (2) using that + a to_numpy() call on the resulting
# ExtensionArray.
t_arr2_npy = t_arr2.chunk(0).to_numpy()
else:
t_arr2_npy = t_arr2.to_numpy()
for t_subarr, subarr in zip(t_arr2_npy, arr[1:]):
np.testing.assert_array_equal(t_subarr, subarr)
if (
chunked
and pyarrow_version >= parse_version("8.0.0")
and pyarrow_version < parse_version("9.0.0")
):
for idx in range(1, outer_dim):
item = t_arr2[idx - 1]
assert isinstance(item, pa.ExtensionScalar)
item = item.type._extension_scalar_to_ndarray(item)
np.testing.assert_array_equal(item, arr[idx])
else:
for idx in range(1, outer_dim):
np.testing.assert_array_equal(t_arr2[idx - 1], arr[idx])
@pytest.mark.parametrize(
"test_arr,dtype",
[
([[1, 2], [3, 4], [5, 6], [7, 8]], None),
([[1, 2], [3, 4], [5, 6], [7, 8]], np.int32),
([[1, 2], [3, 4], [5, 6], [7, 8]], np.int16),
([[1, 2], [3, 4], [5, 6], [7, 8]], np.longlong),
([[1.5, 2.5], [3.3, 4.2], [5.2, 6.9], [7.6, 8.1]], None),
([[1.5, 2.5], [3.3, 4.2], [5.2, 6.9], [7.6, 8.1]], np.float32),
([[1.5, 2.5], [3.3, 4.2], [5.2, 6.9], [7.6, 8.1]], np.float16),
([["B", "A"], ["A", "B"], ["A", "A"], ["B", "B"]], None),
([[False, True], [True, False], [True, True], [False, False]], None),
],
)
def test_arrow_tensor_array_slice(test_arr, dtype, tensor_format_context):
# Test that ArrowTensorArray slicing works as expected.
arr = np.array(test_arr, dtype=dtype)
ata = ArrowTensorArray.from_numpy(arr)
np.testing.assert_array_equal(ata.to_numpy_ndarray(), arr)
slice1 = ata.slice(0, 2)
np.testing.assert_array_equal(slice1.to_numpy_ndarray(), arr[0:2])
np.testing.assert_array_equal(slice1[1].as_py(), arr[1])
slice2 = ata.slice(2, 2)
np.testing.assert_array_equal(slice2.to_numpy_ndarray(), arr[2:4])
np.testing.assert_array_equal(slice2[1].as_py(), arr[3])
pytest_tensor_array_concat_shapes = [(1, 2, 2), (3, 2, 2), (2, 3, 3)]
pytest_tensor_array_concat_arrs = [
np.arange(np.prod(shape)).reshape(shape)
for shape in pytest_tensor_array_concat_shapes
]
pytest_tensor_array_concat_arrs += [
create_ragged_ndarray(
[np.arange(4).reshape((2, 2)), np.arange(4, 13).reshape((3, 3))]
)
]
pytest_tensor_array_concat_arr_combinations = list(
itertools.combinations(pytest_tensor_array_concat_arrs, 2)
)
@pytest.mark.parametrize("a1,a2", pytest_tensor_array_concat_arr_combinations)
def test_tensor_array_concat(a1, a2, tensor_format_context):
ta1 = TensorArray(a1)
ta2 = TensorArray(a2)
ta = TensorArray._concat_same_type([ta1, ta2])
assert len(ta) == a1.shape[0] + a2.shape[0]
assert ta.dtype.element_dtype == ta1.dtype.element_dtype
if a1.shape[1:] == a2.shape[1:]:
assert ta.dtype.element_shape == a1.shape[1:]
np.testing.assert_array_equal(ta.to_numpy(), np.concatenate([a1, a2]))
else:
assert ta.dtype.element_shape == (None,) * (len(a1.shape) - 1)
for arr, expected in zip(
ta.to_numpy(), np.array([e for a in [a1, a2] for e in a], dtype=object)
):
np.testing.assert_array_equal(arr, expected)
@pytest.mark.parametrize("a1,a2", pytest_tensor_array_concat_arr_combinations)
def test_arrow_tensor_array_concat(a1, a2, tensor_format_context):
tensor_format = tensor_format_context
ta1 = ArrowTensorArray.from_numpy(a1)
ta2 = ArrowTensorArray.from_numpy(a2)
ta = concat_tensor_arrays([ta1, ta2])
assert len(ta) == a1.shape[0] + a2.shape[0]
if a1.shape[1:] == a2.shape[1:]:
# With tensor_format_context, ARROW_NATIVE is only tested when
# FixedShapeTensorType is available, so to_type() is safe to use
assert isinstance(ta.type, tensor_format.to_type())
assert ta.type.storage_type == ta1.type.storage_type
assert ta.type.storage_type == ta2.type.storage_type
assert tuple(ta.type.shape) == a1.shape[1:]
np.testing.assert_array_equal(ta.to_numpy_ndarray(), np.concatenate([a1, a2]))
else:
assert isinstance(ta.type, ArrowVariableShapedTensorType)
assert pa.types.is_struct(ta.type.storage_type)
for arr, expected in zip(
ta.to_numpy(), np.array([e for a in [a1, a2] for e in a], dtype=object)
):
np.testing.assert_array_equal(arr, expected)
def test_variable_shaped_tensor_array_chunked_concat(tensor_format_context):
# Test that chunking a tensor column and concatenating its chunks preserves typing
# and underlying data.
shape1 = (2, 2, 2)
shape2 = (3, 4, 4)
a1 = np.arange(np.prod(shape1)).reshape(shape1)
a2 = np.arange(np.prod(shape2)).reshape(shape2)
ta1 = ArrowTensorArray.from_numpy(a1)
ta2 = ArrowTensorArray.from_numpy(a2)
unified_arrs = unify_tensor_arrays([ta1, ta2])
ta = concat_tensor_arrays(unified_arrs)
assert len(ta) == shape1[0] + shape2[0]
assert isinstance(ta.type, ArrowVariableShapedTensorType)
assert pa.types.is_struct(ta.type.storage_type)
for arr, expected in zip(
ta.to_numpy(), np.array([e for a in [a1, a2] for e in a], dtype=object)
):
np.testing.assert_array_equal(arr, expected)
def test_variable_shaped_tensor_array_uniform_dim(tensor_format_context):
shape1 = (3, 2, 2)
shape2 = (3, 4, 4)
a1 = np.arange(np.prod(shape1)).reshape(shape1)
a2 = np.arange(np.prod(shape2)).reshape(shape2)
ta = TensorArray([a1, a2])
assert len(ta) == 2
assert ta.is_variable_shaped
for a, expected in zip(ta.to_numpy(), [a1, a2]):
np.testing.assert_array_equal(a, expected)
def test_large_arrow_tensor_array(tensor_format_context):
tensor_format = tensor_format_context
test_arr = np.ones((1000, 550), dtype=np.uint8)
if tensor_format == FixedShapeTensorFormat.V1:
with pytest.raises(ArrowConversionError) as exc_info:
ta = ArrowTensorArray.from_numpy([test_arr] * 4000)
assert (
repr(exc_info.value.__cause__)
== "ArrowInvalid('Negative offsets in list array')"
)
else:
ta = ArrowTensorArray.from_numpy([test_arr] * 4000)
assert len(ta) == 4000
ta = ta.to_numpy_ndarray()
for arr in ta:
assert arr.shape == (1000, 550)
def test_tensor_array_string_tensors_simple(tensor_format_context):
"""Simple test for fixed-shape string tensor arrays with pandas/arrow roundtrip."""
# Create fixed-shape string tensor
string_tensors = np.array(
[["hello", "world"], ["arrow", "pandas"], ["tensor", "string"]]
)
# Create pandas DataFrame with TensorArray
df_pandas = pd.DataFrame({"id": [1, 2, 3], "strings": TensorArray(string_tensors)})
# Convert to Arrow table
arrow_table = pa.Table.from_pandas(df_pandas)
# Verify the roundtrip preserves the data
original_strings = df_pandas["strings"].to_numpy()
roundtrip_strings = combine_chunked_array(arrow_table["strings"]).to_numpy_ndarray()
np.testing.assert_array_equal(original_strings, roundtrip_strings)
np.testing.assert_array_equal(roundtrip_strings, string_tensors)
def test_tensor_type_equality_checks():
# Test that different types are not equal
fs_tensor_type_v1 = ArrowTensorType((2, 3), pa.int64())
fs_tensor_type_v2 = ArrowTensorTypeV2((2, 3), pa.int64())
assert fs_tensor_type_v1 != fs_tensor_type_v2
# Test different shapes/dtypes aren't equal
assert fs_tensor_type_v1 != ArrowTensorType((3, 3), pa.int64())
assert fs_tensor_type_v1 != ArrowTensorType((2, 3), pa.float64())
assert fs_tensor_type_v2 != ArrowTensorTypeV2((3, 3), pa.int64())
assert fs_tensor_type_v2 != ArrowTensorTypeV2((2, 3), pa.float64())
# Test var-shaped tensor type
vs_tensor_type = ArrowVariableShapedTensorType(pa.int64(), 2)
# Test that different types are not equal
assert vs_tensor_type == ArrowVariableShapedTensorType(pa.int64(), 3)
assert vs_tensor_type != ArrowVariableShapedTensorType(pa.float64(), 2)
assert vs_tensor_type != fs_tensor_type_v1
assert vs_tensor_type != fs_tensor_type_v2
class TestCreateFixedShapeTensorType:
"""Tests for the create_arrow_fixed_shape_tensor_type factory function."""
@pytest.mark.parametrize(
"tensor_format,expected_type_if_native_available,expected_type_fallback",
[
# V1
(FixedShapeTensorFormat.V1, ArrowTensorType, ArrowTensorType),
# V2 (default)
(FixedShapeTensorFormat.V2, ArrowTensorTypeV2, ArrowTensorTypeV2),
# NATIVE with V2 fallback when NATIVE unavailable
(
FixedShapeTensorFormat.ARROW_NATIVE,
FixedShapeTensorType,
ArrowTensorTypeV2,
),
],
)
def test_context_defaults(
self,
tensor_format_context,
expected_type_if_native_available,
expected_type_fallback,
):
"""Test default tensor type based on context settings with fallback behavior."""
tensor_type = create_arrow_fixed_shape_tensor_type(
shape=(2, 3), dtype=pa.int64()
)
if FixedShapeTensorType is not None:
assert isinstance(tensor_type, expected_type_if_native_available)
else:
assert isinstance(tensor_type, expected_type_fallback)
@pytest.mark.parametrize(
"dtype",
[pa.int8(), pa.int16(), pa.int32(), pa.int64(), pa.float32(), pa.float64()],
)
def test_various_dtypes(self, tensor_format_context, dtype):
"""Test factory works with various PyArrow dtypes across all formats."""
tensor_type = create_arrow_fixed_shape_tensor_type(shape=(2, 2), dtype=dtype)
assert tensor_type.value_type == dtype
@pytest.mark.skipif(
not _extension_array_concat_supported(),
reason="ExtensionArrays support concatenation only in Pyarrow >= 12.0",
)
def test_arrow_fixed_shape_tensor_format_eq_with_concat(tensor_format_context):
"""Test that ArrowTensorType, ArrowTensorTypeV2, and native tensor type __eq__
methods work correctly when concatenating Arrow arrays with the same tensor type."""
tensor_format = tensor_format_context
# Create the appropriate tensor type based on format
if tensor_format == FixedShapeTensorFormat.V1:
tensor_type = ArrowTensorType((2, 3), pa.int64())
elif tensor_format == FixedShapeTensorFormat.V2:
tensor_type = ArrowTensorTypeV2((2, 3), pa.int64())
else: # ARROW_NATIVE
tensor_type = pa.fixed_shape_tensor(pa.int64(), (2, 3))
first = ArrowTensorArray.from_numpy(np.ones((2, 2, 3), dtype=np.int64))
second = ArrowTensorArray.from_numpy(np.zeros((3, 2, 3), dtype=np.int64))
assert first.type == second.type
# Assert commutation
assert tensor_type == first.type
assert first.type == tensor_type
# Test concatenation works appropriately
concatenated = pa.concat_arrays([first, second])
assert len(concatenated) == 5
assert concatenated.type == tensor_type
expected = np.vstack([first.to_numpy_ndarray(), second.to_numpy_ndarray()])
np.testing.assert_array_equal(concatenated.to_numpy_ndarray(), expected)
@pytest.mark.skipif(
not _extension_array_concat_supported(),
reason="ExtensionArrays support concatenation only in Pyarrow >= 12.0",
)
def test_arrow_variable_shaped_tensor_type_eq_with_concat():
"""Test that ArrowVariableShapedTensorType __eq__ method works correctly
when concatenating Arrow arrays with variable shaped tensors."""
from ray.data.extensions.tensor_extension import (
ArrowVariableShapedTensorArray,
)
#
# Case 1: Tensors are variable-shaped but same ``ndim``
#
# Create arrays with variable-shaped tensors (but same ndim)
first_tensors = [
# (2, 2)
np.array([[1, 2], [3, 4]]),
# (2, 3)
np.array([[5, 6, 7], [8, 9, 10]]),
]
second_tensors = [
# (1, 4)
np.array([[11, 12, 13, 14]]),
# (3, 1)
np.array([[15], [16], [17]]),
]
first_arr = ArrowVariableShapedTensorArray.from_numpy(first_tensors)
second_arr = ArrowVariableShapedTensorArray.from_numpy(second_tensors)
# Assert commutation
assert first_arr.type == second_arr.type
assert second_arr.type == first_arr.type
# Assert hashing is correct
assert hash(first_arr.type) == hash(second_arr.type)
assert first_arr.type.ndim == 2
assert second_arr.type.ndim == 2
# Test concatenation works appropriately
concatenated = pa.concat_arrays([first_arr, second_arr])
assert len(concatenated) == 4
assert concatenated.type == first_arr.type
result_ndarray = concatenated.to_numpy()
for i, expected_ndarray in enumerate(
itertools.chain.from_iterable([first_tensors, second_tensors])
):
assert result_ndarray[i].shape == expected_ndarray.shape
np.testing.assert_array_equal(result_ndarray[i], expected_ndarray)
#
# Case 2: Tensors are variable-shaped, with diverging ``ndim``s
#
# Create arrays with variable-shaped tensors (but different ndim)
first_tensors = [
# (1, 2, 1)
np.array([[[1], [2]], [[3], [4]]]),
# (2, 3, 1)
np.array([[[5], [6], [7]], [[8], [9], [10]]]),
]
second_tensors = [
# (1, 4)
np.array([[11, 12, 13, 14]]),
# (3, 1)
np.array([[15], [16], [17]]),
]
first_arr = ArrowVariableShapedTensorArray.from_numpy(first_tensors)
second_arr = ArrowVariableShapedTensorArray.from_numpy(second_tensors)
# Assert commutation
assert first_arr.type == second_arr.type
assert second_arr.type == first_arr.type
# Assert hashing is correct
assert hash(first_arr.type) == hash(second_arr.type)
assert first_arr.type.ndim == 3
assert second_arr.type.ndim == 2
# Test concatenation works appropriately
concatenated = pa.concat_arrays([first_arr, second_arr])
assert len(concatenated) == 4
assert concatenated.type == first_arr.type
result_ndarray = concatenated.to_numpy()
for i, expected_ndarray in enumerate(
itertools.chain.from_iterable([first_tensors, second_tensors])
):
assert result_ndarray[i].shape == expected_ndarray.shape
np.testing.assert_array_equal(result_ndarray[i], expected_ndarray)
def test_reverse_order():
"""Test views in reverse order."""
base = np.arange(100, dtype=np.float64)
raveled = np.empty(3, dtype=np.object_)
raveled[0] = base[50:60].ravel()
raveled[1] = base[30:50].ravel()
raveled[2] = base[0:30].ravel()
# Reverse order views should NOT be contiguous
assert not _are_contiguous_1d_views(raveled)
def test_concat_ndarrays_zero_copy():
"""Test that _concat_ndarrays performs zero-copy concatenation when possible."""
# Case 1: Create a base array and contiguous views
base = np.arange(100, dtype=np.int64)
arrs = [base[0:20], base[20:50], base[50:100]]
result = _concat_ndarrays(arrs)
np.testing.assert_array_equal(result, base)
# Verify it's a zero-copy view (shares memory with base)
assert np.shares_memory(result, base)
# Case 2: Verify empty views are skipped
arrs = [base[0:10], base[10:10], base[10:20]] # Empty array
result = _concat_ndarrays(arrs)
expected = np.concatenate([base[0:10], base[10:20]])
np.testing.assert_array_equal(result, expected)
# Verify it's a zero-copy view (shares memory with base)
assert np.shares_memory(result, base)
# Case 3: Singleton ndarray is returned as is
result = _concat_ndarrays([base])
# Should return the same array or equivalent
assert result is base
def test_concat_ndarrays_non_contiguous_fallback():
"""Test that _concat_ndarrays falls back to np.concatenate when arrays aren't contiguous."""
# Case 1: Non-contiguous arrays
arr1 = np.arange(10, dtype=np.float32)
_ = np.arange(1000) # Create gap to prevent contiguity
arr2 = np.arange(10, 20, dtype=np.float32)
_ = np.arange(1000) # Create gap to prevent contiguity
arr3 = np.arange(20, 30, dtype=np.float32)
arrs = [arr1, arr2, arr3]
result = _concat_ndarrays(arrs)
expected = np.concatenate(arrs)
np.testing.assert_array_equal(result, expected)
assert all(not np.shares_memory(result, a) for a in arrs)
# Case 2: Non-contiguous arrays (take 2)
base = np.arange(100, dtype=np.float64)
arrs = [base[0:10], base[20:30], base[30:40]] # Gap from 10-20
result = _concat_ndarrays(arrs)
expected = np.concatenate(arrs)
np.testing.assert_array_equal(result, expected)
# Should have created a copy since there's a gap
assert not np.shares_memory(result, base)
def test_concat_ndarrays_diff_dtypes_fallback():
"""Different dtypes"""
base_int16 = np.arange(50, dtype=np.int16)
base_int32 = np.arange(50, dtype=np.int32)
# Different dtypes should use fallback
arrs = [base_int16, base_int32]
# This should use np.concatenate with type promotion
result = _concat_ndarrays(arrs)
expected = np.concatenate(arrs)
np.testing.assert_array_equal(result, expected)
assert result.dtype == expected.dtype
def test_are_contiguous_1d_views_non_raveled():
"""Test that _are_contiguous_1d_views rejects non-1D arrays."""
base = np.arange(100, dtype=np.int64).reshape(10, 10)
arrs = [
base[0:2].ravel(), # 1D view
base[2:4], # 2D array
]
# Should reject because second array is not 1D
assert not _are_contiguous_1d_views(arrs)
def test_are_contiguous_1d_views_non_c_contiguous():
"""Test _are_contiguous_1d_views with non-C-contiguous arrays."""
base = np.arange(100, dtype=np.int64).reshape(10, 10)
# Column slices are not C-contiguous
arrs = [base[:, 0], base[:, 1]]
assert not _are_contiguous_1d_views(arrs)
def test_are_contiguous_1d_views_different_bases():
"""Test _are_contiguous_1d_views with views from different base arrays."""
base1 = np.arange(50, dtype=np.int64)
_ = np.arange(1000, dtype=np.int64) # Create gap to prevent contiguity
base2 = np.arange(50, 100, dtype=np.int64)
arrs = [base1, base2]
# Different base arrays
assert not _are_contiguous_1d_views(arrs)
def test_are_contiguous_1d_views_overlapping():
"""Test _are_contiguous_1d_views with overlapping views."""
base = np.arange(100, dtype=np.float64)
arrs = [base[0:20], base[10:30]] # Overlaps with first
# Overlapping views are not contiguous
assert not _are_contiguous_1d_views(arrs)
def test_concat_ndarrays_complex_views():
"""Test _concat_ndarrays with complex view scenarios."""
# Create a 2D array and take contiguous row views
base_2d = np.arange(100, dtype=np.int64).reshape(10, 10)
base = base_2d.ravel() # Get 1D view
# Take contiguous slices of the 1D view
arrs = [base[0:30], base[30:60], base[60:100]]
result = _concat_ndarrays(arrs)
np.testing.assert_array_equal(result, base)
assert np.shares_memory(
result, base_2d
) # Should share memory with original 2D array
def test_concat_ndarrays_strided_views():
"""Test _concat_ndarrays with strided (non-contiguous) views."""
base = np.arange(100, dtype=np.float64)
# Every other element - these are strided views
arrs = [base[::2], base[1::2]] # Even indices # Odd indices
# Strided views are not C-contiguous
result = _concat_ndarrays(arrs)
expected = np.concatenate(arrs)
np.testing.assert_array_equal(result, expected)
# Should have created a copy
assert not np.shares_memory(result, base)
def test_arrow_extension_serialize_deserialize_cache():
"""Test caching behavior of ArrowExtensionSerializeDeserializeCache."""
# Test 1: Serialization cache is instance-level
# Create a fresh test instance
tensor_type = ArrowTensorType(shape=(2, 3), dtype=pa.int64())
# Clear the instance's serialization cache to ensure fresh test
tensor_type._serialize_cache = None
# Track calls to _arrow_ext_serialize_compute to verify caching
with patch.object(
tensor_type,
"_arrow_ext_serialize_compute",
wraps=tensor_type._arrow_ext_serialize_compute,
) as mock_serialize:
# First serialization should call compute function
serialized1 = tensor_type.__arrow_ext_serialize__()
assert mock_serialize.call_count == 1
assert serialized1 is not None
assert isinstance(serialized1, bytes)
# Second serialization should use cache (no additional call)
serialized2 = tensor_type.__arrow_ext_serialize__()
assert mock_serialize.call_count == 1 # Still 1, proving cache hit
assert serialized1 == serialized2
# Test 2: Deserialization cache is class-level (shared across instances)
# Clear the lru_cache to ensure fresh test
ArrowTensorType._arrow_ext_deserialize_cache.cache_clear()
storage_type = pa.list_(pa.int64())
# Track calls to _arrow_ext_deserialize_compute to verify caching
with patch.object(
ArrowTensorType,
"_arrow_ext_deserialize_compute",
wraps=ArrowTensorType._arrow_ext_deserialize_compute,
) as mock_deserialize:
# First deserialization should call compute function
deserialized1 = ArrowTensorType.__arrow_ext_deserialize__(
storage_type, serialized1
)
assert mock_deserialize.call_count == 1
assert deserialized1.shape == (2, 3)
assert deserialized1.value_type == pa.int64()
# Second deserialization with same key should use cache (no additional call)
deserialized2 = ArrowTensorType.__arrow_ext_deserialize__(
storage_type, serialized1
)
assert mock_deserialize.call_count == 1 # Still 1, proving cache hit
assert deserialized1.shape == deserialized2.shape
assert deserialized1.value_type == deserialized2.value_type
assert deserialized1.extension_name == deserialized2.extension_name
# Test 3: Different serialized data produces different cache entries
tensor_type2 = ArrowTensorType(shape=(3, 4), dtype=pa.int32())
tensor_type2._serialize_cache = None
different_serialized = tensor_type2.__arrow_ext_serialize__()
storage_type2 = pa.list_(pa.int32())
deserialized3 = ArrowTensorType.__arrow_ext_deserialize__(
storage_type2, different_serialized
)
# Should be different from previous deserialization
assert deserialized3.shape == (3, 4)
assert deserialized3.value_type == pa.int32()
assert deserialized3.shape != deserialized1.shape
# Test 4: Cache parameter generation works correctly
param1 = ArrowTensorType._get_deserialize_parameter(storage_type, serialized1)
param2 = ArrowTensorType._get_deserialize_parameter(storage_type, serialized1)
assert param1 == param2 # Same inputs should produce same parameters
param3 = ArrowTensorType._get_deserialize_parameter(
storage_type2, different_serialized
)
assert param1 != param3 # Different inputs should produce different parameters
# Test 5: Multiple instances have separate serialization caches
tensor_type_a = ArrowTensorType(shape=(2, 3), dtype=pa.int64())
tensor_type_b = ArrowTensorType(shape=(2, 3), dtype=pa.int64())
# Clear caches
tensor_type_a._serialize_cache = None
tensor_type_b._serialize_cache = None
# Track calls to verify separate caches
with patch.object(
tensor_type_a,
"_arrow_ext_serialize_compute",
wraps=tensor_type_a._arrow_ext_serialize_compute,
) as mock_a, patch.object(
tensor_type_b,
"_arrow_ext_serialize_compute",
wraps=tensor_type_b._arrow_ext_serialize_compute,
) as mock_b:
# Serialize both instances
serialized_a = tensor_type_a.__arrow_ext_serialize__()
serialized_b = tensor_type_b.__arrow_ext_serialize__()
# Each should have been called once (separate caches)
assert mock_a.call_count == 1
assert mock_b.call_count == 1
# Both should produce the same serialized data (same shape and dtype)
assert serialized_a == serialized_b
# Second calls should use respective caches (no additional calls)
assert tensor_type_a.__arrow_ext_serialize__() == serialized_a
assert tensor_type_b.__arrow_ext_serialize__() == serialized_b
assert mock_a.call_count == 1 # Cache hit
assert mock_b.call_count == 1 # Cache hit
# Test 6: Deserialization cache is shared (class-level)
# The cache is class-level, so all instances share it
# Note: deserialized1 and deserialized2 were already created in Test 2,
# so the cache should already have this entry. Let's verify it's reused.
with patch.object(
ArrowTensorType,
"_arrow_ext_deserialize_compute",
wraps=ArrowTensorType._arrow_ext_deserialize_compute,
) as mock_deserialize_shared:
# These should use the cache from Test 2 (no new compute calls)
deserialized_a = ArrowTensorType.__arrow_ext_deserialize__(
storage_type, serialized1
)
deserialized_b = ArrowTensorType.__arrow_ext_deserialize__(
storage_type, serialized1
)
# Should not call compute again (cache hit from Test 2)
assert mock_deserialize_shared.call_count == 0
# Both should be equal (cache hit)
assert deserialized_a.shape == deserialized_b.shape
assert deserialized_a.value_type == deserialized_b.value_type
assert deserialized_a.extension_name == deserialized_b.extension_name
def test_arrow_extension_deserialize_cache_per_class():
"""Test that different classes have separate deserialization caches."""
# Create instances of different classes with the same shape and dtype
tensor_type_v1 = ArrowTensorType(shape=(2, 3), dtype=pa.int64())
tensor_type_v2 = ArrowTensorTypeV2(shape=(2, 3), dtype=pa.int64())
# Serialize both (they should produce the same serialized data since shape is the same)
serialized_v1 = tensor_type_v1.__arrow_ext_serialize__()
serialized_v2 = tensor_type_v2.__arrow_ext_serialize__()
# They should have the same serialized representation (same shape)
assert serialized_v1 == serialized_v2
# Clear both caches to ensure fresh test
ArrowTensorType._arrow_ext_deserialize_cache.cache_clear()
ArrowTensorTypeV2._arrow_ext_deserialize_cache.cache_clear()
# Get storage types for each class
storage_type_v1 = pa.list_(pa.int64()) # ArrowTensorType uses list_
storage_type_v2 = pa.large_list(pa.int64()) # ArrowTensorTypeV2 uses large_list
# Track calls to verify each class has its own cache
with patch.object(
ArrowTensorType,
"_arrow_ext_deserialize_compute",
wraps=ArrowTensorType._arrow_ext_deserialize_compute,
) as mock_v1, patch.object(
ArrowTensorTypeV2,
"_arrow_ext_deserialize_compute",
wraps=ArrowTensorTypeV2._arrow_ext_deserialize_compute,
) as mock_v2:
# Deserialize using ArrowTensorType
deserialized_v1_1 = ArrowTensorType.__arrow_ext_deserialize__(
storage_type_v1, serialized_v1
)
assert mock_v1.call_count == 1
assert mock_v2.call_count == 0 # V2 cache not affected
# Deserialize using ArrowTensorTypeV2 with compatible parameters
# Note: We use the same serialized data but different storage type
deserialized_v2_1 = ArrowTensorTypeV2.__arrow_ext_deserialize__(
storage_type_v2, serialized_v2
)
assert mock_v1.call_count == 1 # V1 cache not affected
assert mock_v2.call_count == 1
# Verify they are different instances (different classes)
assert type(deserialized_v1_1) is not type(deserialized_v2_1)
assert isinstance(deserialized_v1_1, ArrowTensorType)
assert isinstance(deserialized_v2_1, ArrowTensorTypeV2)
assert not isinstance(deserialized_v1_1, ArrowTensorTypeV2)
assert not isinstance(deserialized_v2_1, ArrowTensorType)
# Verify they have the same shape and dtype (same logical content)
assert deserialized_v1_1.shape == deserialized_v2_1.shape
assert deserialized_v1_1.value_type == deserialized_v2_1.value_type
# But different extension names (different classes)
assert deserialized_v1_1.extension_name != deserialized_v2_1.extension_name
assert deserialized_v1_1.extension_name == "ray.data.arrow_tensor"
assert deserialized_v2_1.extension_name == "ray.data.arrow_tensor_v2"
# Verify each class uses its own cache (second calls should hit cache)
deserialized_v1_2 = ArrowTensorType.__arrow_ext_deserialize__(
storage_type_v1, serialized_v1
)
deserialized_v2_2 = ArrowTensorTypeV2.__arrow_ext_deserialize__(
storage_type_v2, serialized_v2
)
# Both should use cache (no additional compute calls)
assert mock_v1.call_count == 1 # Cache hit for V1
assert mock_v2.call_count == 1 # Cache hit for V2
# Verify cache returns same instances for same class
assert deserialized_v1_1 is deserialized_v1_2 # Same instance from V1 cache
assert deserialized_v2_1 is deserialized_v2_2 # Same instance from V2 cache
# But instances from different classes are different
assert deserialized_v1_1 is not deserialized_v2_1
assert deserialized_v1_2 is not deserialized_v2_2
def test_arrow_extension_serialize_deserialize_cache_thread_safety():
"""Test that ArrowExtensionSerializeDeserializeCache is thread-safe."""
tensor_type = ArrowTensorType(shape=(2, 3), dtype=pa.int64())
storage_type = pa.list_(pa.int64())
serialized = tensor_type.__arrow_ext_serialize__()
results = []
errors = []
def deserialize_worker():
try:
result = ArrowTensorType.__arrow_ext_deserialize__(storage_type, serialized)
results.append(result)
except Exception as e:
errors.append(e)
# Create multiple threads that deserialize simultaneously
threads = [threading.Thread(target=deserialize_worker) for _ in range(10)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
# Should have no errors
assert len(errors) == 0, f"Errors occurred: {errors}"
# All results should be equal (same deserialized type)
assert len(results) == 10
for result in results[1:]:
assert result.shape == results[0].shape
assert result.value_type == results[0].value_type
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-x", __file__]))