Files
2026-07-13 13:17:40 +08:00

294 lines
10 KiB
Python

"""Tests for cuDF batch format support in Ray Data.
These tests require cuDF to be installed and run on GPU CI. Use pytest.importorskip
so the file is skipped when cudf is missing (e.g. local CPU runs).
Uses cudf.testing.assert_eq for comparisons (see cuDF developer guide:
https://docs.rapids.ai/api/cudf/latest/developer_guide/testing/).
"""
import pandas as pd
import pyarrow as pa
import pytest
import ray
from ray.data._internal.block_batching.block_batching import batch_blocks
from ray.data.expressions import col
from ray.data.tests.conftest import * # noqa
cudf = pytest.importorskip("cudf")
def block_generator(num_rows: int, num_blocks: int):
"""Yield Arrow blocks for testing batch_blocks."""
for i in range(num_blocks):
yield pa.table({"foo": list(range(i * num_rows, (i + 1) * num_rows))})
def _make_dataset(data_source: str, shutdown_only, **kwargs):
"""Create dataset from data_source type."""
if data_source == "range":
return ray.data.range(
kwargs.get("n", 100), override_num_blocks=kwargs.get("blocks", 2)
)
elif data_source == "range_tensor":
return ray.data.range_tensor(
kwargs.get("n", 100), override_num_blocks=kwargs.get("blocks", 2)
)
elif data_source == "from_pandas":
df = kwargs.get(
"df",
pd.DataFrame({"foo": ["a", "b"], "bar": [0, 1]}),
)
return ray.data.from_pandas(df)
else:
raise ValueError(f"Unknown data_source: {data_source}")
# TODO(elliot-barn): "range_tensor" is disabled because cudf does not support Ray's
# TensorDtype (TypeError: Unrecognized dtype: TensorDtype). This was not caught before
# because cudf was not installed in the docgpu CI image — pytest.importorskip skipped
# the entire file. Now that the depset installs cudf-cu12, these tests run for real.
@pytest.mark.parametrize(
"data_source",
["range", "from_pandas"],
ids=["range", "from_pandas"],
)
class TestCudfIterBatches:
"""Tests for iter_batches with batch_format='cudf'."""
def test_iter_batches_returns_cudf(self, shutdown_only, data_source):
ds = _make_dataset(data_source, shutdown_only)
batch = next(iter(ds.iter_batches(batch_format="cudf")))
assert isinstance(batch, cudf.DataFrame)
assert len(batch) > 0
def test_iter_batches_columns(self, shutdown_only, data_source):
ds = _make_dataset(data_source, shutdown_only)
batch = next(iter(ds.iter_batches(batch_format="cudf")))
if data_source == "range":
assert list(batch.columns) == ["id"]
elif data_source == "range_tensor":
assert "data" in batch.columns
else:
assert list(batch.columns) == ["foo", "bar"]
cudf.testing.assert_eq(
batch, cudf.DataFrame({"foo": ["a", "b"], "bar": [0, 1]})
)
# TODO(elliot-barn): "range_tensor" is disabled — see comment on TestCudfIterBatches.
@pytest.mark.parametrize(
"data_source",
["range"],
ids=["range"],
)
class TestCudfTakeBatch:
"""Tests for take_batch with batch_format='cudf'."""
def test_take_batch_returns_cudf(self, ray_start_regular_shared, data_source):
ds = _make_dataset(data_source, None, n=10, blocks=2)
batch = ds.take_batch(3, batch_format="cudf")
assert isinstance(batch, cudf.DataFrame)
def test_take_batch_data(self, ray_start_regular_shared, data_source):
ds = _make_dataset(data_source, None, n=10, blocks=2)
batch = ds.take_batch(3, batch_format="cudf")
if data_source == "range":
cudf.testing.assert_eq(batch["id"], cudf.Series([0, 1, 2], name="id"))
else:
# Tensor columns are stored as list-type in cudf; compare via Arrow
assert batch["data"].to_arrow().to_pylist() == [[0], [1], [2]]
class TestCudfBatchBlocks:
"""Tests for batch_blocks with batch_format='cudf'."""
def test_batch_blocks_cudf(self):
blocks = block_generator(num_rows=3, num_blocks=2)
batches = list(batch_blocks(blocks, batch_format="cudf"))
assert len(batches) == 2
assert isinstance(batches[0], cudf.DataFrame)
assert isinstance(batches[1], cudf.DataFrame)
cudf.testing.assert_eq(batches[0], cudf.DataFrame({"foo": [0, 1, 2]}))
cudf.testing.assert_eq(batches[1], cudf.DataFrame({"foo": [3, 4, 5]}))
@pytest.mark.parametrize(
"batch_format",
["cudf", "pandas", "pyarrow"],
ids=["cudf", "pandas", "pyarrow"],
)
class TestCudfMapBatches:
"""Tests for map_batches with various batch formats (cuDF in/out)."""
def test_map_batches_cudf_receive_and_return(
self, ray_start_regular_shared, batch_format
):
"""UDF receives batches in requested format; test cudf round-trip."""
ds = ray.data.range(10, override_num_blocks=2)
def add_one(batch):
if batch_format == "cudf":
assert isinstance(batch, cudf.DataFrame)
batch = batch.copy()
batch["id"] = batch["id"] + 1
return batch
# For pandas/pyarrow input, convert to cudf, transform, return cudf
cudf_batch = (
cudf.from_pandas(batch)
if batch_format == "pandas"
else cudf.DataFrame.from_arrow(batch)
)
cudf_batch["id"] = cudf_batch["id"] + 1
return cudf_batch
result = ds.map_batches(
add_one,
batch_format=batch_format,
batch_size=10,
num_gpus=0.001,
).take()
assert result == [{"id": i} for i in range(1, 11)]
def test_map_batches_udf_returns_cudf(self, ray_start_regular_shared, batch_format):
"""UDF returns cudf.DataFrame regardless of input format (batch_to_block)."""
if batch_format == "cudf":
pytest.skip("Already testing cudf in/out above")
ds = ray.data.range(5, override_num_blocks=1)
def to_cudf_and_double(batch):
cudf_batch = (
cudf.from_pandas(batch)
if batch_format == "pandas"
else cudf.DataFrame.from_arrow(batch)
)
cudf_batch["id"] = cudf_batch["id"] * 2
return cudf_batch
result = ds.map_batches(
to_cudf_and_double,
batch_format=batch_format,
batch_size=5,
num_gpus=0.001,
).take()
assert result == [{"id": 0}, {"id": 2}, {"id": 4}, {"id": 6}, {"id": 8}]
@pytest.mark.parametrize(
"predicate_expr, test_data, expected_ids",
[
(col("id") > 5, None, list(range(6, 10))),
(col("id") >= 3, None, list(range(3, 10))),
(col("id") < 3, None, [0, 1, 2]),
(col("id") <= 2, None, [0, 1, 2]),
(col("id") == 4, None, [4]),
(col("id") != 4, None, [0, 1, 2, 3, 5, 6, 7, 8, 9]),
((col("id") >= 2) & (col("id") < 6), None, [2, 3, 4, 5]),
((col("id") < 2) | (col("id") > 7), None, [0, 1, 8, 9]),
(
col("value").is_not_null(),
[{"value": None}, {"value": 1}, {"value": 2}],
[1, 2],
),
],
ids=[
"gt",
"gte",
"lt",
"lte",
"eq",
"neq",
"and",
"or",
"is_not_null",
],
)
class TestCudfFilterExpressions:
"""Tests for filter with expressions on cuDF blocks."""
def test_filter_expr_after_map_batches_cudf(
self, ray_start_regular_shared, predicate_expr, test_data, expected_ids
):
"""filter(expr=...) works on cuDF blocks from map_batches(batch_format='cudf')."""
if test_data is not None:
ds = ray.data.from_items(test_data)
ds = ds.map_batches(
lambda x: x,
batch_format="cudf",
batch_size=3,
num_gpus=0.001,
)
result = ds.filter(expr=predicate_expr).take()
result_ids = [r.get("value", r.get("id", r)) for r in result]
else:
ds = ray.data.range(10, override_num_blocks=2)
ds = ds.map_batches(
lambda x: x,
batch_format="cudf",
batch_size=10,
num_gpus=0.001,
)
result = ds.filter(expr=predicate_expr).take()
result_ids = [r["id"] for r in result]
assert result_ids == expected_ids
def test_map_batches_after_filter_expr(
self, ray_start_regular_shared, predicate_expr, test_data, expected_ids
):
"""map_batches(batch_format='cudf') after filter(expr=...) works."""
if test_data is not None:
ds = ray.data.from_items(test_data)
ds = ds.filter(expr=predicate_expr)
ds = ds.map_batches(
lambda x: x,
batch_format="cudf",
batch_size=3,
num_gpus=0.001,
)
result = ds.take()
result_ids = [r.get("value", r.get("id", r)) for r in result]
else:
ds = ray.data.range(10, override_num_blocks=2)
ds = ds.filter(expr=predicate_expr)
ds = ds.map_batches(
lambda x: x,
batch_format="cudf",
batch_size=10,
num_gpus=0.001,
)
result = ds.take()
result_ids = [r["id"] for r in result]
assert result_ids == expected_ids
class TestCudfAddColumn:
"""Tests for add_column with batch_format='cudf'."""
def test_add_column_cudf(self, ray_start_regular_shared):
"""add_column with batch_format='cudf' adds column to cudf batches."""
ds = ray.data.range(5).add_column(
"doubled",
lambda x: x["id"] * 2,
batch_format="cudf",
batch_size=5,
num_gpus=0.001,
)
result = ds.take()
assert result == [
{"id": 0, "doubled": 0},
{"id": 1, "doubled": 2},
{"id": 2, "doubled": 4},
{"id": 3, "doubled": 6},
{"id": 4, "doubled": 8},
]
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))