chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from decimal import Decimal
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pyarrow as pa
|
||||
import pytest
|
||||
from pyarrow import ArrowInvalid
|
||||
|
||||
import ray
|
||||
from ray._common.test_utils import run_string_as_driver
|
||||
from ray.data._internal.arrow_block import (
|
||||
ArrowBlockAccessor,
|
||||
ArrowBlockBuilder,
|
||||
)
|
||||
from ray.data._internal.arrow_ops.transform_pyarrow import combine_chunked_array
|
||||
from ray.data._internal.util import GiB, MiB
|
||||
from ray.data.block import BlockAccessor
|
||||
from ray.data.context import DataContext
|
||||
|
||||
|
||||
def test_combine_chunked_fixed_width_array_large():
|
||||
"""Verifies `combine_chunked_array` on fixed-width arrays > 2 GiB, produces
|
||||
single contiguous PA Array"""
|
||||
|
||||
# 144 MiB
|
||||
ones_1gb = np.ones(shape=(550, 128, 128, 4), dtype=np.int32()).ravel()
|
||||
|
||||
# Total ~2.15 GiB
|
||||
input_ = pa.chunked_array(
|
||||
[
|
||||
pa.array(ones_1gb),
|
||||
]
|
||||
* 16
|
||||
)
|
||||
|
||||
assert round(input_.nbytes / GiB, 2) == 2.15
|
||||
|
||||
result = combine_chunked_array(input_)
|
||||
|
||||
assert isinstance(result, pa.Int32Array)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"array_type,input_factory",
|
||||
[
|
||||
(
|
||||
pa.binary(),
|
||||
lambda num_bytes: np.arange(num_bytes, dtype=np.uint8).tobytes(),
|
||||
),
|
||||
(
|
||||
pa.string(),
|
||||
lambda num_bytes: base64.encodebytes(
|
||||
np.arange(num_bytes, dtype=np.int8).tobytes()
|
||||
).decode("ascii"),
|
||||
),
|
||||
(pa.list_(pa.uint8()), lambda num_bytes: np.arange(num_bytes, dtype=np.uint8)),
|
||||
],
|
||||
)
|
||||
def test_combine_chunked_variable_width_array_large(array_type, input_factory):
|
||||
"""Verifies `combine_chunked_array` on variable-width arrays > 2 GiB,
|
||||
safely produces new ChunkedArray with provided chunks recombined into
|
||||
larger ones up to INT32_MAX in size"""
|
||||
|
||||
one_half_gb_arr = pa.array([input_factory(GiB / 2)], type=array_type)
|
||||
chunked_arr = pa.chunked_array(
|
||||
[one_half_gb_arr, one_half_gb_arr, one_half_gb_arr, one_half_gb_arr]
|
||||
)
|
||||
|
||||
# 2 GiB + offsets (4 x int32)
|
||||
num_bytes = chunked_arr.nbytes
|
||||
expected_num_bytes = 4 * one_half_gb_arr.nbytes
|
||||
|
||||
num_chunks = len(chunked_arr.chunks)
|
||||
assert num_chunks == 4
|
||||
assert num_bytes == expected_num_bytes
|
||||
|
||||
# Assert attempt to combine directly fails
|
||||
with pytest.raises(ArrowInvalid):
|
||||
chunked_arr.combine_chunks()
|
||||
|
||||
# Safe combination succeeds by avoiding overflowing combination
|
||||
combined = combine_chunked_array(chunked_arr)
|
||||
|
||||
num_bytes = combined.nbytes
|
||||
|
||||
num_chunks = len(combined.chunks)
|
||||
assert num_chunks == 2
|
||||
assert num_bytes == expected_num_bytes
|
||||
|
||||
|
||||
def test_add_rows_with_different_column_names(ray_start_regular_shared):
|
||||
builder = ArrowBlockBuilder()
|
||||
|
||||
builder.add({"col1": "spam"})
|
||||
builder.add({"col2": "foo"})
|
||||
block = builder.build()
|
||||
|
||||
expected_table = pa.Table.from_pydict(
|
||||
{"col1": ["spam", None], "col2": [None, "foo"]}
|
||||
)
|
||||
assert block.equals(expected_table)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def binary_dataset_single_file_gt_2gb():
|
||||
total_size = int(2.1 * GiB)
|
||||
chunk_size = 256 * MiB
|
||||
num_chunks = total_size // chunk_size
|
||||
remainder = total_size % chunk_size
|
||||
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
dataset_path = f"{tmp_dir}/binary_dataset_gt_2gb_single_file"
|
||||
|
||||
# Create directory
|
||||
os.mkdir(dataset_path)
|
||||
|
||||
with open(f"{dataset_path}/chunk.bin", "wb") as f:
|
||||
for i in range(num_chunks):
|
||||
f.write(b"a" * chunk_size)
|
||||
|
||||
print(f">>> Written chunk #{i}")
|
||||
|
||||
if remainder:
|
||||
f.write(b"a" * remainder)
|
||||
|
||||
print(f">>> Wrote chunked dataset at: {dataset_path}")
|
||||
|
||||
yield dataset_path, total_size
|
||||
|
||||
print(f">>> Cleaning up dataset: {dataset_path}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"col_name",
|
||||
[
|
||||
"bytes",
|
||||
# TODO fix numpy conversion
|
||||
# "text",
|
||||
],
|
||||
)
|
||||
def test_single_row_gt_2gb(
|
||||
ray_start_regular_shared,
|
||||
restore_data_context,
|
||||
binary_dataset_single_file_gt_2gb,
|
||||
col_name,
|
||||
):
|
||||
# Disable (automatic) fallback to `ArrowPythonObjectType` extension type
|
||||
DataContext.get_current().enable_fallback_to_arrow_object_ext_type = False
|
||||
|
||||
dataset_path, target_binary_size = binary_dataset_single_file_gt_2gb
|
||||
|
||||
def _id(row):
|
||||
bs = row[col_name]
|
||||
assert round(len(bs) / GiB, 1) == round(target_binary_size / GiB, 1)
|
||||
return row
|
||||
|
||||
if col_name == "text":
|
||||
ds = ray.data.read_text(dataset_path)
|
||||
elif col_name == "bytes":
|
||||
ds = ray.data.read_binary_files(dataset_path)
|
||||
|
||||
total = ds.map(_id).count()
|
||||
|
||||
assert total == 1
|
||||
|
||||
|
||||
def test_random_shuffle(ray_start_regular_shared):
|
||||
TOTAL_ROWS = 10000
|
||||
table = pa.table({"id": pa.array(range(TOTAL_ROWS))})
|
||||
block_accessor = ArrowBlockAccessor(table)
|
||||
|
||||
# Perform the random shuffle
|
||||
shuffled_table = block_accessor.random_shuffle(random_seed=None)
|
||||
assert shuffled_table.num_rows == TOTAL_ROWS
|
||||
|
||||
# Access the shuffled data
|
||||
block_accessor = ArrowBlockAccessor(shuffled_table)
|
||||
shuffled_data = block_accessor.to_pandas()["id"].tolist()
|
||||
original_data = list(range(TOTAL_ROWS))
|
||||
|
||||
# Ensure the shuffled data is not identical to the original
|
||||
assert (
|
||||
shuffled_data != original_data
|
||||
), "Shuffling should result in a different order"
|
||||
|
||||
# Ensure the entire set of original values is still in the shuffled dataset
|
||||
assert (
|
||||
sorted(shuffled_data) == original_data
|
||||
), "The shuffled data should contain all the original values"
|
||||
|
||||
|
||||
def test_register_arrow_types(ray_start_regular_shared, tmp_path):
|
||||
# Test that our custom arrow extension types are registered on initialization.
|
||||
ds = ray.data.from_items(np.zeros((8, 8, 8), dtype=np.int64))
|
||||
tmp_file = f"{tmp_path}/test.parquet"
|
||||
ds.write_parquet(tmp_file)
|
||||
|
||||
ds = ray.data.read_parquet(tmp_file)
|
||||
schema = "Column Type\n------ ----\nitem ArrowTensorTypeV2(shape=(8, 8), dtype=int64)"
|
||||
assert str(ds.schema()) == schema
|
||||
|
||||
# Also run in driver script to eliminate existing imports.
|
||||
driver_script = """import ray
|
||||
ds = ray.data.read_parquet("{0}")
|
||||
schema = ds.schema()
|
||||
assert str(schema) == \"\"\"{1}\"\"\"
|
||||
""".format(
|
||||
tmp_file, schema
|
||||
)
|
||||
run_string_as_driver(driver_script)
|
||||
|
||||
|
||||
def test_dict_doesnt_fallback_to_pandas_block(ray_start_regular_shared):
|
||||
# If the UDF returns a column with dict, previously, we would
|
||||
# fall back to pandas, because we couldn't convert it to
|
||||
# an Arrow block. This test checks that the block
|
||||
# construction now correctly goes to Arrow.
|
||||
def fn(batch):
|
||||
batch["data_dict"] = [{"data": 0} for _ in range(len(batch["id"]))]
|
||||
batch["data_objects"] = [
|
||||
types.SimpleNamespace(a=1, b="test") for _ in range(len(batch["id"]))
|
||||
]
|
||||
return batch
|
||||
|
||||
ds = ray.data.range(10).map_batches(fn)
|
||||
ds = ds.materialize()
|
||||
block = ray.get(ds.get_internal_block_refs()[0])
|
||||
assert isinstance(block, pa.Table), type(block)
|
||||
df_from_block = block.to_pandas()
|
||||
assert df_from_block["data_dict"].iloc[0] == {"data": 0}
|
||||
assert df_from_block["data_objects"].iloc[0] == types.SimpleNamespace(a=1, b="test")
|
||||
|
||||
def fn2(batch):
|
||||
batch["data_none"] = [None for _ in range(len(batch["id"]))]
|
||||
return batch
|
||||
|
||||
ds2 = ray.data.range(10).map_batches(fn2)
|
||||
ds2 = ds2.materialize()
|
||||
block = ray.get(ds2.get_internal_block_refs()[0])
|
||||
assert isinstance(block, pa.Table), type(block)
|
||||
df_from_block = block.to_pandas()
|
||||
assert df_from_block["data_none"].iloc[0] is None
|
||||
|
||||
|
||||
# Test for https://github.com/ray-project/ray/issues/49338.
|
||||
def test_build_block_with_null_column(ray_start_regular_shared, restore_data_context):
|
||||
ctx = DataContext.get_current()
|
||||
ctx.execution_options.preserve_order = True
|
||||
|
||||
# The blocks need to contain a tensor column to trigger the bug.
|
||||
block1 = BlockAccessor.batch_to_block(
|
||||
{"string": [None], "array": np.zeros((1, 2, 2))}
|
||||
)
|
||||
block2 = BlockAccessor.batch_to_block(
|
||||
{"string": ["spam"], "array": np.zeros((1, 2, 2))}
|
||||
)
|
||||
|
||||
builder = ArrowBlockBuilder()
|
||||
builder.add_block(block1)
|
||||
builder.add_block(block2)
|
||||
block = builder.build()
|
||||
|
||||
rows = list(BlockAccessor.for_block(block).iter_rows(True))
|
||||
assert len(rows) == 2
|
||||
|
||||
assert rows[0]["string"] is None
|
||||
assert rows[1]["string"] == "spam"
|
||||
assert np.array_equal(rows[0]["array"], np.zeros((2, 2)))
|
||||
assert np.array_equal(rows[1]["array"], np.zeros((2, 2)))
|
||||
|
||||
|
||||
def test_arrow_block_timestamp_ns(ray_start_regular_shared):
|
||||
# Input data with nanosecond precision timestamps
|
||||
data_rows = [
|
||||
{"col1": 1, "col2": pd.Timestamp("2023-01-01T00:00:00.123456789")},
|
||||
{"col1": 2, "col2": pd.Timestamp("2023-01-01T01:15:30.987654321")},
|
||||
{"col1": 3, "col2": pd.Timestamp("2023-01-01T02:30:15.111111111")},
|
||||
{"col1": 4, "col2": pd.Timestamp("2023-01-01T03:45:45.222222222")},
|
||||
{"col1": 5, "col2": pd.Timestamp("2023-01-01T05:00:00.333333333")},
|
||||
]
|
||||
|
||||
# Initialize ArrowBlockBuilder
|
||||
arrow_builder = ArrowBlockBuilder()
|
||||
for row in data_rows:
|
||||
arrow_builder.add(row)
|
||||
arrow_block = arrow_builder.build()
|
||||
|
||||
assert arrow_block.schema.field("col2").type == pa.timestamp("ns")
|
||||
for i, row in enumerate(data_rows):
|
||||
result_timestamp = arrow_block["col2"][i].as_py()
|
||||
# Convert both values to pandas Timestamp to preserve nanosecond precision for
|
||||
# comparison.
|
||||
assert pd.Timestamp(row["col2"]) == pd.Timestamp(
|
||||
result_timestamp
|
||||
), f"Timestamp mismatch at row {i} in ArrowBlockBuilder output"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_array,transform,expected_type,expected_values",
|
||||
[
|
||||
(
|
||||
pa.array([None, None], type=pa.string()),
|
||||
None,
|
||||
pa.string(),
|
||||
[None, None],
|
||||
),
|
||||
(
|
||||
pa.array([None, None], type=pa.list_(pa.string())),
|
||||
None,
|
||||
pa.list_(pa.string()),
|
||||
[None, None],
|
||||
),
|
||||
(
|
||||
pa.array([None, None], type=pa.decimal128(10, 2)),
|
||||
lambda df: df.fillna({"x": 0}),
|
||||
pa.decimal128(10, 2),
|
||||
[Decimal("0.00"), Decimal("0.00")],
|
||||
),
|
||||
(
|
||||
pa.array([["a", "b"], None], type=pa.list_(pa.string())),
|
||||
None,
|
||||
pa.list_(pa.string()),
|
||||
[["a", "b"], None],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_arrow_block_to_pandas_preserves_arrow_types_through_roundtrip(
|
||||
input_array, transform, expected_type, expected_values
|
||||
):
|
||||
table = pa.table({"x": input_array})
|
||||
|
||||
df = ArrowBlockAccessor(table).to_pandas()
|
||||
assert isinstance(df.dtypes["x"], pd.ArrowDtype)
|
||||
assert df.dtypes["x"].pyarrow_dtype == expected_type
|
||||
|
||||
if transform is not None:
|
||||
df = transform(df)
|
||||
|
||||
roundtripped = BlockAccessor.for_block(df).to_arrow()
|
||||
|
||||
assert roundtripped.schema.field("x").type == expected_type
|
||||
assert roundtripped.to_pydict() == {"x": expected_values}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
Reference in New Issue
Block a user