Files
ray-project--ray/python/ray/data/tests/test_arrow_serialization.py
2026-07-13 13:17:40 +08:00

515 lines
16 KiB
Python

import logging
import os
import types
import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq
import pytest
from packaging.version import parse as parse_version
from pytest_lazy_fixtures import lf as lazy_fixture
import ray
import ray.cloudpickle as pickle
import ray.data
import ray.train
from ray.data._internal.utils.arrow_utils import get_pyarrow_version
from ray.data.extensions.object_extension import (
ArrowPythonObjectArray,
)
from ray.data.extensions.tensor_extension import (
ArrowTensorArray,
ArrowVariableShapedTensorArray,
)
from ray.tests.conftest import * # noqa
@pytest.fixture
def null_array():
return pa.array([])
@pytest.fixture
def int_array():
return pa.array(list(range(1000)))
@pytest.fixture
def int_array_with_nulls():
return pa.array((list(range(9)) + [None]) * 100)
@pytest.fixture
def float_array():
return pa.array([float(i) for i in range(1000)])
@pytest.fixture
def boolean_array():
return pa.array([True, False] * 500)
@pytest.fixture
def string_array():
return pa.array(["foo", "bar", "bz", None, "quux"] * 200)
@pytest.fixture
def large_string_array():
return pa.array(["foo", "bar", "bz", None, "quux"] * 200, type=pa.large_string())
@pytest.fixture
def binary_array():
return pa.array([b"foo", b"bar", b"bz", None, b"quux"] * 200)
@pytest.fixture
def fixed_size_binary_array():
return pa.array([b"foo", b"bar", b"baz", None, b"qux"] * 200, type=pa.binary(3))
@pytest.fixture
def large_binary_array():
return pa.array(
[b"foo", b"bar", b"bz", None, b"quux"] * 200, type=pa.large_binary()
)
@pytest.fixture
def list_array():
return pa.array(([None] + [list(range(9)) + [None]] * 9) * 100)
@pytest.fixture
def large_list_array():
# Large list array with nulls
return pa.array(
([None] + [list(range(9)) + [None]] * 9) * 100,
type=pa.large_list(pa.int64()),
)
@pytest.fixture
def fixed_size_list_array():
# Fixed size list array
return pa.FixedSizeListArray.from_arrays(
pa.array((list(range(9)) + [None]) * 1000), 10
)
@pytest.fixture
def map_array():
return pa.array(
[list(zip("abcdefghij", range(10))) for _ in range(1000)],
type=pa.map_(pa.string(), pa.int64()),
)
@pytest.fixture
def struct_array():
# Struct array
return pa.array({"a": i} for i in range(1000))
@pytest.fixture
def sparse_union_array():
return pa.UnionArray.from_sparse(
pa.array([0, 1] * 500, type=pa.int8()),
[pa.array(list(range(1000))), pa.array([True, False] * 500)],
)
@pytest.fixture
def dense_union_array():
return pa.UnionArray.from_dense(
pa.array([0, 1] * 500, type=pa.int8()),
pa.array(
[i if i % 2 == 0 else (i % 3) % 2 for i in range(1000)], type=pa.int32()
),
[pa.array(list(range(1000))), pa.array([True, False])],
)
@pytest.fixture
def dictionary_array():
return pa.DictionaryArray.from_arrays(
pa.array((list(range(9)) + [None]) * 100),
pa.array(["a", "b", "c", "d", "e", "f", "g", "h", "i"]),
)
@pytest.fixture
def tensor_array():
return ArrowTensorArray.from_numpy(np.arange(1000 * 4 * 4).reshape((1000, 4, 4)))
@pytest.fixture
def boolean_tensor_array():
return ArrowTensorArray.from_numpy(
np.array(
[True, False, False, True, False, False, True, True] * 2 * 1000
).reshape((1000, 4, 4))
)
@pytest.fixture
def variable_shaped_tensor_array():
return ArrowVariableShapedTensorArray.from_numpy(
np.array(
[
np.arange(4).reshape((2, 2)),
np.arange(4, 13).reshape((3, 3)),
]
* 500,
dtype=object,
),
)
@pytest.fixture
def boolean_variable_shaped_tensor_array():
return ArrowVariableShapedTensorArray.from_numpy(
np.array(
[
np.array([[True, False], [False, True]]),
np.array(
[
[False, True, False],
[True, True, False],
[False, False, False],
],
),
]
* 500,
dtype=object,
)
)
@pytest.fixture
def list_of_struct_array():
return pa.array([{"a": i}, {"a": -i}] for i in range(1000))
@pytest.fixture
def list_of_empty_struct_array():
return pa.array([{}, {}] for i in range(1000))
@pytest.fixture
def complex_nested_array():
return pa.UnionArray.from_sparse(
pa.array([0, 1] * 500, type=pa.int8()),
[
pa.array(
[
{
"a": i % 2 == 0,
"b": i,
"c": "bar",
}
for i in range(1000)
]
),
pa.array(
[list(zip("abcdefghij", range(10))) for _ in range(1000)],
type=pa.map_(pa.string(), pa.int64()),
),
],
)
@pytest.fixture
def pickled_objects_array():
elements = ["test", 20, False, {"some": "value"}, None, np.zeros((10, 10))]
elements *= 1 + 1000 // len(elements)
elements = elements[:1000]
arr = np.array(elements, dtype=object)
return ArrowPythonObjectArray.from_objects(arr)
pytest_custom_serialization_arrays = [
# Null array
(lazy_fixture("null_array"), 1.0),
# Int array
(lazy_fixture("int_array"), 0.1),
# Array with nulls
(lazy_fixture("int_array_with_nulls"), 0.1),
# Float array
(lazy_fixture("float_array"), 0.1),
# Boolean array
# Due to bit-packing, most of the pickle bytes are metadata.
(lazy_fixture("boolean_array"), 0.8),
# String array
(lazy_fixture("string_array"), 0.1),
# Large string array
(lazy_fixture("large_string_array"), 0.1),
# Binary array
(lazy_fixture("binary_array"), 0.1),
# Fixed size binary array
(lazy_fixture("fixed_size_binary_array"), 0.1),
# Large binary array
(lazy_fixture("large_binary_array"), 0.1),
# List array with nulls
(lazy_fixture("list_array"), 0.1),
# Large list array with nulls
(lazy_fixture("large_list_array"), 0.1),
# Fixed size list array
(lazy_fixture("fixed_size_list_array"), 0.1),
# Map array
(lazy_fixture("map_array"), 0.1),
# Struct array
(lazy_fixture("struct_array"), 0.1),
# Union array (sparse)
(lazy_fixture("sparse_union_array"), 0.1),
# Union array (dense)
(lazy_fixture("dense_union_array"), 0.1),
# Dictionary array
(lazy_fixture("dictionary_array"), 0.1),
# Tensor extension array
(lazy_fixture("tensor_array"), 0.1),
# Boolean tensor extension array
(lazy_fixture("boolean_tensor_array"), 0.25),
# Variable-shaped tensor extension array
(lazy_fixture("variable_shaped_tensor_array"), 0.1),
# Boolean variable-shaped tensor extension array
(lazy_fixture("boolean_variable_shaped_tensor_array"), 0.25),
# List of struct array
(lazy_fixture("list_of_struct_array"), 0.1),
# List of empty struct array
(lazy_fixture("list_of_empty_struct_array"), 0.1),
# Complex nested array
(lazy_fixture("complex_nested_array"), 0.1),
# Array of pickled objects
(lazy_fixture("pickled_objects_array"), 0.1),
]
@pytest.mark.parametrize("data,cap_mult", pytest_custom_serialization_arrays)
def test_custom_arrow_data_serializer(ray_start_regular_shared, data, cap_mult):
if len(data) == 0:
data = pa.table({"a": []})
else:
data = pa.Table.from_arrays(
[data, data, pa.array(range(1000), type=pa.int32())],
schema=pa.schema(
[
pa.field("arr1", data.type),
pa.field("arr2", data.type),
pa.field("arr3", pa.int32()),
],
metadata={b"foo": b"bar"},
),
)
ray._private.worker.global_worker.get_serialization_context()
data.validate()
pyarrow_version = get_pyarrow_version()
if pyarrow_version >= parse_version("7.0.0"):
# get_total_buffer_size API was added in Arrow 7.0.0.
buf_size = data.get_total_buffer_size()
# Create a zero-copy slice view of data.
view = data.slice(10, 10)
s_arr = pickle.dumps(data)
s_view = pickle.dumps(view)
post_slice = pickle.loads(s_view)
post_slice.validate()
# Check for round-trip equality.
assert view.equals(post_slice), post_slice
# Check that the slice view was truncated upon serialization.
assert len(s_view) <= cap_mult * len(s_arr)
for column, pre_column in zip(post_slice.columns, view.columns):
# Check that offset was reset on slice.
if column.num_chunks > 0:
assert column.chunk(0).offset == 0
# Check that null count was either properly cached or recomputed.
assert column.null_count == pre_column.null_count
if pyarrow_version >= parse_version("7.0.0"):
# Check that slice buffer only contains slice data.
slice_buf_size = post_slice.get_total_buffer_size()
if buf_size > 0:
assert buf_size / slice_buf_size - len(data) / len(post_slice) < 100
def test_custom_arrow_data_serializer_fallback(
ray_start_regular_shared, propagate_logs, caplog
):
# Reset serialization fallback set so warning is logged.
import ray._private.arrow_serialization as arrow_ser_module
arrow_ser_module._serialization_fallback_set = set()
data = pa.table(
{
"a": pa.UnionArray.from_dense(
pa.array([0, 1] * 500, type=pa.int8()),
pa.array(
[i if i % 2 == 0 else (i % 3) % 2 for i in range(1000)],
type=pa.int32(),
),
[pa.array(list(range(1000))), pa.array([True, False])],
)
}
)
cap_mult = 0.1
ray._private.worker.global_worker.get_serialization_context()
data.validate()
pyarrow_version = get_pyarrow_version()
if pyarrow_version >= parse_version("7.0.0"):
# get_total_buffer_size API was added in Arrow 7.0.0.
buf_size = data.get_total_buffer_size()
# Create a zero-copy slice view of data.
view = data.slice(10, 10)
# Confirm that (1) fallback works, and (2) warning is logged.
with caplog.at_level(
logging.WARNING,
logger="ray.data._internal.arrow_serialization",
):
s_arr = pickle.dumps(data)
assert "Failed to complete optimized serialization" in caplog.text
caplog.clear()
# Confirm that we only warn once per process.
with caplog.at_level(
logging.WARNING,
logger="ray.data._internal.arrow_serialization",
):
s_view = pickle.dumps(view)
assert "Failed to complete optimized serialization" not in caplog.text
post_slice = pickle.loads(s_view)
post_slice.validate()
# Check for round-trip equality.
assert view.equals(post_slice), post_slice
# Check that the slice view was truncated upon serialization.
assert len(s_view) <= cap_mult * len(s_arr)
for column, pre_column in zip(post_slice.columns, view.columns):
# Check that offset was reset on slice.
if column.num_chunks > 0:
assert column.chunk(0).offset == 0
# Check that null count was either properly cached or recomputed.
assert column.null_count == pre_column.null_count
if pyarrow_version >= parse_version("7.0.0"):
# Check that slice buffer only contains slice data.
slice_buf_size = post_slice.get_total_buffer_size()
if buf_size > 0:
assert buf_size / slice_buf_size - len(data) / len(post_slice) < 100
def test_arrow_scalar_conversion(ray_start_regular_shared):
ds = ray.data.from_items([1])
def fn(batch: list):
return {"id": np.array([1])}
ds = ds.map_batches(fn)
res = ds.take()
assert res == [{"id": 1}], res
def test_arrow_object_and_array_support(ray_start_regular_shared):
obj = types.SimpleNamespace(some_attribute="test")
def f(batch):
batch_size = len(batch["id"])
return {
"array": np.zeros((batch_size, 32, 32, 3)),
"unsupported": [obj] * batch_size,
}
res = ray.data.range(5).map_batches(f, batch_size=None).take(1)
assert res[0]["array"].shape == (32, 32, 3)
assert np.all(res[0]["array"] == 0)
assert res[0]["unsupported"] == obj
def test_custom_arrow_data_serializer_parquet_roundtrip(
ray_start_regular_shared, tmp_path
):
ray._private.worker.global_worker.get_serialization_context()
t = pa.table({"a": list(range(10000000))})
pq.write_table(t, f"{tmp_path}/test.parquet")
t2 = pq.read_table(f"{tmp_path}/test.parquet")
s_t = pickle.dumps(t)
s_t2 = pickle.dumps(t2)
# Check that the post-Parquet slice view chunks don't cause a serialization blow-up.
assert len(s_t2) < 1.1 * len(s_t)
# Check for round-trip equality.
assert t2.equals(pickle.loads(s_t2))
def test_arrow_schema_ipc_serialization(ray_start_regular_shared):
"""Test that Arrow Schema uses IPC serialization for performance."""
from ray._private.arrow_serialization import (
_arrow_schema_reduce,
_restore_schema_from_ipc,
)
# Verify the reducer is registered
ray._private.worker.global_worker.get_serialization_context()
assert pa.Schema in pickle.CloudPickler.dispatch
assert pickle.CloudPickler.dispatch[pa.Schema] == _arrow_schema_reduce
# Create a complex schema with various types
schema = pa.schema(
[
pa.field("id", pa.int64()),
pa.field("name", pa.string()),
pa.field("timestamp", pa.timestamp("us", tz="UTC")),
pa.field("tags", pa.list_(pa.string())),
pa.field("metadata", pa.map_(pa.string(), pa.string())),
pa.field(
"nested",
pa.struct(
[
pa.field("x", pa.float64()),
pa.field("y", pa.float64()),
]
),
),
pa.field("category", pa.dictionary(pa.int8(), pa.string())),
pa.field("decimal_val", pa.decimal128(18, 6)),
],
metadata={b"foo": b"bar"},
)
# Test roundtrip serialization
serialized = pickle.dumps(schema)
deserialized = pickle.loads(serialized)
assert schema.equals(deserialized)
assert schema.metadata == deserialized.metadata
# Verify the reducer uses IPC format (check via direct call)
restore_func, (ipc_bytes,) = _arrow_schema_reduce(schema)
assert restore_func == _restore_schema_from_ipc
# IPC bytes should match what schema.serialize() produces
assert ipc_bytes == schema.serialize().to_pybytes()
# Verify restore works
restored = restore_func(ipc_bytes)
assert schema.equals(restored)
def test_custom_arrow_data_serializer_disable(shutdown_only):
ray.shutdown()
ray.worker._post_init_hooks = []
context = ray.worker.global_worker.get_serialization_context()
context._unregister_cloudpickle_reducer(pa.Table)
# Disable custom Arrow array serialization.
os.environ["RAY_DISABLE_CUSTOM_ARROW_ARRAY_SERIALIZATION"] = "1"
ray.init()
# Create a zero-copy slice view of table.
t = pa.table({"a": list(range(10000000))})
view = t.slice(10, 10)
s_t = pickle.dumps(t)
s_view = pickle.dumps(view)
# Check that the slice view contains the full buffer of the underlying array.
d_view = pickle.loads(s_view)
assert d_view["a"].chunk(0).buffers()[1].size == t["a"].chunk(0).buffers()[1].size
# Check that the serialized slice view is large
assert len(s_view) > 0.8 * len(s_t)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))