1472 lines
53 KiB
Python
1472 lines
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__]))
|