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

1013 lines
36 KiB
Python

"""Unit tests for GPU hash aggregation with ShuffleStrategy.GPU_SHUFFLE.
The planning tests do not require GPUs. Tests marked ``gpu`` exercise cuDF and
RAPIDS MPF paths on actual GPU hardware.
"""
from typing import Any, List, Optional, Tuple, cast
from unittest.mock import MagicMock, patch
import numpy as np
import pandas as pd
import pyarrow as pa
import pytest
import ray
import ray.data._internal.gpu_shuffle.hash_aggregate as hash_aggregate
from ray.actor import ActorClass, ActorHandle
from ray.data._internal.execution.interfaces import ExecutionResources, PhysicalOperator
from ray.data._internal.gpu_shuffle.hash_aggregate import (
GPUAggregateFn,
GPUAggregationPlan,
GPUHashAggregateActor,
GPUHashAggregateOperator,
build_gpu_aggregation_plan,
)
from ray.data._internal.logical.interfaces import LogicalOperator
from ray.data._internal.logical.operators import Aggregate
from ray.data._internal.planner.plan_all_to_all_op import plan_all_to_all_op
from ray.data.aggregate import AggregateFnV2, AsList, Count, Max, Mean, Min, Sum
from ray.data.block import BlockMetadata
from ray.data.context import DataContext, ShuffleStrategy
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_input_op_mock(num_blocks=None, size_bytes=None):
"""Return a minimal PhysicalOperator mock.
The mock is compatible with HashShufflingOperatorBase.
"""
logical_mock = MagicMock(LogicalOperator)
logical_mock.infer_metadata.return_value = BlockMetadata(
num_rows=None,
size_bytes=size_bytes,
exec_stats=None,
input_files=None,
)
logical_mock.estimated_num_outputs.return_value = num_blocks
op_mock = MagicMock(PhysicalOperator)
op_mock._output_dependencies = []
op_mock._logical_operators = [logical_mock]
op_mock.num_output_splits.return_value = 1
return op_mock
# ---------------------------------------------------------------------------
# GPU hash aggregate planning
# ---------------------------------------------------------------------------
class TestGPUHashAggregatePlanning:
def _make_aggregate_op(
self,
aggs,
key="user_id",
num_partitions=8,
input_schema=pa.schema([("user_id", pa.int64()), ("value", pa.int64())]),
):
input_op = MagicMock(LogicalOperator)
input_op.infer_schema.return_value = input_schema
return Aggregate(
key=key,
aggs=list(aggs),
input_dependencies=[input_op],
num_partitions=num_partitions,
)
def test_builtin_aggregation_plan_supported(self):
schema = pa.schema([("user_id", pa.int64()), ("value", pa.int64())])
plan = build_gpu_aggregation_plan(
("user_id",),
(
Count(),
Count(on="value", ignore_nulls=True),
Sum("value"),
Min("value"),
Max("value"),
Mean("value"),
),
input_schema=schema,
)
assert isinstance(plan, GPUAggregationPlan), plan
assert plan.shuffle_key_columns == ("user_id",)
assert plan.output_names == (
"count()",
"count(value)",
"sum(value)",
"min(value)",
"max(value)",
"mean(value)",
)
assert tuple(type(agg).__name__ for agg in plan._gpu_aggregates) == (
"GPUCount",
"GPUCount",
"GPUSum",
"GPUMin",
"GPUMax",
"GPUMean",
)
for gpu_agg in plan._gpu_aggregates:
assert not isinstance(gpu_agg, AggregateFnV2)
assert not hasattr(gpu_agg, "aggregate_block")
assert not hasattr(gpu_agg, "combine")
assert not hasattr(gpu_agg, "finalize")
assert "user_id" in plan.required_columns
assert "value" in plan.required_columns
def test_unsupported_aggregation_plan_rejected(self):
schema = pa.schema([("user_id", pa.int64())])
unsupported_result = build_gpu_aggregation_plan(
("user_id",), (AsList("value"),), input_schema=schema
)
assert unsupported_result == "AsList is not supported by GPU aggregation."
missing_schema_result = build_gpu_aggregation_plan(
("user_id",), (Sum("value"),)
)
assert missing_schema_result == (
"missing input schema for key column(s): user_id."
)
def test_custom_gpu_aggregation_plan_supported_without_aggregate_fn_v2(self):
class _CustomGPUAggregate(GPUAggregateFn):
def __init__(self) -> None:
super().__init__(
"custom(value)",
on="value",
ignore_nulls=True,
accumulators=("acc",),
)
def partial_aggregate(
self,
df: Any,
key_columns: Tuple[str, ...],
accumulator_columns: Tuple[str, ...],
*,
input_schema: Any = None,
) -> Any:
raise NotImplementedError
def final_aggregate(
self,
df: Any,
key_columns: Tuple[str, ...],
accumulator_columns: Tuple[str, ...],
output_name: str,
) -> Any:
raise NotImplementedError
gpu_agg = _CustomGPUAggregate()
plan = build_gpu_aggregation_plan(
("user_id",),
(gpu_agg,),
input_schema=pa.schema([("user_id", pa.int64()), ("value", pa.int64())]),
)
assert isinstance(plan, GPUAggregationPlan), plan
assert plan._gpu_aggregates == (gpu_agg,)
assert plan.output_names == ("custom(value)",)
assert plan.required_columns == ("user_id", "value")
assert not isinstance(gpu_agg, AggregateFnV2)
assert not hasattr(gpu_agg, "aggregate_block")
assert not hasattr(gpu_agg, "combine")
assert not hasattr(gpu_agg, "finalize")
for method_name in (
"_empty_global_partial_values",
"_partial_accumulator_dtypes",
"_final_arrow_types",
"_final_cudf_dtypes",
):
assert hasattr(gpu_agg, method_name)
def test_custom_gpu_aggregation_plan_supported_without_input_schema(self):
class _CustomGPUAggregate(GPUAggregateFn):
def __init__(self) -> None:
super().__init__(
"custom(value)",
on="value",
ignore_nulls=True,
accumulators=("acc",),
)
def partial_aggregate(
self,
df: Any,
key_columns: Tuple[str, ...],
accumulator_columns: Tuple[str, ...],
*,
input_schema: Any = None,
) -> Any:
raise NotImplementedError
def final_aggregate(
self,
df: Any,
key_columns: Tuple[str, ...],
accumulator_columns: Tuple[str, ...],
output_name: str,
) -> Any:
raise NotImplementedError
gpu_agg = _CustomGPUAggregate()
plan = build_gpu_aggregation_plan(("user_id",), (gpu_agg,), input_schema=None)
assert isinstance(plan, GPUAggregationPlan), plan
assert plan._gpu_aggregates == (gpu_agg,)
assert plan.output_names == ("custom(value)",)
assert plan.required_columns == ("user_id", "value")
def test_gpu_shuffle_routes_custom_gpu_aggregate_without_input_schema(self):
class _CustomGPUAggregate(GPUAggregateFn):
def __init__(self) -> None:
super().__init__(
"custom(value)",
on="value",
ignore_nulls=True,
accumulators=("acc",),
)
def partial_aggregate(
self,
df: Any,
key_columns: Tuple[str, ...],
accumulator_columns: Tuple[str, ...],
*,
input_schema: Any = None,
) -> Any:
raise NotImplementedError
def final_aggregate(
self,
df: Any,
key_columns: Tuple[str, ...],
accumulator_columns: Tuple[str, ...],
output_name: str,
) -> Any:
raise NotImplementedError
ctx = DataContext()
ctx.gpu_shuffle_num_actors = 4
ctx._shuffle_strategy = ShuffleStrategy.GPU_SHUFFLE
logical_op = self._make_aggregate_op([_CustomGPUAggregate()], input_schema=None)
input_physical_op = _make_input_op_mock()
op = plan_all_to_all_op(logical_op, [input_physical_op], ctx)
assert isinstance(op, GPUHashAggregateOperator)
def test_gpu_shuffle_routes_supported_aggregate_to_gpu_operator(self):
ctx = DataContext()
ctx.gpu_shuffle_num_actors = 4
ctx._shuffle_strategy = ShuffleStrategy.GPU_SHUFFLE
logical_op = self._make_aggregate_op([Count(), Sum("value")])
input_physical_op = _make_input_op_mock()
original_build_plan = hash_aggregate.build_gpu_aggregation_plan
built_plans: List[GPUAggregationPlan] = []
def _build_plan_once(*args: Any, **kwargs: Any) -> GPUAggregationPlan:
result = original_build_plan(*args, **kwargs)
assert isinstance(result, GPUAggregationPlan), result
built_plans.append(result)
return result
with patch(
"ray.data._internal.gpu_shuffle.hash_aggregate.build_gpu_aggregation_plan",
side_effect=_build_plan_once,
) as mock_build_plan:
op = plan_all_to_all_op(logical_op, [input_physical_op], ctx)
assert isinstance(op, GPUHashAggregateOperator)
assert op._num_partitions == 8
assert "GPUHashAggregate" in op.name
mock_build_plan.assert_called_once()
assert op._aggregation_plan is built_plans[0]
def test_gpu_hash_aggregate_operator_uses_prebuilt_plan(self):
ctx = DataContext()
ctx.gpu_shuffle_num_actors = 4
ctx._shuffle_strategy = ShuffleStrategy.GPU_SHUFFLE
input_physical_op = _make_input_op_mock()
schema = pa.schema([("user_id", pa.int64()), ("value", pa.int64())])
aggregation_plan = build_gpu_aggregation_plan(
("user_id",), (Count(), Sum("value")), input_schema=schema
)
assert isinstance(aggregation_plan, GPUAggregationPlan), aggregation_plan
with patch(
"ray.data._internal.gpu_shuffle.hash_shuffle.GPURankPool"
) as mock_default_pool:
op = GPUHashAggregateOperator(
ctx,
input_physical_op,
key_columns=("user_id",),
aggregation_plan=aggregation_plan,
num_partitions=8,
)
mock_default_pool.assert_not_called()
assert op._aggregation_plan is aggregation_plan
assert op._rank_pool.nranks == 4
def test_gpu_shuffle_unsupported_aggregate_falls_back_to_cpu_hash_aggregate(self):
from ray.data._internal.execution.operators.hash_aggregate import (
HashAggregateOperator,
)
ctx = DataContext()
ctx.gpu_shuffle_num_actors = 4
ctx._shuffle_strategy = ShuffleStrategy.GPU_SHUFFLE
logical_op = self._make_aggregate_op([AsList("value")])
input_physical_op = _make_input_op_mock(num_blocks=8, size_bytes=1024)
with patch(
"ray.data._internal.execution.operators.hash_shuffle"
"._get_total_cluster_resources",
return_value=ExecutionResources(cpu=4, memory=1024 * 1024 * 1024),
), patch(
"ray.data._internal.execution.operators.hash_shuffle.ray.put",
return_value=MagicMock(),
):
op = plan_all_to_all_op(logical_op, [input_physical_op], ctx)
assert isinstance(op, HashAggregateOperator)
def test_gpu_shuffle_missing_key_schema_falls_back_to_cpu_hash_aggregate(self):
from ray.data._internal.execution.operators.hash_aggregate import (
HashAggregateOperator,
)
ctx = DataContext()
ctx.gpu_shuffle_num_actors = 4
ctx._shuffle_strategy = ShuffleStrategy.GPU_SHUFFLE
logical_op = self._make_aggregate_op([Sum("value")], input_schema=None)
input_physical_op = _make_input_op_mock(num_blocks=8, size_bytes=1024)
with patch(
"ray.data._internal.execution.operators.hash_shuffle"
"._get_total_cluster_resources",
return_value=ExecutionResources(cpu=4, memory=1024 * 1024 * 1024),
), patch(
"ray.data._internal.execution.operators.hash_shuffle.ray.put",
return_value=MagicMock(),
):
op = plan_all_to_all_op(logical_op, [input_physical_op], ctx)
assert isinstance(op, HashAggregateOperator)
def test_global_aggregate_uses_synthetic_shuffle_key(self):
plan = build_gpu_aggregation_plan(tuple(), (Count(), Sum("value")))
assert isinstance(plan, GPUAggregationPlan), plan
assert plan.shuffle_key_columns == (hash_aggregate._GLOBAL_AGGREGATE_KEY,)
def test_required_columns_excludes_unused_input_columns(self):
table = pa.table(
{
"user_id": pa.array([0, 0, 1], type=pa.int64()),
"small": pa.array([1, 2, 3], type=pa.int64()),
"unused": pa.array([[1], [2], [3]], type=pa.list_(pa.int64())),
}
)
plan = build_gpu_aggregation_plan(
("user_id",), (Count(), Sum("small")), input_schema=table.schema
)
assert isinstance(plan, GPUAggregationPlan), plan
assert plan.required_columns == ("user_id", "small")
projected = table.select(list(plan.required_columns))
assert projected.column_names == ["user_id", "small"]
def test_global_count_required_columns_empty(self):
table = pa.table({"unused": pa.array([10, 20, 30], type=pa.int64())})
plan = build_gpu_aggregation_plan(
tuple(), (Count(),), input_schema=table.schema
)
assert isinstance(plan, GPUAggregationPlan), plan
assert plan.required_columns == ()
def test_merge_input_schema_unifies_value_dtype_across_blocks(self):
block1 = pa.table({"value": pa.array([1], type=pa.int8())})
block2 = pa.table({"value": pa.array([2], type=pa.int32())})
plan = build_gpu_aggregation_plan(tuple(), (Sum("value"),))
assert isinstance(plan, GPUAggregationPlan), plan
runtime_schema = None
runtime_schema = plan.merge_input_schema(runtime_schema, block1.schema)
runtime_schema = plan.merge_input_schema(runtime_schema, block2.schema)
assert runtime_schema is not None
assert runtime_schema.field("value").type == pa.int32()
def test_merge_input_schema_uses_logical_schema_for_shuffle_keys(self):
logical_schema = pa.schema(
[
("user_id", pa.int32()),
("value", pa.int64()),
]
)
plan = build_gpu_aggregation_plan(
("user_id",), (Sum("value"),), input_schema=logical_schema
)
assert isinstance(plan, GPUAggregationPlan), plan
runtime_schema = plan.merge_input_schema(
None, pa.schema([("user_id", pa.int8()), ("value", pa.int64())])
)
runtime_schema = plan.merge_input_schema(
runtime_schema,
pa.schema([("user_id", pa.int32()), ("value", pa.int64())]),
)
assert runtime_schema is not None
assert runtime_schema.field("user_id").type == pa.int32()
assert runtime_schema.field("value").type == pa.int64()
def test_normalize_output_arrow_null_schema(self):
null_schema = pa.schema(
[
("user_id", pa.int64()),
("value", pa.null()),
]
)
null_plan = build_gpu_aggregation_plan(
("user_id",), (Sum("value"),), input_schema=null_schema
)
assert isinstance(null_plan, GPUAggregationPlan), null_plan
output_table = pa.table(
{
"user_id": pa.array([0, 1, 2], type=pa.int64()),
"sum(value)": pa.array([None, None, None], type=pa.int64()),
}
)
assert null_plan.normalize_output_arrow(output_table).schema == pa.schema(
[
("user_id", pa.int64()),
("sum(value)", pa.null()),
]
)
def test_normalize_output_arrow_preserves_values_when_runtime_schema_upgrades(
self,
):
null_schema = pa.schema(
[
("user_id", pa.int64()),
("value", pa.null()),
]
)
plan = build_gpu_aggregation_plan(
("user_id",), (Sum("value"),), input_schema=null_schema
)
assert isinstance(plan, GPUAggregationPlan), plan
runtime_schema = plan.merge_input_schema(
null_schema,
pa.schema([("user_id", pa.int64()), ("value", pa.int64())]),
)
output_table = pa.table(
{
"user_id": pa.array([0, 1], type=pa.int64()),
"sum(value)": pa.array([10, 20], type=pa.int64()),
}
)
normalized = plan.normalize_output_arrow(
output_table, input_schema=runtime_schema
)
assert normalized.column("sum(value)").to_pylist() == [10, 20]
assert normalized.schema.field("sum(value)").type == pa.int64()
# ---------------------------------------------------------------------------
# GPU fixtures — shared by real-GPU aggregate tests below
# ---------------------------------------------------------------------------
def _num_cluster_gpus() -> int:
"""Return the number of GPUs in the Ray cluster (0 if Ray not initialised)."""
if not ray.is_initialized():
return 0
return int(ray.cluster_resources().get("GPU", 0))
@pytest.fixture(scope="module")
def ray_with_gpu():
"""Skip the test if GPU packages or GPU hardware are absent."""
pytest.importorskip("cudf", reason="cudf (GPU DataFrame library) not installed")
pytest.importorskip("rapidsmpf", reason="rapidsmpf not installed")
if not ray.is_initialized():
ray.init()
num_gpus = _num_cluster_gpus()
if num_gpus < 1:
pytest.skip("No GPU resources found in the Ray cluster")
yield num_gpus
# ---------------------------------------------------------------------------
# GPUAggregationPlan — real cuDF execution (conditional)
# ---------------------------------------------------------------------------
@pytest.mark.gpu
class TestGPUAggregationPlanReal:
"""Exercises aggregation plan methods with real cuDF on GPU hardware."""
def test_sum_and_mean_schema_source_dtypes_use_cudf_accumulators(
self, ray_with_gpu
):
import cudf
schema = pa.schema(
[
("user_id", pa.int64()),
("small", pa.int8()),
("flag", pa.bool_()),
("ratio", pa.float32()),
]
)
plan = build_gpu_aggregation_plan(
("user_id",),
(Sum("small"), Mean("flag"), Sum("ratio")),
input_schema=schema,
)
assert isinstance(plan, GPUAggregationPlan), plan
df = cudf.DataFrame(
{
"user_id": [0, 0, 1],
"small": np.array([100, 100, 1], dtype=np.int8),
"flag": [True, True, False],
"ratio": np.array([1.5, 2.5, 3.5], dtype=np.float32),
}
)
partial = plan.partial_aggregate(df, input_schema=schema)
sum_col = plan.accumulator_columns[0]
mean_sum_col = plan.accumulator_columns[1]
float_sum_col = plan.accumulator_columns[4]
group_zero = partial[partial["user_id"] == 0].iloc[0]
assert str(partial[sum_col].dtype) == "int64"
assert str(partial[mean_sum_col].dtype) == "int64"
assert str(partial[float_sum_col].dtype) == "float64"
assert group_zero[sum_col] == 200
assert group_zero[mean_sum_col] == 2
assert group_zero[float_sum_col] == pytest.approx(4.0)
def test_empty_final_aggregate_preserves_output_dtypes(self, ray_with_gpu):
import cudf
schema = pa.schema(
[
("user_id", pa.int64()),
("value", pa.int8()),
]
)
plan = build_gpu_aggregation_plan(
("user_id",),
(Count(), Sum("value"), Min("value"), Mean("value")),
input_schema=schema,
)
assert isinstance(plan, GPUAggregationPlan), plan
result = plan.final_aggregate(cudf.DataFrame())
result_table = result.to_arrow(preserve_index=False)
expected_schema = pa.schema(
[
("user_id", pa.int64()),
("count()", pa.int64()),
("sum(value)", pa.int64()),
("min(value)", pa.int64()),
("mean(value)", pa.float64()),
]
)
assert result_table.schema.equals(expected_schema, check_metadata=False)
global_plan = build_gpu_aggregation_plan(
tuple(), (Count(),), input_schema=schema
)
assert isinstance(global_plan, GPUAggregationPlan), global_plan
global_result = global_plan.final_aggregate(cudf.DataFrame())
global_table = global_result.to_arrow(preserve_index=False)
assert global_table.schema.equals(
pa.schema([("count()", pa.int64())]), check_metadata=False
)
runtime_plan = build_gpu_aggregation_plan(
("user_id",),
(Count(), Sum("value")),
input_schema=pa.schema(
[
("user_id", pa.int64()),
("value", pa.int8()),
]
),
)
assert isinstance(runtime_plan, GPUAggregationPlan), runtime_plan
runtime_result = runtime_plan.final_aggregate(
cudf.DataFrame(),
input_schema=pa.schema(
[
("user_id", pa.int64()),
("value", pa.int8()),
]
),
)
runtime_table = runtime_result.to_arrow(preserve_index=False)
assert runtime_table.schema.equals(
pa.schema(
[
("user_id", pa.int64()),
("count()", pa.int64()),
("sum(value)", pa.int64()),
]
),
check_metadata=False,
)
def test_custom_gpu_aggregate_fn_receives_input_schema(self, ray_with_gpu):
import cudf
class _CustomGPUAggregate(GPUAggregateFn):
def __init__(self) -> None:
super().__init__(
"custom(value)",
on="value",
ignore_nulls=True,
accumulators=("acc",),
)
self.seen_schema: Optional[pa.Schema] = None
def partial_aggregate(
self,
df: cudf.DataFrame,
key_columns: Tuple[str, ...],
accumulator_columns: Tuple[str, ...],
*,
input_schema: Any = None,
) -> cudf.DataFrame:
self.seen_schema = input_schema
acc_col = accumulator_columns[0]
return df[[key_columns[0], "value"]].rename(columns={"value": acc_col})
def final_aggregate(
self,
df: cudf.DataFrame,
key_columns: Tuple[str, ...],
accumulator_columns: Tuple[str, ...],
output_name: str,
) -> cudf.DataFrame:
acc_col = accumulator_columns[0]
return df[[key_columns[0], acc_col]].rename(
columns={acc_col: output_name}
)
schema = pa.schema([("user_id", pa.int64()), ("value", pa.int32())])
gpu_agg = _CustomGPUAggregate()
plan = build_gpu_aggregation_plan(("user_id",), (gpu_agg,), input_schema=schema)
assert isinstance(plan, GPUAggregationPlan), plan
partial = plan.partial_aggregate(
cudf.DataFrame({"user_id": [1], "value": np.array([2], dtype=np.int32)}),
input_schema=schema,
)
result = plan.final_aggregate(partial, input_schema=schema)
assert gpu_agg.seen_schema is schema
assert partial.to_pandas().to_dict("list") == {
"user_id": [1],
plan.accumulator_columns[0]: [2],
}
assert str(partial[plan.accumulator_columns[0]].dtype) == "int32"
assert result.to_pandas().to_dict("list") == {
"user_id": [1],
"custom(value)": [2],
}
assert str(result["custom(value)"].dtype) == "int32"
def test_null_reductions_preserve_groups_and_accumulator_dtypes(self, ray_with_gpu):
import cudf
schema = pa.schema([("user_id", pa.int64())])
plan = build_gpu_aggregation_plan(
("user_id",), (Sum("value"),), input_schema=schema
)
assert isinstance(plan, GPUAggregationPlan), plan
acc_col = plan.accumulator_columns[0]
df = cudf.DataFrame(
{
"user_id": [0, 1, 2, 0],
"value": cudf.Series([None, None, None, None], dtype="int64"),
}
)
partial = plan.partial_aggregate(df)
assert str(partial[acc_col].dtype) == "int64"
result = (
plan.final_aggregate(partial)
.to_pandas()
.sort_values("user_id")
.reset_index(drop=True)
)
assert result["user_id"].tolist() == [0, 1, 2]
assert result["sum(value)"].isna().all()
count_plan = build_gpu_aggregation_plan(
("user_id",),
(Count("value", ignore_nulls=True),),
input_schema=schema,
)
assert isinstance(count_plan, GPUAggregationPlan), count_plan
count_partial = count_plan.partial_aggregate(df)
count_result = (
count_plan.final_aggregate(count_partial)
.to_pandas()
.sort_values("user_id")
.reset_index(drop=True)
)
assert count_result.to_dict("records") == [
{"user_id": 0, "count(value)": 0},
{"user_id": 1, "count(value)": 0},
{"user_id": 2, "count(value)": 0},
]
def test_partial_aggregate_widens_shuffle_key_to_logical_schema(self, ray_with_gpu):
import cudf
logical_schema = pa.schema(
[
("user_id", pa.int32()),
("value", pa.int64()),
]
)
plan = build_gpu_aggregation_plan(
("user_id",), (Sum("value"),), input_schema=logical_schema
)
assert isinstance(plan, GPUAggregationPlan), plan
df = cudf.DataFrame(
{
"user_id": np.array([1], dtype=np.int8),
"value": np.array([1], dtype=np.int64),
}
)
partial = plan.partial_aggregate(
df,
input_schema=pa.schema([("user_id", pa.int8()), ("value", pa.int64())]),
)
assert partial["user_id"].iloc[0] == 1
assert str(partial["user_id"].dtype) == "int32"
def test_global_count_partial_aggregate(self, ray_with_gpu):
import cudf
plan = build_gpu_aggregation_plan(tuple(), (Count(),))
assert isinstance(plan, GPUAggregationPlan), plan
df = cudf.DataFrame(index=range(3))
partial = plan.partial_aggregate(df)
assert partial[plan.accumulator_columns[0]].iloc[0] == 3
def test_partial_aggregate_normalizes_null_key_column(self, ray_with_gpu):
import cudf
nan_key_plan = build_gpu_aggregation_plan(
("item",), (Count(),), input_schema=pa.schema([("item", pa.null())])
)
assert isinstance(nan_key_plan, GPUAggregationPlan), nan_key_plan
normalized_nan_key_partial = nan_key_plan.partial_aggregate(
cudf.DataFrame({"item": cudf.Series([None], dtype="float64")}),
input_schema=pa.schema([("item", pa.null())]),
)
assert str(normalized_nan_key_partial["item"].dtype) == "float64"
assert (
str(normalized_nan_key_partial[nan_key_plan.accumulator_columns[0]].dtype)
== "int64"
)
def test_partial_aggregate_normalizes_unknown_schema_accumulators(
self, ray_with_gpu
):
import cudf
unknown_schema_plan = build_gpu_aggregation_plan(
("A",), (Sum("B"),), input_schema=pa.schema([("A", pa.int64())])
)
assert isinstance(unknown_schema_plan, GPUAggregationPlan), unknown_schema_plan
unknown_schema_acc_col = unknown_schema_plan.accumulator_columns[0]
int_input = cudf.DataFrame({"A": [0], "B": np.array([1], dtype=np.int64)})
double_input = cudf.DataFrame(
{"A": [0], "B": np.array([1.0], dtype=np.float64)}
)
normalized_int_partial = unknown_schema_plan.partial_aggregate(
int_input,
input_schema=pa.schema([("A", pa.int64()), ("B", pa.int64())]),
)
normalized_double_partial = unknown_schema_plan.partial_aggregate(
double_input,
input_schema=pa.schema([("A", pa.int64()), ("B", pa.float64())]),
)
assert str(normalized_int_partial[unknown_schema_acc_col].dtype) == "int64"
assert str(normalized_double_partial[unknown_schema_acc_col].dtype) == "float64"
def test_unsupported_cudf_dtype_cast_is_noop(self, monkeypatch):
cudf = pytest.importorskip("cudf")
df = cudf.DataFrame({"value": np.array([1, 2], dtype=np.int64)})
def raise_type_error(_self):
raise TypeError("unsupported cuDF dtype")
monkeypatch.setattr(hash_aggregate.DataType, "to_cudf_type", raise_type_error)
hash_aggregate._cast_cudf_column_dtype(
df, "value", hash_aggregate.DataType.from_numpy("int64")
)
assert df["value"].to_pandas().tolist() == [1, 2]
assert str(df["value"].dtype) == "int64"
# ---------------------------------------------------------------------------
# GPUHashAggregateActor — real GPU paths (conditional)
# ---------------------------------------------------------------------------
@pytest.mark.gpu
class TestGPUHashAggregateActorReal:
"""Exercises GPU aggregate methods on actual hardware."""
def _make_setup_actor(self, aggregation_plan, total_nparts: int = 2):
actor_cls = cast(ActorClass[Any], GPUHashAggregateActor)
actor = cast(
ActorHandle[Any],
actor_cls.options(num_gpus=1).remote(
nranks=1,
total_nparts=total_nparts,
aggregation_plan=aggregation_plan,
),
)
_, root_address = ray.get(actor.setup_root.remote())
ray.get(actor.setup_worker.remote(root_address))
return actor
@staticmethod
def _collect_tables(actor) -> List[pa.Table]:
gen = actor.finish_and_extract.options(num_returns="streaming").remote()
return [
item for ref in gen for item in [ray.get(ref)] if isinstance(item, pa.Table)
]
@staticmethod
def _collect_frame(actor) -> pd.DataFrame:
tables = TestGPUHashAggregateActorReal._collect_tables(actor)
frames = [table.to_pandas() for table in tables if table.num_rows > 0]
if not frames:
return pd.DataFrame()
return pd.concat(frames, ignore_index=True)
def test_grouped_builtin_aggregates_real_gpu(self, ray_with_gpu):
table = pa.table(
{
"group": pa.array([0, 0, 1, 1, 1, 2], type=pa.int64()),
"value": pa.array([1, None, 2, 3, None, 10], type=pa.int64()),
}
)
plan = build_gpu_aggregation_plan(
("group",),
(
Count(),
Count("value", ignore_nulls=True),
Sum("value"),
Min("value"),
Max("value"),
Mean("value"),
),
input_schema=table.schema,
)
assert isinstance(plan, GPUAggregationPlan), plan
actor = self._make_setup_actor(plan)
try:
assert ray.get(actor.insert_batch.remote(table)) == table.num_rows
result = (
self._collect_frame(actor).sort_values("group").reset_index(drop=True)
)
finally:
ray.kill(actor)
assert result["group"].tolist() == [0, 1, 2]
assert result["count()"].tolist() == [2, 3, 1]
assert result["count(value)"].tolist() == [1, 2, 1]
assert result["sum(value)"].tolist() == [1, 5, 10]
assert result["min(value)"].tolist() == [1, 2, 10]
assert result["max(value)"].tolist() == [1, 3, 10]
assert result["mean(value)"].tolist() == pytest.approx([1.0, 2.5, 10.0])
def test_grouped_aggregate_output_has_unique_join_keys(self, ray_with_gpu):
import cudf
schema = pa.schema(
[
("key1", pa.int64()),
("key2", pa.int64()),
("value", pa.int64()),
]
)
plan = build_gpu_aggregation_plan(
("key1", "key2"),
(
Min(on="value", alias_name="min_value"),
Count(alias_name="row_count"),
),
input_schema=schema,
)
assert isinstance(plan, GPUAggregationPlan), plan
df = cudf.DataFrame(
{
"key1": [0, 0, 1],
"key2": [10, 10, 20],
"value": [5, 6, 7],
}
)
partial = plan.partial_aggregate(df, input_schema=schema)
result = plan.final_aggregate(partial, input_schema=schema)
result_table = result.to_arrow(preserve_index=False)
assert result_table.column_names == ["key1", "key2", "min_value", "row_count"]
assert len(result_table.column_names) == len(set(result_table.column_names))
key_table = pa.table(
{
"key1": pa.array([0, 1], type=pa.int64()),
"key2": pa.array([10, 20], type=pa.int64()),
}
)
joined = key_table.join(
result_table,
keys=["key1", "key2"],
join_type="inner",
)
assert joined.num_rows == 2
def test_global_builtin_aggregates_real_gpu(self, ray_with_gpu):
plan = build_gpu_aggregation_plan(
tuple(),
(
Count(),
Count("value", ignore_nulls=True),
Sum("value"),
Mean("value"),
),
)
assert isinstance(plan, GPUAggregationPlan), plan
table = pa.table({"value": pa.array([1, None, 2, 5], type=pa.int64())})
actor = self._make_setup_actor(plan, total_nparts=1)
try:
assert ray.get(actor.insert_batch.remote(table)) == table.num_rows
result = self._collect_frame(actor)
finally:
ray.kill(actor)
assert result.columns.tolist() == [
"count()",
"count(value)",
"sum(value)",
"mean(value)",
]
assert len(result) == 1
row = result.iloc[0].to_dict()
assert row["count()"] == 4
assert row["count(value)"] == 3
assert row["sum(value)"] == 8
assert row["mean(value)"] == pytest.approx(8 / 3)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))