Files
ray-project--ray/python/ray/data/_internal/gpu_shuffle/hash_aggregate.py
T
2026-07-13 13:17:40 +08:00

1691 lines
60 KiB
Python

from __future__ import annotations
import abc
import logging
import pickle
import time
import types
import typing
from collections import defaultdict
from typing import (
Any,
Dict,
Iterator,
List,
Optional,
Sequence,
Tuple,
Union,
cast,
)
import pyarrow as pa
import ray
from ray.data._internal.execution.interfaces import PhysicalOperator
from ray.data._internal.gpu_shuffle.hash_shuffle import (
_GPU_PARTITION_ID_KEY,
GPURankPool,
GPUShuffleOperator,
_derive_num_gpu_ranks,
)
from ray.data._internal.table_block import TableBlockAccessor
from ray.data.aggregate import AggregateFn, AggregateFnV2, Count, Max, Mean, Min, Sum
from ray.data.block import (
Block,
BlockAccessor,
BlockExecStats,
BlockMetadataWithSchema,
BlockStats,
Schema,
)
from ray.data.context import DataContext
from ray.data.datatype import DataType
if typing.TYPE_CHECKING:
import cudf
from ray.data._internal.progress.base_progress import BaseProgressBar
logger = logging.getLogger(__name__)
_GLOBAL_AGGREGATE_KEY = "__hash_aggregate_global_key"
def _cast_cudf_column_dtype(
df: cudf.DataFrame, column: str, dtype: Optional[DataType]
) -> None:
"""Cast a ``cudf.DataFrame`` column to specified dtype in place."""
if dtype is None or column not in df.columns:
return
dtype = DataType.from_dtype(dtype)
if dtype is None:
return
try:
cast_dtype = dtype.to_cudf_type()
except (TypeError, ValueError, NotImplementedError):
return
try:
df[column] = df[column].astype(cast_dtype)
except (TypeError, ValueError, NotImplementedError):
# fallback for handling all-null columns
if len(df) > 0 and not bool(df[column].isnull().all()):
return
import cudf
df[column] = cudf.Series([None] * len(df), dtype=cast_dtype)
def _cudf_column_dtype(df: cudf.DataFrame, column: str) -> Optional[DataType]:
"""Get the DataType of a column from a ``cudf.DataFrame``.
Returns None if the column is not found.
"""
if column not in df.columns:
return None
raw_dtype = df[column].dtype
try:
return DataType.from_cudf(raw_dtype)
except (AttributeError, ImportError, TypeError):
return DataType.from_dtype(raw_dtype)
def _schema_column_dtype(
schema: Optional[Schema], column: Optional[str]
) -> Optional[DataType]:
"""Get the DataType of a column from a ``Schema``.
Returns None if the column is not found (or None).
"""
if schema is None or column is None or column not in schema.names:
return None
if isinstance(schema, pa.Schema):
return DataType.from_dtype(schema.field(column).type)
return DataType.from_dtype(schema.types[schema.names.index(column)])
class GPUAggregateFn(abc.ABC):
"""Extension point for GPU-enabled aggregations.
GPU aggregate implementations define cuDF partial and final aggregation methods.
Args:
name: The name of the aggregation, which will be used as part of the column name
in the output, e.g. "sum" -> "sum(col)".
on: The name of the column to perform the aggregation on.
ignore_nulls: Whether to ignore null values during aggregation.
For example, should "count" include null rows or not?
accumulators: The names of (internal) accumulators used by the aggregation.
For example, "sum" uses "value" while "mean" uses ("sum", "count",
"null_count"). These will be combined with the accumulator_prefix to form
the final, unique names of the GPU accumulator columns.
"""
def __init__(
self,
name: str,
*,
on: Optional[str],
ignore_nulls: bool,
accumulators: Tuple[str, ...] = ("value",),
) -> None:
if not name:
raise ValueError(
f"Non-empty string has to be provided as name (got {name})"
)
if not accumulators or any(not accumulator for accumulator in accumulators):
raise ValueError("Accumulators must be non-empty strings.")
self.name = name
self.target_column = on
self.ignore_nulls = ignore_nulls
self._accumulators = accumulators
@abc.abstractmethod
def partial_aggregate(
self,
df: Any,
key_columns: Tuple[str, ...],
accumulator_columns: Tuple[str, ...],
*,
input_schema: Optional[Schema] = None,
) -> Any:
"""Aggregate one input block (as a ``cudf.DataFrame``) into GPU accumulator
columns."""
...
@abc.abstractmethod
def final_aggregate(
self,
df: Any,
key_columns: Tuple[str, ...],
accumulator_columns: Tuple[str, ...],
output_name: str,
) -> Any:
"""Aggregate shuffled GPU accumulator columns into final output."""
...
def _accumulator_columns(self, accumulator_prefix: str) -> Tuple[str, ...]:
"""Return the final, unique names of the GPU accumulator columns per
aggregation by concatenating the accumulator_prefix and the accumulator columns.
The accumulator_prefix is generated by the GPUAggregationPlan to uniquely
identify each GPU aggregation, e.g. for a single `mean` aggregation with
accumulator prefix "__ray_gpu_agg_0", the unique accumulator column names
will be:
- "__ray_gpu_agg_0_sum"
- "__ray_gpu_agg_0_count"
- "__ray_gpu_agg_0_null_count"
"""
return tuple(
f"{accumulator_prefix}_{accumulator}" for accumulator in self._accumulators
)
def _empty_global_partial_values(self, accumulator_prefix: str) -> Dict[str, Any]:
"""Return accumulator values for an empty block during a global aggregation
operation (no key columns).
This is used to ensure that the GPU aggregation can handle empty blocks
gracefully.
Subclasses should override this when all-null accumulator values are not
semantically correct for empty global input.
"""
return {
column: None for column in self._accumulator_columns(accumulator_prefix)
}
def _partial_accumulator_dtypes(
self,
df: cudf.DataFrame,
accumulator_prefix: str,
*,
input_schema: Optional[Schema] = None,
) -> Dict[str, Optional[DataType]]:
"""Return dtypes for partial accumulator columns.
Subclasses should override this when accumulator columns require explicit
dtype normalization.
"""
return {
column: None for column in self._accumulator_columns(accumulator_prefix)
}
def _final_cudf_dtypes(
self,
df: cudf.DataFrame,
output_name: str,
accumulator_prefix: str,
*,
input_schema: Optional[Schema] = None,
) -> Dict[str, Optional[DataType]]:
"""Return dtypes for final cuDF output columns.
Subclasses should override this when final output columns require explicit
dtype normalization.
"""
return {}
def _final_arrow_types(
self,
output_name: str,
*,
input_schema: Optional[Schema] = None,
) -> Dict[str, pa.DataType]:
"""Return Arrow types for final output columns.
Subclasses should override this when Arrow output normalization requires
explicit types.
"""
return {}
def _fill_missing_count(
result: cudf.DataFrame, count_column: str, dtype: Optional[DataType] = None
) -> None:
"""Fill missing counts with 0 and cast to specified dtype.
This is used to ensure that the GPU aggregation can handle empty blocks
gracefully.
"""
if count_column not in result.columns:
result[count_column] = 0
else:
result[count_column] = result[count_column].fillna(0)
_cast_cudf_column_dtype(result, count_column, dtype)
def _fill_missing_reduction(
result: cudf.DataFrame, reduction_column: str, dtype: Optional[DataType] = None
) -> None:
"""Fill any missing reduction values with None and cast to specified dtype.
This is used to ensure that the GPU aggregation can handle empty blocks
gracefully.
"""
if reduction_column not in result.columns:
result[reduction_column] = None
_cast_cudf_column_dtype(result, reduction_column, dtype)
class GPUCount(GPUAggregateFn):
"""GPU implementation for :class:`ray.data.aggregate.Count`."""
def __init__(self, agg: Count, *, source_dtype: Optional[DataType] = None) -> None:
self.source_dtype = source_dtype
super().__init__(
agg.name,
on=agg.get_target_column(),
ignore_nulls=agg._ignore_nulls,
accumulators=("value",),
)
def partial_aggregate(
self,
df: cudf.DataFrame,
key_columns: Tuple[str, ...],
accumulator_columns: Tuple[str, ...],
*,
input_schema: Optional[Schema] = None,
) -> cudf.DataFrame:
acc_col = accumulator_columns[0]
grouped = df.groupby(list(key_columns), dropna=False)
if self.target_column is None or not self.ignore_nulls:
result = grouped.size().reset_index()
return result.rename(columns={result.columns[-1]: acc_col})
sizes = grouped.size().reset_index()
sizes = sizes.rename(columns={sizes.columns[-1]: acc_col})
count_dtype = _cudf_column_dtype(sizes, acc_col)
counts = grouped[self.target_column].count().reset_index()
counts = counts.rename(columns={counts.columns[-1]: acc_col})
result = sizes[list(key_columns)].merge(
counts, on=list(key_columns), how="left"
)
_fill_missing_count(result, acc_col, count_dtype)
return result[list(key_columns) + [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]
result = (
df.groupby(list(key_columns), dropna=False)[acc_col].sum().reset_index()
)
result = result.rename(columns={result.columns[-1]: output_name})
return result[list(key_columns) + [output_name]]
def _empty_global_partial_values(self, accumulator_prefix: str) -> Dict[str, Any]:
return {self._accumulator_columns(accumulator_prefix)[0]: 0}
def _partial_accumulator_dtypes(
self,
df: cudf.DataFrame,
accumulator_prefix: str,
*,
input_schema: Optional[Schema] = None,
) -> Dict[str, Optional[DataType]]:
return {
self._accumulator_columns(accumulator_prefix)[0]: DataType.from_numpy(
"int64"
)
}
def _final_cudf_dtypes(
self,
df: cudf.DataFrame,
output_name: str,
accumulator_prefix: str,
*,
input_schema: Optional[Schema] = None,
) -> Dict[str, Optional[DataType]]:
return {output_name: DataType.from_numpy("int64")}
class GPUSum(GPUAggregateFn):
"""GPU implementation for :class:`ray.data.aggregate.Sum`."""
def __init__(self, agg: Sum, *, source_dtype: Optional[DataType] = None) -> None:
self.source_dtype = source_dtype
super().__init__(
agg.name,
on=agg.get_target_column(),
ignore_nulls=agg._ignore_nulls,
accumulators=("value",),
)
def partial_aggregate(
self,
df: cudf.DataFrame,
key_columns: Tuple[str, ...],
accumulator_columns: Tuple[str, ...],
*,
input_schema: Optional[Schema] = None,
) -> cudf.DataFrame:
assert self.target_column is not None
acc_col = accumulator_columns[0]
output_dtype = self._reduction_dtype(df, input_schema)
size_col = f"{acc_col}_size"
count_col = f"{acc_col}_count"
grouped = df.groupby(list(key_columns), dropna=False)
sizes = grouped.size().reset_index()
sizes = sizes.rename(columns={sizes.columns[-1]: size_col})
count_dtype = _cudf_column_dtype(sizes, size_col)
counts = grouped[self.target_column].count().reset_index()
counts = counts.rename(columns={counts.columns[-1]: count_col})
result = sizes.merge(counts, on=list(key_columns), how="left")
_fill_missing_count(result, count_col, count_dtype)
if len(result) > 0 and bool(cast("cudf.Series", result[count_col] == 0).all()):
result[acc_col] = None
_cast_cudf_column_dtype(result, acc_col, output_dtype)
else:
aggregated = grouped[self.target_column].sum().reset_index()
aggregated = aggregated.rename(columns={aggregated.columns[-1]: acc_col})
result = result.merge(aggregated, on=list(key_columns), how="left")
_fill_missing_reduction(result, acc_col, output_dtype)
null_mask = result[count_col] == 0
if not self.ignore_nulls:
null_mask = result[size_col] != result[count_col]
result.loc[null_mask, acc_col] = None
return result[list(key_columns) + [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]
output_dtype = _cudf_column_dtype(df, acc_col)
size_col = f"{acc_col}_partial_size"
count_col = f"{acc_col}_partial_count"
grouped = df.groupby(list(key_columns), dropna=False)
sizes = grouped.size().reset_index()
sizes = sizes.rename(columns={sizes.columns[-1]: size_col})
count_dtype = _cudf_column_dtype(sizes, size_col)
counts = grouped[acc_col].count().reset_index()
counts = counts.rename(columns={counts.columns[-1]: count_col})
result = sizes.merge(counts, on=list(key_columns), how="left")
_fill_missing_count(result, count_col, count_dtype)
if len(result) > 0 and bool(cast("cudf.Series", result[count_col] == 0).all()):
result[output_name] = None
_cast_cudf_column_dtype(result, output_name, output_dtype)
else:
aggregated = grouped[acc_col].sum().reset_index()
aggregated = aggregated.rename(
columns={aggregated.columns[-1]: output_name}
)
result = result.merge(aggregated, on=list(key_columns), how="left")
_fill_missing_reduction(result, output_name, output_dtype)
null_mask = result[count_col] == 0
if not self.ignore_nulls:
null_mask = result[size_col] != result[count_col]
result.loc[null_mask, output_name] = None
return result[list(key_columns) + [output_name]]
def _empty_global_partial_values(self, accumulator_prefix: str) -> Dict[str, Any]:
return {
self._accumulator_columns(accumulator_prefix)[0]: (
None if self.ignore_nulls else 0
)
}
def _partial_accumulator_dtypes(
self,
df: cudf.DataFrame,
accumulator_prefix: str,
*,
input_schema: Optional[Schema] = None,
) -> Dict[str, Optional[DataType]]:
assert self.target_column is not None
return {
self._accumulator_columns(accumulator_prefix)[0]: self._reduction_dtype(
df, input_schema
)
}
def _reduction_dtype(
self, df: cudf.DataFrame, input_schema: Optional[Schema]
) -> Optional[DataType]:
assert self.target_column is not None
dtype = self.source_dtype
if dtype is None:
dtype = _schema_column_dtype(input_schema, self.target_column)
if dtype is None:
dtype = _cudf_column_dtype(df, self.target_column)
if dtype is None:
return None
if dtype.is_null_type() or dtype.is_boolean_type():
return DataType.from_numpy("int64")
if dtype.is_integer_type():
return DataType.from_numpy("uint64" if dtype.is_uint64_type() else "int64")
if dtype.is_floating_type():
return DataType.from_numpy("float64")
return dtype
def _final_cudf_dtypes(
self,
df: cudf.DataFrame,
output_name: str,
accumulator_prefix: str,
*,
input_schema: Optional[Schema] = None,
) -> Dict[str, Optional[DataType]]:
acc_col = self._accumulator_columns(accumulator_prefix)[0]
acc_dtype = _cudf_column_dtype(df, acc_col)
if acc_dtype is None:
acc_dtype = self._partial_accumulator_dtypes(
df, accumulator_prefix, input_schema=input_schema
)[acc_col]
return {output_name: acc_dtype}
def _final_arrow_types(
self,
output_name: str,
*,
input_schema: Optional[Schema] = None,
) -> Dict[str, pa.DataType]:
dtype = self.source_dtype
if dtype is None or dtype.is_null_type():
dtype = _schema_column_dtype(input_schema, self.target_column)
if dtype is None or not dtype.is_null_type():
return {}
return {output_name: pa.null()}
class GPUMin(GPUAggregateFn):
"""GPU implementation for :class:`ray.data.aggregate.Min`."""
def __init__(self, agg: Min, *, source_dtype: Optional[DataType] = None) -> None:
self.source_dtype = source_dtype
super().__init__(
agg.name,
on=agg.get_target_column(),
ignore_nulls=agg._ignore_nulls,
accumulators=("value",),
)
def partial_aggregate(
self,
df: cudf.DataFrame,
key_columns: Tuple[str, ...],
accumulator_columns: Tuple[str, ...],
*,
input_schema: Optional[Schema] = None,
) -> cudf.DataFrame:
assert self.target_column is not None
acc_col = accumulator_columns[0]
output_dtype = self._reduction_dtype(df, input_schema)
size_col = f"{acc_col}_size"
count_col = f"{acc_col}_count"
grouped = df.groupby(list(key_columns), dropna=False)
sizes = grouped.size().reset_index()
sizes = sizes.rename(columns={sizes.columns[-1]: size_col})
count_dtype = _cudf_column_dtype(sizes, size_col)
counts = grouped[self.target_column].count().reset_index()
counts = counts.rename(columns={counts.columns[-1]: count_col})
result = sizes.merge(counts, on=list(key_columns), how="left")
_fill_missing_count(result, count_col, count_dtype)
if len(result) > 0 and bool(cast("cudf.Series", result[count_col] == 0).all()):
result[acc_col] = None
_cast_cudf_column_dtype(result, acc_col, output_dtype)
else:
aggregated = grouped[self.target_column].min().reset_index()
aggregated = aggregated.rename(columns={aggregated.columns[-1]: acc_col})
result = result.merge(aggregated, on=list(key_columns), how="left")
_fill_missing_reduction(result, acc_col, output_dtype)
null_mask = result[count_col] == 0
if not self.ignore_nulls:
null_mask = result[size_col] != result[count_col]
result.loc[null_mask, acc_col] = None
return result[list(key_columns) + [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]
output_dtype = _cudf_column_dtype(df, acc_col)
size_col = f"{acc_col}_partial_size"
count_col = f"{acc_col}_partial_count"
grouped = df.groupby(list(key_columns), dropna=False)
sizes = grouped.size().reset_index()
sizes = sizes.rename(columns={sizes.columns[-1]: size_col})
count_dtype = _cudf_column_dtype(sizes, size_col)
counts = grouped[acc_col].count().reset_index()
counts = counts.rename(columns={counts.columns[-1]: count_col})
result = sizes.merge(counts, on=list(key_columns), how="left")
_fill_missing_count(result, count_col, count_dtype)
if len(result) > 0 and bool(cast("cudf.Series", result[count_col] == 0).all()):
result[output_name] = None
_cast_cudf_column_dtype(result, output_name, output_dtype)
else:
aggregated = grouped[acc_col].min().reset_index()
aggregated = aggregated.rename(
columns={aggregated.columns[-1]: output_name}
)
result = result.merge(aggregated, on=list(key_columns), how="left")
_fill_missing_reduction(result, output_name, output_dtype)
null_mask = result[count_col] == 0
if not self.ignore_nulls:
null_mask = result[size_col] != result[count_col]
result.loc[null_mask, output_name] = None
return result[list(key_columns) + [output_name]]
def _empty_global_partial_values(self, accumulator_prefix: str) -> Dict[str, Any]:
return {
self._accumulator_columns(accumulator_prefix)[0]: (
None if self.ignore_nulls else float("+inf")
)
}
def _partial_accumulator_dtypes(
self,
df: cudf.DataFrame,
accumulator_prefix: str,
*,
input_schema: Optional[Schema] = None,
) -> Dict[str, Optional[DataType]]:
assert self.target_column is not None
return {
self._accumulator_columns(accumulator_prefix)[0]: self._reduction_dtype(
df, input_schema
)
}
def _reduction_dtype(
self, df: cudf.DataFrame, input_schema: Optional[Schema]
) -> Optional[DataType]:
assert self.target_column is not None
dtype = self.source_dtype
if dtype is None:
dtype = _schema_column_dtype(input_schema, self.target_column)
if dtype is None:
dtype = _cudf_column_dtype(df, self.target_column)
if dtype is None:
return None
if dtype.is_null_type():
return DataType.from_numpy("int64")
if dtype.is_boolean_type():
return DataType.from_numpy("bool")
if dtype.is_integer_type():
return DataType.from_numpy("uint64" if dtype.is_uint64_type() else "int64")
if dtype.is_floating_type():
return DataType.from_numpy("float64")
return dtype
def _final_cudf_dtypes(
self,
df: cudf.DataFrame,
output_name: str,
accumulator_prefix: str,
*,
input_schema: Optional[Schema] = None,
) -> Dict[str, Optional[DataType]]:
acc_col = self._accumulator_columns(accumulator_prefix)[0]
acc_dtype = _cudf_column_dtype(df, acc_col)
if acc_dtype is None:
acc_dtype = self._partial_accumulator_dtypes(
df, accumulator_prefix, input_schema=input_schema
)[acc_col]
return {output_name: acc_dtype}
def _final_arrow_types(
self,
output_name: str,
*,
input_schema: Optional[Schema] = None,
) -> Dict[str, pa.DataType]:
dtype = self.source_dtype
if dtype is None or dtype.is_null_type():
dtype = _schema_column_dtype(input_schema, self.target_column)
if dtype is None or not dtype.is_null_type():
return {}
return {output_name: pa.null()}
class GPUMax(GPUAggregateFn):
"""GPU implementation for :class:`ray.data.aggregate.Max`."""
def __init__(self, agg: Max, *, source_dtype: Optional[DataType] = None) -> None:
self.source_dtype = source_dtype
super().__init__(
agg.name,
on=agg.get_target_column(),
ignore_nulls=agg._ignore_nulls,
accumulators=("value",),
)
def partial_aggregate(
self,
df: cudf.DataFrame,
key_columns: Tuple[str, ...],
accumulator_columns: Tuple[str, ...],
*,
input_schema: Optional[Schema] = None,
) -> cudf.DataFrame:
assert self.target_column is not None
acc_col = accumulator_columns[0]
output_dtype = self._reduction_dtype(df, input_schema)
size_col = f"{acc_col}_size"
count_col = f"{acc_col}_count"
grouped = df.groupby(list(key_columns), dropna=False)
sizes = grouped.size().reset_index()
sizes = sizes.rename(columns={sizes.columns[-1]: size_col})
count_dtype = _cudf_column_dtype(sizes, size_col)
counts = grouped[self.target_column].count().reset_index()
counts = counts.rename(columns={counts.columns[-1]: count_col})
result = sizes.merge(counts, on=list(key_columns), how="left")
_fill_missing_count(result, count_col, count_dtype)
if len(result) > 0 and bool(cast("cudf.Series", result[count_col] == 0).all()):
result[acc_col] = None
_cast_cudf_column_dtype(result, acc_col, output_dtype)
else:
aggregated = grouped[self.target_column].max().reset_index()
aggregated = aggregated.rename(columns={aggregated.columns[-1]: acc_col})
result = result.merge(aggregated, on=list(key_columns), how="left")
_fill_missing_reduction(result, acc_col, output_dtype)
null_mask = result[count_col] == 0
if not self.ignore_nulls:
null_mask = result[size_col] != result[count_col]
result.loc[null_mask, acc_col] = None
return result[list(key_columns) + [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]
output_dtype = _cudf_column_dtype(df, acc_col)
size_col = f"{acc_col}_partial_size"
count_col = f"{acc_col}_partial_count"
grouped = df.groupby(list(key_columns), dropna=False)
sizes = grouped.size().reset_index()
sizes = sizes.rename(columns={sizes.columns[-1]: size_col})
count_dtype = _cudf_column_dtype(sizes, size_col)
counts = grouped[acc_col].count().reset_index()
counts = counts.rename(columns={counts.columns[-1]: count_col})
result = sizes.merge(counts, on=list(key_columns), how="left")
_fill_missing_count(result, count_col, count_dtype)
if len(result) > 0 and bool(cast("cudf.Series", result[count_col] == 0).all()):
result[output_name] = None
_cast_cudf_column_dtype(result, output_name, output_dtype)
else:
aggregated = grouped[acc_col].max().reset_index()
aggregated = aggregated.rename(
columns={aggregated.columns[-1]: output_name}
)
result = result.merge(aggregated, on=list(key_columns), how="left")
_fill_missing_reduction(result, output_name, output_dtype)
null_mask = result[count_col] == 0
if not self.ignore_nulls:
null_mask = result[size_col] != result[count_col]
result.loc[null_mask, output_name] = None
return result[list(key_columns) + [output_name]]
def _empty_global_partial_values(self, accumulator_prefix: str) -> Dict[str, Any]:
return {
self._accumulator_columns(accumulator_prefix)[0]: (
None if self.ignore_nulls else float("-inf")
)
}
def _partial_accumulator_dtypes(
self,
df: cudf.DataFrame,
accumulator_prefix: str,
*,
input_schema: Optional[Schema] = None,
) -> Dict[str, Optional[DataType]]:
assert self.target_column is not None
return {
self._accumulator_columns(accumulator_prefix)[0]: self._reduction_dtype(
df, input_schema
)
}
def _reduction_dtype(
self, df: cudf.DataFrame, input_schema: Optional[Schema]
) -> Optional[DataType]:
assert self.target_column is not None
dtype = self.source_dtype
if dtype is None:
dtype = _schema_column_dtype(input_schema, self.target_column)
if dtype is None:
dtype = _cudf_column_dtype(df, self.target_column)
if dtype is None:
return None
if dtype.is_null_type():
return DataType.from_numpy("int64")
if dtype.is_boolean_type():
return DataType.from_numpy("bool")
if dtype.is_integer_type():
return DataType.from_numpy("uint64" if dtype.is_uint64_type() else "int64")
if dtype.is_floating_type():
return DataType.from_numpy("float64")
return dtype
def _final_cudf_dtypes(
self,
df: cudf.DataFrame,
output_name: str,
accumulator_prefix: str,
*,
input_schema: Optional[Schema] = None,
) -> Dict[str, Optional[DataType]]:
acc_col = self._accumulator_columns(accumulator_prefix)[0]
acc_dtype = _cudf_column_dtype(df, acc_col)
if acc_dtype is None:
acc_dtype = self._partial_accumulator_dtypes(
df, accumulator_prefix, input_schema=input_schema
)[acc_col]
return {output_name: acc_dtype}
def _final_arrow_types(
self,
output_name: str,
*,
input_schema: Optional[Schema] = None,
) -> Dict[str, pa.DataType]:
dtype = self.source_dtype
if dtype is None or dtype.is_null_type():
dtype = _schema_column_dtype(input_schema, self.target_column)
if dtype is None or not dtype.is_null_type():
return {}
return {output_name: pa.null()}
class GPUMean(GPUAggregateFn):
"""GPU implementation for :class:`ray.data.aggregate.Mean`."""
def __init__(self, agg: Mean, *, source_dtype: Optional[DataType] = None) -> None:
self.source_dtype = source_dtype
super().__init__(
agg.name,
on=agg.get_target_column(),
ignore_nulls=agg._ignore_nulls,
accumulators=("sum", "count", "null_count"),
)
def partial_aggregate(
self,
df: cudf.DataFrame,
key_columns: Tuple[str, ...],
accumulator_columns: Tuple[str, ...],
*,
input_schema: Optional[Schema] = None,
) -> cudf.DataFrame:
assert self.target_column is not None
sum_col, count_col, null_count_col = accumulator_columns
size_col = f"{sum_col}_size"
output_dtype = self._reduction_dtype(df, input_schema)
grouped = df.groupby(list(key_columns), dropna=False)
sizes = grouped.size().reset_index()
sizes = sizes.rename(columns={sizes.columns[-1]: size_col})
count_dtype = _cudf_column_dtype(sizes, size_col)
counts = grouped[self.target_column].count().reset_index()
counts = counts.rename(columns={counts.columns[-1]: count_col})
result = sizes.merge(counts, on=list(key_columns), how="left")
_fill_missing_count(result, count_col, count_dtype)
if len(result) > 0 and bool(cast("cudf.Series", result[count_col] == 0).all()):
result[sum_col] = None
_cast_cudf_column_dtype(result, sum_col, output_dtype)
else:
aggregated = grouped[self.target_column].sum().reset_index()
aggregated = aggregated.rename(columns={aggregated.columns[-1]: sum_col})
result = result.merge(aggregated, on=list(key_columns), how="left")
_fill_missing_reduction(result, sum_col, output_dtype)
null_mask = result[count_col] == 0
if not self.ignore_nulls:
null_mask = result[size_col] != result[count_col]
result.loc[null_mask, sum_col] = None
result[null_count_col] = result[size_col] - result[count_col]
_cast_cudf_column_dtype(result, null_count_col, count_dtype)
return result[list(key_columns) + list(accumulator_columns)]
def final_aggregate(
self,
df: cudf.DataFrame,
key_columns: Tuple[str, ...],
accumulator_columns: Tuple[str, ...],
output_name: str,
) -> cudf.DataFrame:
sum_col, count_col, null_count_col = accumulator_columns
final_sum_col = f"{sum_col}_final_sum"
final_count_col = f"{count_col}_final_count"
final_null_count_col = f"{null_count_col}_final_null_count"
sum_dtype = _cudf_column_dtype(df, sum_col)
acc_cols = [count_col, null_count_col, sum_col]
aggregated = (
df.groupby(list(key_columns), dropna=False)[acc_cols].sum().reset_index()
)
result = aggregated.rename(
columns={
count_col: final_count_col,
null_count_col: final_null_count_col,
sum_col: final_sum_col,
}
)
_fill_missing_reduction(result, final_sum_col, sum_dtype)
result[output_name] = result[final_sum_col] / result[final_count_col]
null_mask = result[final_count_col] == 0
if not self.ignore_nulls:
null_mask = null_mask | (result[final_null_count_col] > 0)
result.loc[null_mask, output_name] = None
return result[list(key_columns) + [output_name]]
def _empty_global_partial_values(self, accumulator_prefix: str) -> Dict[str, Any]:
sum_col, count_col, null_count_col = self._accumulator_columns(
accumulator_prefix
)
return {sum_col: None, count_col: 0, null_count_col: 0}
def _partial_accumulator_dtypes(
self,
df: cudf.DataFrame,
accumulator_prefix: str,
*,
input_schema: Optional[Schema] = None,
) -> Dict[str, Optional[DataType]]:
sum_col, count_col, null_count_col = self._accumulator_columns(
accumulator_prefix
)
assert self.target_column is not None
return {
sum_col: self._reduction_dtype(df, input_schema),
count_col: DataType.from_numpy("int64"),
null_count_col: DataType.from_numpy("int64"),
}
def _reduction_dtype(
self, df: cudf.DataFrame, input_schema: Optional[Schema]
) -> Optional[DataType]:
assert self.target_column is not None
dtype = self.source_dtype
if dtype is None:
dtype = _schema_column_dtype(input_schema, self.target_column)
if dtype is None:
dtype = _cudf_column_dtype(df, self.target_column)
if dtype is None:
return None
if dtype.is_null_type():
return DataType.from_numpy("float64")
if dtype.is_boolean_type():
return DataType.from_numpy("int64")
if dtype.is_integer_type():
return DataType.from_numpy("uint64" if dtype.is_uint64_type() else "int64")
if dtype.is_floating_type():
return DataType.from_numpy("float64")
return dtype
def _final_cudf_dtypes(
self,
df: cudf.DataFrame,
output_name: str,
accumulator_prefix: str,
*,
input_schema: Optional[Schema] = None,
) -> Dict[str, Optional[DataType]]:
return {output_name: DataType.from_numpy("float64")}
def _final_arrow_types(
self,
output_name: str,
*,
input_schema: Optional[Schema] = None,
) -> Dict[str, pa.DataType]:
dtype = self.source_dtype
if dtype is None or dtype.is_null_type():
dtype = _schema_column_dtype(input_schema, self.target_column)
if dtype is None or not dtype.is_null_type():
return {}
return {output_name: pa.null()}
def _empty_dataframe(
cudf_module: types.ModuleType,
columns: Sequence[str],
dtypes: Optional[Dict[str, Optional[DataType]]] = None,
) -> cudf.DataFrame:
"""Create an empty ``cudf.DataFrame`` with specified columns and dtypes."""
dtypes = dtypes or {}
df = cudf_module.DataFrame()
for column in columns:
df[column] = []
_cast_cudf_column_dtype(df, column, dtypes.get(column))
return df
class GPUAggregationPlan:
"""Executable GPU aggregation plan shared by the driver and GPU actors.
Args:
key_columns: The key columns to group by.
gpu_aggregates: The GPU aggregate functions to apply.
accumulator_prefix: The prefix for intermediate accumulator columns.
input_schema: The schema of the input data.
"""
def __init__(
self,
key_columns: Tuple[str, ...],
gpu_aggregates: Tuple[GPUAggregateFn, ...],
accumulator_prefix: str,
input_schema: Optional[Schema] = None,
) -> None:
if not accumulator_prefix:
raise ValueError("Accumulator prefix must be a non-empty string.")
self._key_columns = key_columns
self._gpu_aggregates = gpu_aggregates
self._input_schema = input_schema
self._is_global = not key_columns
self._shuffle_key_columns = key_columns
# Resolve duplicate aggregation names the same way TableBlockAccessor does.
counts: Dict[str, int] = defaultdict(int)
resolved_names: List[str] = []
for agg in gpu_aggregates:
name = agg.name
if counts[name] > 0:
name = TableBlockAccessor._munge_conflict(name, counts[name])
counts[agg.name] += 1
resolved_names.append(name)
self._output_names = tuple(resolved_names)
# Generate unique accumulator prefixes for each resolved name
self._accumulator_prefixes = tuple(
f"{accumulator_prefix}_{index}" for index, _ in enumerate(gpu_aggregates)
)
# If global aggregation (w/o key columns), use an artificial shuffle key.
if self._is_global:
# filter out empty target columns
required_columns = {
agg.target_column
for agg in gpu_aggregates
if agg.target_column is not None
}
# ensure a unique global key by prepending an underscore if needed
# (just in case there is a collision)
global_key = _GLOBAL_AGGREGATE_KEY
while global_key in required_columns:
global_key = f"_{global_key}"
# set the shuffle key to the global key
self._shuffle_key_columns = (global_key,)
@property
def accumulator_columns(self) -> Tuple[str, ...]:
"""Return all internal accumulator columns for the GPU aggregation plan."""
columns: List[str] = []
for agg, accumulator_prefix in zip(
self._gpu_aggregates, self._accumulator_prefixes
):
columns.extend(agg._accumulator_columns(accumulator_prefix))
return tuple(columns)
@property
def output_names(self) -> Tuple[str, ...]:
"""Return all final output names for the GPU aggregation plan.
These will be used as the column names for the final output of the GPU
aggregations in the plan, e.g. "sum(col1)", "mean(col2)", etc.
"""
return self._output_names
@property
def required_columns(self) -> Tuple[str, ...]:
"""Return all required columns for the GPU aggregation plan.
These include the key columns and aggregation target columns, e.g.
groupby("col1").sum("col2")
"""
columns = list(self._key_columns)
for agg in self._gpu_aggregates:
target_column = agg.target_column
if target_column is not None and target_column not in columns:
columns.append(target_column)
return tuple(columns)
@property
def shuffle_key_columns(self) -> Tuple[str, ...]:
"""Return the shuffle key columns for the GPU aggregation plan."""
return self._shuffle_key_columns
def normalize_output_arrow(
self,
table: pa.Table,
input_schema: Optional[Schema] = None,
) -> pa.Table:
arrow_types = self._final_arrow_types(input_schema)
if not arrow_types:
return table
columns = []
for column_name in table.column_names:
if column_name in arrow_types:
columns.append(pa.nulls(table.num_rows, type=arrow_types[column_name]))
else:
columns.append(table[column_name])
return pa.table(columns, names=table.column_names)
def partial_aggregate(
self, df: cudf.DataFrame, input_schema: Optional[Schema] = None
) -> cudf.DataFrame:
import cudf as cudf_module
if self._is_global:
df = df.copy(deep=False)
df[self._shuffle_key_columns[0]] = 0
key_columns = self._shuffle_key_columns
if len(df) == 0:
if self._is_global:
values: Dict[str, List[Any]] = {key_columns[0]: [0]}
for agg, accumulator_prefix in zip(
self._gpu_aggregates,
self._accumulator_prefixes,
):
empty_values = agg._empty_global_partial_values(accumulator_prefix)
for column, value in empty_values.items():
values[column] = [value]
result = cudf_module.DataFrame(values)[
list(key_columns) + list(self.accumulator_columns)
]
for column, dtype in self._partial_accumulator_dtypes(
df, key_columns, input_schema=input_schema
).items():
_cast_cudf_column_dtype(result, column, dtype)
return result
return _empty_dataframe(
cudf_module,
list(key_columns) + list(self.accumulator_columns),
dtypes=self._partial_accumulator_dtypes(
df, key_columns, input_schema=input_schema
),
)
result = None
for agg, accumulator_prefix in zip(
self._gpu_aggregates, self._accumulator_prefixes
):
accumulator_columns = agg._accumulator_columns(accumulator_prefix)
partial = agg.partial_aggregate(
df,
key_columns,
accumulator_columns,
input_schema=input_schema,
)
result = (
partial
if result is None
else result.merge(partial, on=list(key_columns), how="outer")
)
assert result is not None
for column, dtype in self._partial_accumulator_dtypes(
df, key_columns, input_schema=input_schema
).items():
_cast_cudf_column_dtype(result, column, dtype)
return result[list(key_columns) + list(self.accumulator_columns)]
def final_aggregate(
self,
df: cudf.DataFrame,
input_schema: Optional[Schema] = None,
) -> cudf.DataFrame:
import cudf as cudf_module
key_columns = self._shuffle_key_columns
output_columns = ([] if self._is_global else list(key_columns)) + list(
self.output_names
)
if len(df) == 0:
return _empty_dataframe(
cudf_module,
output_columns,
dtypes=self._final_cudf_dtypes(
df,
input_schema=input_schema,
),
)
result = None
for agg, output_name, accumulator_prefix in zip(
self._gpu_aggregates, self._output_names, self._accumulator_prefixes
):
accumulator_columns = agg._accumulator_columns(accumulator_prefix)
finalized = agg.final_aggregate(
df,
key_columns,
accumulator_columns,
output_name,
)
result = (
finalized
if result is None
else result.merge(finalized, on=list(key_columns), how="outer")
)
assert result is not None
if self._is_global:
result = result.drop(columns=[self._shuffle_key_columns[0]])
return result[output_columns]
def merge_input_schema(
self, current: Optional[pa.Schema], observed: Optional[Schema]
) -> Optional[pa.Schema]:
"""Merge an observed block schema into the current runtime input schema."""
if observed is None:
return current
fields: Dict[str, pa.DataType] = {}
if current is not None:
fields.update({field.name: field.type for field in current})
for column in self.required_columns:
observed_dtype = _schema_column_dtype(observed, column)
if observed_dtype is None:
continue
try:
observed_arrow_dtype = observed_dtype.to_arrow_dtype()
except (AssertionError, TypeError, pa.ArrowNotImplementedError):
continue
current_dtype = fields.get(column)
if current_dtype is None or pa.types.is_null(current_dtype):
fields[column] = observed_arrow_dtype
elif not current_dtype.equals(observed_arrow_dtype):
# Unify schemas using arrow_ops
try:
from ray.data._internal.arrow_ops.transform_pyarrow import (
unify_schemas,
)
fields[column] = (
unify_schemas(
[
pa.schema([(column, current_dtype)]),
pa.schema([(column, observed_arrow_dtype)]),
],
promote_types=True,
)
.field(column)
.type
)
except (pa.ArrowInvalid, pa.ArrowTypeError):
pass
if not fields:
return current
ordered_names = [column for column in self.required_columns if column in fields]
ordered_names.extend(name for name in fields if name not in ordered_names)
return pa.schema([(name, fields[name]) for name in ordered_names])
def _effective_column_dtype(
self, column: str, runtime_input_schema: Optional[Schema] = None
) -> Optional[DataType]:
"""Return the dtype for a column in the input schema.
This method first checks the input schema provided to the GPUAggregationPlan
constructor, and then falls back to the runtime input schema if provided.
"""
dtype = _schema_column_dtype(self._input_schema, column)
if dtype is not None:
return dtype
return _schema_column_dtype(runtime_input_schema, column)
def _effective_input_schema(
self, runtime_input_schema: Optional[Schema] = None
) -> Optional[Schema]:
"""Return the effective input schema for the GPU aggregation plan.
This method supplies a fallback schema derived from the input schema,
if the runtime input schema is not provided.
"""
return (
runtime_input_schema
if runtime_input_schema is not None
else self._input_schema
)
def _final_arrow_types(
self, input_schema: Optional[Schema] = None
) -> Dict[str, pa.DataType]:
"""Return the Arrow types for the final output columns of the GPU aggregation plan."""
input_schema = self._effective_input_schema(input_schema)
types: Dict[str, pa.DataType] = {}
for agg, output_name in zip(self._gpu_aggregates, self._output_names):
types.update(
agg._final_arrow_types(
output_name,
input_schema=input_schema,
)
)
return types
def _final_cudf_dtypes(
self,
df: cudf.DataFrame,
input_schema: Optional[Schema] = None,
) -> Dict[str, Optional[DataType]]:
"""Return the cuDF dtypes for the final output columns of the GPU aggregation plan.
This provides a fallback schema derived from the supplied input schema and runtime input
schema,
"""
input_schema = self._effective_input_schema(input_schema)
dtypes: Dict[str, Optional[DataType]] = {}
if not self._is_global:
for column in self._shuffle_key_columns:
dtype = self._effective_column_dtype(column, input_schema)
if dtype is None:
dtype = _cudf_column_dtype(df, column)
elif dtype.is_null_type():
dtype = DataType.from_numpy("float64")
dtypes[column] = dtype
for agg, output_name, accumulator_prefix in zip(
self._gpu_aggregates, self._output_names, self._accumulator_prefixes
):
dtypes.update(
{
column: DataType.from_dtype(dtype)
for column, dtype in agg._final_cudf_dtypes(
df,
output_name,
accumulator_prefix,
input_schema=input_schema,
).items()
}
)
return dtypes
def _partial_accumulator_dtypes(
self,
df: cudf.DataFrame,
key_columns: Tuple[str, ...],
input_schema: Optional[Schema] = None,
) -> Dict[str, Optional[DataType]]:
dtypes: Dict[str, Optional[DataType]] = {}
for column in key_columns:
if column not in df.columns:
continue
dtype = self._effective_column_dtype(column, input_schema)
if dtype is None:
dtype = _cudf_column_dtype(df, column)
elif dtype.is_null_type():
dtype = DataType.from_numpy("float64")
dtypes[column] = dtype
for agg, accumulator_prefix in zip(
self._gpu_aggregates, self._accumulator_prefixes
):
dtypes.update(
{
column: DataType.from_dtype(dtype) if dtype is not None else None
for column, dtype in agg._partial_accumulator_dtypes(
df,
accumulator_prefix,
input_schema=input_schema,
).items()
}
)
return dtypes
def build_gpu_aggregation_plan(
key_columns: Tuple[str, ...],
aggregation_fns: Tuple[Union[AggregateFn, GPUAggregateFn], ...],
input_schema: Optional[Schema] = None,
) -> Union[GPUAggregationPlan, str]:
"""Build a GPU aggregation plan.
Args:
key_columns: The key columns to group by.
aggregation_fns: The aggregation functions to apply.
input_schema: The schema of the input data.
Returns:
A GPU aggregation plan if supported, otherwise a fallback reason string.
"""
if not aggregation_fns:
# No aggregation functions, no plan needed.
return "no aggregation functions were provided."
has_gpu_aggregate = any(isinstance(agg, GPUAggregateFn) for agg in aggregation_fns)
missing_key_columns = [
column
for column in key_columns
if _schema_column_dtype(input_schema, column) is None
]
if missing_key_columns and not has_gpu_aggregate:
# Missing key columns in the input schema, fallback to CPU.
return (
"missing input schema for key column(s): "
f"{', '.join(missing_key_columns)}."
)
gpu_aggregates: List[GPUAggregateFn] = []
for agg in aggregation_fns:
if isinstance(agg, GPUAggregateFn):
# handle subclasses of GPUAggregateFn as-is (e.g. custom GPU aggregations)
gpu_aggregate = agg
else:
# try to convert built-in GPU aggregation functions to GPU equivalents
if not isinstance(agg, AggregateFnV2):
return (
f"{type(agg).__name__} is not supported by GPU aggregation "
"because it is not an AggregateFnV2."
)
target_column = agg.get_target_column()
source_dtype = _schema_column_dtype(input_schema, target_column)
if isinstance(agg, Count):
gpu_aggregate = GPUCount(agg, source_dtype=source_dtype)
elif target_column is None:
return (
f"{type(agg).__name__} is not supported by GPU aggregation "
"without a target column."
)
elif isinstance(agg, Sum):
gpu_aggregate = GPUSum(agg, source_dtype=source_dtype)
elif isinstance(agg, Min):
gpu_aggregate = GPUMin(agg, source_dtype=source_dtype)
elif isinstance(agg, Max):
gpu_aggregate = GPUMax(agg, source_dtype=source_dtype)
elif isinstance(agg, Mean):
gpu_aggregate = GPUMean(agg, source_dtype=source_dtype)
else:
# Any unsupported built-in aggregation in the list falls back
# the entire list to CPU.
return f"{type(agg).__name__} is not supported by GPU aggregation."
gpu_aggregates.append(gpu_aggregate)
return GPUAggregationPlan(
key_columns,
tuple(gpu_aggregates),
accumulator_prefix="__ray_gpu_agg",
input_schema=input_schema,
)
@ray.remote(num_gpus=1)
class GPUHashAggregateActor:
"""One GPU rank for hash shuffle plus aggregate."""
def __init__(
self,
nranks: int,
total_nparts: int,
aggregation_plan: GPUAggregationPlan,
rmm_pool_size: Optional[int | str] = None,
spill_memory_limit: Optional[int | str] = "auto",
) -> None:
from ray.data._internal.gpu_shuffle.rapidsmpf_backend import (
BulkRapidsMPFShuffler,
)
self._aggregation_plan = aggregation_plan
self._shuffler = BulkRapidsMPFShuffler(
nranks=nranks,
total_nparts=total_nparts,
shuffle_on=list(aggregation_plan.shuffle_key_columns),
rmm_pool_size=rmm_pool_size,
spill_memory_limit=spill_memory_limit,
)
self._shuffle_columns: Optional[List[str]] = None
self._runtime_input_schema: Optional[pa.Schema] = (
aggregation_plan._input_schema
if isinstance(aggregation_plan._input_schema, pa.Schema)
else None
)
def setup_root(self) -> Tuple[int, bytes]:
logger.info("UCXX setup_root starting on GPU hash aggregate rank 0.")
t0 = time.perf_counter()
result = self._shuffler.setup_root()
elapsed = time.perf_counter() - t0
logger.info(
"UCXX setup_root completed in %.2fs for GPU hash aggregate rank %d.",
elapsed,
result[0],
)
return result
def setup_worker(self, root_address: bytes) -> None:
logger.info(
"UCXX setup_worker starting for GPU hash aggregate "
"(root_address=%d bytes).",
len(root_address),
)
t0 = time.perf_counter()
self._shuffler.setup_worker(root_address)
elapsed = time.perf_counter() - t0
logger.info("UCXX setup_worker completed in %.2fs.", elapsed)
def insert_batch(self, block: Block) -> int:
import cudf
table = BlockAccessor.for_block(block).to_arrow()
required_columns = self._aggregation_plan.required_columns
if required_columns:
projected_table = table.select(list(required_columns))
df = cudf.DataFrame.from_arrow(projected_table)
else:
df = cudf.DataFrame(index=range(table.num_rows))
self._runtime_input_schema = self._aggregation_plan.merge_input_schema(
self._runtime_input_schema,
table.schema,
)
partial = self._aggregation_plan.partial_aggregate(
df,
input_schema=self._runtime_input_schema,
)
if self._shuffle_columns is None:
self._shuffle_columns = list(partial.columns)
self._shuffler.insert_chunk(table=partial, column_names=self._shuffle_columns)
return table.num_rows
def finish_and_extract(self) -> Iterator[pa.Table | bytes]:
self._shuffler.insert_finished()
import cudf
from rapidsmpf.utils.cudf import pylibcudf_to_cudf_dataframe
self._shuffle_columns = self._shuffle_columns or list(
self._aggregation_plan.shuffle_key_columns
+ self._aggregation_plan.accumulator_columns
)
for partition_id, partition in self._shuffler.extract():
exec_stats_builder = BlockExecStats.builder()
if partition.num_columns() == 0:
cdf = cudf.DataFrame()
else:
cdf = pylibcudf_to_cudf_dataframe(
partition, column_names=self._shuffle_columns
).copy(deep=True)
output_df = self._aggregation_plan.final_aggregate(
cdf,
input_schema=self._runtime_input_schema,
)
block = output_df.to_arrow(preserve_index=False)
block = self._aggregation_plan.normalize_output_arrow(
block, input_schema=self._runtime_input_schema
)
existing_metadata = block.schema.metadata or {}
tagged_schema = block.schema.with_metadata(
{**existing_metadata, _GPU_PARTITION_ID_KEY: str(partition_id).encode()}
)
exec_stats = exec_stats_builder.build()
stats = yield block
if stats:
object.__setattr__(
exec_stats, "block_ser_time_s", stats.object_creation_dur_s
)
block_meta = BlockMetadataWithSchema.from_block(
block, block_exec_stats=exec_stats
)
bm = BlockMetadataWithSchema.from_metadata(
block_meta.metadata, schema=tagged_schema
)
yield pickle.dumps(bm)
class GPUHashAggregateOperator(GPUShuffleOperator):
"""GPU-native hash aggregate using RAPIDS MPF for the shuffle stage."""
def __init__(
self,
data_context: DataContext,
input_op: PhysicalOperator,
key_columns: Tuple[str, ...],
aggregation_plan: GPUAggregationPlan,
*,
num_partitions: Optional[int] = None,
) -> None:
if aggregation_plan is None:
raise ValueError(
"GPUHashAggregateOperator received unsupported aggregations."
)
nranks = _derive_num_gpu_ranks(data_context)
if len(key_columns) == 0:
# global aggregation
target_num_partitions = 1
elif num_partitions is not None:
# user-specified number of partitions
target_num_partitions = num_partitions
else:
# estimate number of partitions from input operator, otherwise use default
input_logical_op = input_op._logical_operators[0]
target_num_partitions = (
input_logical_op.estimated_num_outputs()
or data_context.default_hash_shuffle_parallelism
)
# rapidsmpf requires total_nparts >= nranks
target_num_partitions = max(target_num_partitions, nranks)
rank_pool = GPURankPool(
nranks=nranks,
total_nparts=target_num_partitions,
setup_timeout_s=data_context.gpu_shuffle_setup_timeout_s,
actor_cls_factory=lambda: GPUHashAggregateActor,
actor_kwargs={
"aggregation_plan": aggregation_plan,
"rmm_pool_size": data_context.gpu_shuffle_rmm_pool_size,
"spill_memory_limit": data_context.gpu_shuffle_spill_memory_limit,
},
log_label="GPUHashAggregatePool",
label_selector=data_context.execution_options.label_selector,
)
super().__init__(
input_op,
data_context,
key_columns=aggregation_plan.shuffle_key_columns,
columns=None,
num_partitions=target_num_partitions,
should_sort=False,
name=(
f"GPUHashAggregate(key_columns={key_columns}, "
f"num_partitions={target_num_partitions})"
),
nranks=nranks,
rank_pool=rank_pool,
)
self._aggregation_plan = aggregation_plan
def get_sub_progress_bar_names(self) -> List[str]:
return ["GPU Shuffle", "GPU Aggregation"]
def set_sub_progress_bar(self, name: str, pg: "BaseProgressBar") -> None:
if name == "GPU Shuffle":
self._shuffle_bar = pg
elif name == "GPU Aggregation":
self._reduce_bar = pg
def get_stats(self) -> Dict[str, List[BlockStats]]:
shuffle_name = f"{self._name}_shuffle"
aggregate_name = f"{self._name}_aggregate"
return {
shuffle_name: self._shuffled_blocks_stats,
aggregate_name: self._output_blocks_stats,
}