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

563 lines
21 KiB
Python

import logging
import math
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Set, Tuple, Type
from ray.data._internal.arrow_block import ArrowBlockAccessor
from ray.data._internal.arrow_ops.transform_pyarrow import (
MIN_PYARROW_VERSION_RUN_END_ENCODED_TYPES,
MIN_PYARROW_VERSION_VIEW_TYPES,
)
from ray.data._internal.execution.interfaces import PhysicalOperator
from ray.data._internal.execution.operators.hash_shuffle import (
HashShufflingOperatorBase,
ShuffleAggregation,
_combine,
)
from ray.data._internal.execution.operators.shuffle_operators.shuffle_tasks import (
ReduceFn,
)
from ray.data._internal.logical.operators import JoinType
from ray.data._internal.util import GiB, MiB
from ray.data._internal.utils.arrow_utils import get_pyarrow_version
from ray.data._internal.utils.transform_pyarrow import _is_pa_extension_type
from ray.data.block import Block
from ray.data.context import DataContext
if TYPE_CHECKING:
import pyarrow as pa
@dataclass(frozen=True)
class _DatasetPreprocessingResult:
"""Result of join preprocessing containing split tables.
Separates tables into supported (directly joinable) and unsupported
(requires indexing) column projections.
"""
supported_projection: "pa.Table"
unsupported_projection: "pa.Table"
_JOIN_TYPE_TO_ARROW_JOIN_VERB_MAP = {
JoinType.INNER: "inner",
JoinType.LEFT_OUTER: "left outer",
JoinType.RIGHT_OUTER: "right outer",
JoinType.FULL_OUTER: "full outer",
JoinType.LEFT_SEMI: "left semi",
JoinType.RIGHT_SEMI: "right semi",
JoinType.LEFT_ANTI: "left anti",
JoinType.RIGHT_ANTI: "right anti",
}
logger = logging.getLogger(__name__)
class JoiningAggregation(ShuffleAggregation):
"""Stateless aggregation for distributed joining of 2 sequences.
This implementation performs hash-based distributed joining by:
- Accumulating identical keys from both sequences into the same partition
- Performing join on individual partitions independently
For actual joining, Pyarrow native joining functionality is utilised.
"""
def __init__(
self,
*,
join_type: JoinType,
left_key_col_names: Tuple[str, ...],
right_key_col_names: Tuple[str, ...],
left_columns_suffix: Optional[str] = None,
right_columns_suffix: Optional[str] = None,
data_context: DataContext,
):
assert (
len(left_key_col_names) > 0
), "At least 1 column to join on has to be provided"
assert len(right_key_col_names) == len(
left_key_col_names
), "Number of columns for both left and right join operands has to match"
assert join_type in _JOIN_TYPE_TO_ARROW_JOIN_VERB_MAP, (
f"Join type is not currently supported (got: {join_type}; " # noqa: C416
f"supported: {[jt for jt in JoinType]})" # noqa: C416
)
self._left_key_col_names: Tuple[str, ...] = left_key_col_names
self._right_key_col_names: Tuple[str, ...] = right_key_col_names
self._join_type: JoinType = join_type
self._left_columns_suffix: Optional[str] = left_columns_suffix
self._right_columns_suffix: Optional[str] = right_columns_suffix
def finalize(self, partition_shards_map: Dict[int, List[Block]]) -> Iterator[Block]:
"""Performs join on blocks from left (seq 0) and right (seq 1) sequences."""
assert (
len(partition_shards_map) == 2
), f"Two input-sequences are expected (got {len(partition_shards_map)})"
left_partition_shards = partition_shards_map[0]
right_partition_shards = partition_shards_map[1]
left_table = _combine(left_partition_shards)
right_table = _combine(right_partition_shards)
yield join_tables(
left_table,
right_table,
join_type=self._join_type,
left_key_col_names=self._left_key_col_names,
right_key_col_names=self._right_key_col_names,
left_columns_suffix=self._left_columns_suffix,
right_columns_suffix=self._right_columns_suffix,
)
def _make_join_reduce_fn(
*,
join_type: JoinType,
left_key_col_names: Tuple[str, ...],
right_key_col_names: Tuple[str, ...],
left_columns_suffix: Optional[str] = None,
right_columns_suffix: Optional[str] = None,
left_schema: Optional[Any] = None,
right_schema: Optional[Any] = None,
) -> ReduceFn:
"""Build a V2-shuffle reduce fn that joins two co-partitioned inputs."""
import pyarrow as pa
def _side_table(tables: List[Block], schema: Optional[Any]) -> Optional["pa.Table"]:
if tables:
return _combine(tables)
if isinstance(schema, pa.Schema):
return schema.empty_table()
return None
def _reduce(
partition_id: int, tables_by_input: List[List[Block]]
) -> Iterator[Block]:
assert (
len(tables_by_input) == 2
), f"Join reduce expects two inputs (got {len(tables_by_input)})"
left_table = _side_table(tables_by_input[0], left_schema)
right_table = _side_table(tables_by_input[1], right_schema)
if left_table is None or right_table is None:
# TODO(you-cheng): A whole input side is empty AND its schema can't be inferred
# (0 blocks + un-inferable schema, e.g. a map_batches side), so
# _side_table returns None and we skip the partition. This silently
# drops the preserved side's rows for preserving joins, left_outer/
# full_outer and left_anti/right_anti.
return
yield join_tables(
left_table,
right_table,
join_type=join_type,
left_key_col_names=left_key_col_names,
right_key_col_names=right_key_col_names,
left_columns_suffix=left_columns_suffix,
right_columns_suffix=right_columns_suffix,
)
return _reduce
def join_tables(
left_table: "pa.Table",
right_table: "pa.Table",
*,
join_type: JoinType,
left_key_col_names: Tuple[str, ...],
right_key_col_names: Tuple[str, ...],
left_columns_suffix: Optional[str] = None,
right_columns_suffix: Optional[str] = None,
) -> "pa.Table":
"""Apply preprocess -> ``pa.Table.join`` -> postprocess to two input tables.
Shared between the physical executor (``JoiningAggregation.finalize``)
and plan-time schema inference (``Join.infer_schema``), which calls
this with empty tables built from the input schemas. Plan-time and
runtime schemas therefore agree by construction.
"""
left_on = list(left_key_col_names)
right_on = list(right_key_col_names)
# Eagerly validate suffix conflicts so callers get a clear error instead
# of the opaque PyArrow schema-merge error ('Field X exists 2 times').
# Skip for semi/anti joins: only one side's columns appear in the result,
# so overlapping non-key names between left and right are harmless.
if join_type not in (
JoinType.LEFT_SEMI,
JoinType.LEFT_ANTI,
JoinType.RIGHT_SEMI,
JoinType.RIGHT_ANTI,
):
left_cols = set(left_table.schema.names)
# PyArrow drops right key columns from output (coalescing them into
# the left keys), so only right non-key columns can collide with
# left columns. Subtracting only right_on (not left_on) correctly
# handles asymmetric key names (left_on != right_on).
right_output_cols = set(right_table.schema.names) - set(right_on)
collisions = left_cols & right_output_cols
if left_columns_suffix is None and right_columns_suffix is None and collisions:
raise ValueError(
"Left and right columns suffixes cannot be both None "
f"(overlapping columns: {sorted(collisions)})"
)
# Preprocess: split unsupported columns and add index columns if needed
preprocess_result_l, preprocess_result_r = _preprocess(
left_table, right_table, left_on, right_on, join_type
)
# Perform the join on supported columns
arrow_join_type = _JOIN_TYPE_TO_ARROW_JOIN_VERB_MAP[join_type]
supported = preprocess_result_l.supported_projection.join(
preprocess_result_r.supported_projection,
join_type=arrow_join_type,
keys=left_on,
right_keys=right_on,
left_suffix=left_columns_suffix,
right_suffix=right_columns_suffix,
)
# Add back unsupported columns
return _postprocess(
supported,
preprocess_result_l.unsupported_projection,
preprocess_result_r.unsupported_projection,
)
def _preprocess(
left_table: "pa.Table",
right_table: "pa.Table",
left_on: List[str],
right_on: List[str],
join_type: JoinType,
) -> Tuple[_DatasetPreprocessingResult, _DatasetPreprocessingResult]:
"""Split inputs into supported/unsupported columns and add indices."""
supported_l, unsupported_l = _split_unsupported_columns(left_table)
supported_r, unsupported_r = _split_unsupported_columns(right_table)
# Handle joins on unsupported columns
conflicting_columns: Set[str] = set(unsupported_l.column_names) & set(left_on)
if conflicting_columns:
raise ValueError(
f"Cannot join on columns with unjoinable types. "
f"Left join key columns {conflicting_columns} have unjoinable types "
f"(map, union, list, struct, etc.) which cannot be used for join operations."
)
conflicting_columns: Set[str] = set(unsupported_r.column_names) & set(right_on)
if conflicting_columns:
raise ValueError(
f"Cannot join on columns with unjoinable types. "
f"Right join key columns {conflicting_columns} have unjoinable types "
f"(map, union, list, struct, etc.) which cannot be used for join operations."
)
# Index if we have unsupported columns
should_index_l = _should_index_side("left", supported_l, unsupported_l, join_type)
should_index_r = _should_index_side("right", supported_r, unsupported_r, join_type)
# Add index columns for back-referencing if we have unsupported columns
if should_index_l:
supported_l = _append_index_column(
table=supported_l, col_name=_index_name("left")
)
if should_index_r:
supported_r = _append_index_column(
table=supported_r, col_name=_index_name("right")
)
left = _DatasetPreprocessingResult(
supported_projection=supported_l,
unsupported_projection=unsupported_l,
)
right = _DatasetPreprocessingResult(
supported_projection=supported_r,
unsupported_projection=unsupported_r,
)
return left, right
def _postprocess(
supported: "pa.Table",
unsupported_l: "pa.Table",
unsupported_r: "pa.Table",
) -> "pa.Table":
"""Re-attach unsupported columns to the joined table via the index column."""
should_index_l = _index_name("left") in supported.schema.names
should_index_r = _index_name("right") in supported.schema.names
# Add back unsupported columns (join type logic is in should_index_* variables)
if should_index_l:
supported = _add_back_unsupported_columns(
joined_table=supported,
unsupported_table=unsupported_l,
index_col_name=_index_name("left"),
)
if should_index_r:
supported = _add_back_unsupported_columns(
joined_table=supported,
unsupported_table=unsupported_r,
index_col_name=_index_name("right"),
)
return supported
def _index_name(suffix: str) -> str:
return f"__rd_index_level_{suffix}__"
def _should_index_side(
side: str,
supported_table: "pa.Table",
unsupported_table: "pa.Table",
join_type: JoinType,
) -> bool:
"""
Determine whether to create an index column for a given side of the join.
A column is "supported" if it is "joinable", and "unsupported" otherwise.
A supported_table is a table with only "supported" columns. Index columns are
needed when we have both supported and unsupported columns in a table, and
that table's columns will appear in the final result.
Args:
side: "left" or "right" to indicate which side of the join
supported_table: Table containing ONLY joinable columns
unsupported_table: Table containing ONLY unjoinable columns
join_type: The join type, used to decide whether this side appears in
the result (semi/anti joins drop one side).
Returns:
True if an index column should be created for this side
"""
# Must have both supported and unsupported columns to need indexing.
# We cannot rely on row_count because it can return a non-zero row count
# for an empty-schema.
if len(supported_table.schema) == 0 or len(unsupported_table.schema) == 0:
return False
# For semi/anti joins, only index the side that appears in the result
if side == "left":
# Left side appears in result for all joins except right_semi/right_anti
return join_type not in [JoinType.RIGHT_SEMI, JoinType.RIGHT_ANTI]
else: # side == "right"
# Right side appears in result for all joins except left_semi/left_anti
return join_type not in [JoinType.LEFT_SEMI, JoinType.LEFT_ANTI]
def _split_unsupported_columns(
table: "pa.Table",
) -> Tuple["pa.Table", "pa.Table"]:
"""
Split a PyArrow table into two tables based on column joinability.
Separates columns into supported types and unsupported types that cannot be
directly joined on but should be preserved in results.
Args:
table: Input PyArrow table to split
Returns:
Tuple of (supported_table, unsupported_table) where:
- supported_table contains columns with primitive/joinable types
- unsupported_table contains columns with complex/unjoinable types
"""
supported, unsupported = [], []
for idx in range(len(table.columns)):
col: "pa.ChunkedArray" = table.column(idx)
col_type: "pa.DataType" = col.type
if _is_pa_extension_type(col_type) or JoinOperator._is_pa_join_not_supported(
col_type
):
unsupported.append(idx)
else:
supported.append(idx)
return table.select(supported), table.select(unsupported)
def _add_back_unsupported_columns(
joined_table: "pa.Table",
unsupported_table: "pa.Table",
index_col_name: str,
) -> "pa.Table":
# Extract the index column array and drop the column from the joined table
i = joined_table.schema.get_field_index(index_col_name)
indices = joined_table.column(i)
joined_table = joined_table.remove_column(i)
# Project the unsupported columns using the indices and combine with joined table
projected = ArrowBlockAccessor(unsupported_table).take(indices)
return ArrowBlockAccessor(joined_table).hstack(projected)
def _append_index_column(table: "pa.Table", col_name: str) -> "pa.Table":
import numpy as np
import pyarrow as pa
index_col = pa.array(np.arange(table.num_rows))
return table.append_column(col_name, index_col)
class JoinOperator(HashShufflingOperatorBase):
def __init__(
self,
data_context: DataContext,
left_input_op: PhysicalOperator,
right_input_op: PhysicalOperator,
left_key_columns: Tuple[str],
right_key_columns: Tuple[str],
join_type: JoinType,
*,
num_partitions: Optional[int] = None,
left_columns_suffix: Optional[str] = None,
right_columns_suffix: Optional[str] = None,
partition_size_hint: Optional[int] = None,
aggregator_ray_remote_args_override: Optional[Dict[str, Any]] = None,
shuffle_aggregation_type: Optional[Type[ShuffleAggregation]] = None,
):
# Use new stateless JoiningAggregation factory
def _create_joining_aggregation() -> JoiningAggregation:
if shuffle_aggregation_type is not None:
if not issubclass(shuffle_aggregation_type, ShuffleAggregation):
raise TypeError(
f"shuffle_aggregation_type must be a subclass of {ShuffleAggregation}, "
f"got {shuffle_aggregation_type}"
)
aggregation_class = shuffle_aggregation_type or JoiningAggregation
return aggregation_class(
join_type=join_type,
left_key_col_names=left_key_columns,
right_key_col_names=right_key_columns,
left_columns_suffix=left_columns_suffix,
right_columns_suffix=right_columns_suffix,
data_context=data_context,
)
super().__init__(
name_factory=(
lambda num_partitions: f"Join(num_partitions={num_partitions})"
),
input_ops=[left_input_op, right_input_op],
data_context=data_context,
key_columns=[left_key_columns, right_key_columns],
num_input_seqs=2,
num_partitions=num_partitions,
partition_size_hint=partition_size_hint,
partition_aggregation_factory=_create_joining_aggregation,
aggregator_ray_remote_args_override=aggregator_ray_remote_args_override,
shuffle_progress_bar_name="Shuffle",
finalize_progress_bar_name="Join",
)
@staticmethod
def _is_pa_join_not_supported(dtype: "pa.DataType") -> bool:
"""
The latest pyarrow versions do not support joins where the
tables contain the following types below (lists,
structs, maps, unions, extension types, etc.)
Args:
dtype: The input type of column.
Returns:
True if the type cannot be present (non join-key) during joins.
False if the type can be present.
"""
import pyarrow as pa
pyarrow_version = get_pyarrow_version()
is_v12 = pyarrow_version >= MIN_PYARROW_VERSION_RUN_END_ENCODED_TYPES
is_v16 = pyarrow_version >= MIN_PYARROW_VERSION_VIEW_TYPES
return (
pa.types.is_map(dtype)
or pa.types.is_union(dtype)
or pa.types.is_list(dtype)
or pa.types.is_struct(dtype)
or pa.types.is_null(dtype)
or pa.types.is_large_list(dtype)
or pa.types.is_fixed_size_list(dtype)
or (is_v12 and pa.types.is_run_end_encoded(dtype))
or (
is_v16
and (
pa.types.is_binary_view(dtype)
or pa.types.is_string_view(dtype)
or pa.types.is_list_view(dtype)
)
)
)
def _get_operator_num_cpus_override(self) -> float:
return self.data_context.join_operator_actor_num_cpus_override
@classmethod
def _estimate_aggregator_memory_allocation(
cls,
*,
num_aggregators: int,
num_partitions: int,
estimated_dataset_bytes: int,
) -> int:
partition_byte_size_estimate = math.ceil(
estimated_dataset_bytes / num_partitions
)
# Estimate of object store memory required to accommodate all partitions
# handled by a single aggregator
aggregator_shuffle_object_store_memory_required: int = math.ceil(
estimated_dataset_bytes / num_aggregators
)
# Estimate of memory required to perform actual (in-memory) join
# operation (inclusive of 50% overhead allocated for Pyarrow join
# implementation)
#
# NOTE:
# - 2x due to budgeted 100% overhead of Arrow's in-memory join
join_memory_required: int = math.ceil(partition_byte_size_estimate * 2)
# Estimate of memory required to accommodate single partition as an output
# (inside Object Store)
#
# NOTE: x2 due to 2 sequences involved in joins
output_object_store_memory_required: int = partition_byte_size_estimate
aggregator_total_memory_required: int = (
# Inputs (object store)
aggregator_shuffle_object_store_memory_required
+
# Join (heap)
join_memory_required
+
# Output (object store)
output_object_store_memory_required
)
logger.info(
f"Estimated memory requirement for joining aggregator "
f"(partitions={num_partitions}, "
f"aggregators={num_aggregators}, "
f"dataset (estimate)={estimated_dataset_bytes / GiB:.1f}GiB): "
f"shuffle={aggregator_shuffle_object_store_memory_required / MiB:.1f}MiB, "
f"joining={join_memory_required / MiB:.1f}MiB, "
f"output={output_object_store_memory_required / MiB:.1f}MiB, "
f"total={aggregator_total_memory_required / MiB:.1f}MiB, "
)
return aggregator_total_memory_required