591 lines
21 KiB
Python
591 lines
21 KiB
Python
import collections
|
|
import heapq
|
|
from typing import (
|
|
TYPE_CHECKING,
|
|
Any,
|
|
Dict,
|
|
Iterator,
|
|
List,
|
|
Mapping,
|
|
Optional,
|
|
Sequence,
|
|
Tuple,
|
|
TypeVar,
|
|
Union,
|
|
)
|
|
|
|
from ray._common.utils import env_integer
|
|
from ray.data._internal.block_builder import BlockBuilder
|
|
from ray.data._internal.size_estimator import SizeEstimator
|
|
from ray.data._internal.util import (
|
|
NULL_SENTINEL,
|
|
find_partition_index,
|
|
is_nan,
|
|
keys_equal,
|
|
)
|
|
from ray.data.block import (
|
|
Block,
|
|
BlockAccessor,
|
|
BlockColumnAccessor,
|
|
BlockExecStats,
|
|
BlockMetadataWithSchema,
|
|
BlockType,
|
|
KeyType,
|
|
U,
|
|
)
|
|
from ray.data.context import DEFAULT_TARGET_MAX_BLOCK_SIZE
|
|
|
|
if TYPE_CHECKING:
|
|
from ray.data._internal.planner.exchange.sort_task_spec import SortKey
|
|
from ray.data.aggregate import AggregateFn
|
|
|
|
T = TypeVar("T")
|
|
|
|
# The max size of Python tuples to buffer before compacting them into a
|
|
# table in the BlockBuilder.
|
|
MAX_UNCOMPACTED_SIZE_BYTES = env_integer(
|
|
"RAY_DATA_MAX_UNCOMPACTED_SIZE_BYTES", DEFAULT_TARGET_MAX_BLOCK_SIZE
|
|
)
|
|
|
|
|
|
class TableBlockBuilder(BlockBuilder):
|
|
def __init__(self, block_type):
|
|
# The set of uncompacted Python values buffered.
|
|
self._columns = collections.defaultdict(list)
|
|
# The set of compacted tables we have built so far.
|
|
self._tables: List[Any] = []
|
|
# Cursor into tables indicating up to which table we've accumulated table sizes.
|
|
# This is used to defer table size calculation, which can be expensive for e.g.
|
|
# Pandas DataFrames.
|
|
# This cursor points to the first table for which we haven't accumulated a table
|
|
# size.
|
|
self._tables_size_cursor = 0
|
|
# Accumulated table sizes, up to the table in _tables pointed to by
|
|
# _tables_size_cursor.
|
|
self._tables_size_bytes = 0
|
|
# Size estimator for un-compacted table values.
|
|
self._uncompacted_size = SizeEstimator()
|
|
self._num_rows = 0
|
|
self._num_uncompacted_rows = 0
|
|
self._num_compactions = 0
|
|
self._block_type = block_type
|
|
|
|
def add(self, item: Union[dict, Mapping]) -> None:
|
|
if hasattr(item, "as_pydict"):
|
|
item = item.as_pydict()
|
|
if not isinstance(item, collections.abc.Mapping):
|
|
raise ValueError(
|
|
"Returned elements of an TableBlock must be of type `dict`, "
|
|
"got {} (type {}).".format(item, type(item))
|
|
)
|
|
|
|
# Fill in missing columns with None.
|
|
for column_name in item:
|
|
if column_name not in self._columns:
|
|
self._columns[column_name] = [None] * self._num_uncompacted_rows
|
|
|
|
for column_name in self._columns:
|
|
value = item.get(column_name)
|
|
self._columns[column_name].append(value)
|
|
|
|
self._num_rows += 1
|
|
self._num_uncompacted_rows += 1
|
|
self._compact_if_needed()
|
|
self._uncompacted_size.add(item)
|
|
|
|
def add_block(self, block: Any) -> None:
|
|
if not isinstance(block, self._block_type):
|
|
raise TypeError(
|
|
f"Got a block of type {type(block)}, expected {self._block_type}."
|
|
"If you are mapping a function, ensure it returns an "
|
|
"object with the expected type. Block:\n"
|
|
f"{block}"
|
|
)
|
|
accessor = BlockAccessor.for_block(block)
|
|
self._tables.append(block)
|
|
self._num_rows += accessor.num_rows()
|
|
|
|
@staticmethod
|
|
def _table_from_pydict(columns: Dict[str, List[Any]]) -> Block:
|
|
raise NotImplementedError
|
|
|
|
@staticmethod
|
|
def _combine_tables(tables: List[Block]) -> Block:
|
|
raise NotImplementedError
|
|
|
|
@staticmethod
|
|
def _empty_table() -> Any:
|
|
raise NotImplementedError
|
|
|
|
@staticmethod
|
|
def _concat_would_copy() -> bool:
|
|
raise NotImplementedError
|
|
|
|
def will_build_yield_copy(self) -> bool:
|
|
if self._columns:
|
|
# Building a table from a dict of list columns always creates a copy.
|
|
return True
|
|
return self._concat_would_copy() and len(self._tables) > 1
|
|
|
|
def build(self) -> Block:
|
|
# Preserve insertion order: previously-compacted tables (older) first,
|
|
# then any rows added since the last compaction (newest) last.
|
|
tables = list(self._tables)
|
|
if self._columns:
|
|
tables.append(self._table_from_pydict(self._columns))
|
|
|
|
if len(tables) == 0:
|
|
return self._empty_table()
|
|
else:
|
|
return self._combine_tables(tables)
|
|
|
|
def num_rows(self) -> int:
|
|
return self._num_rows
|
|
|
|
def num_blocks(self) -> int:
|
|
return len(self._tables)
|
|
|
|
def get_estimated_memory_usage(self) -> int:
|
|
if self._num_rows == 0:
|
|
return 0
|
|
for table in self._tables[self._tables_size_cursor :]:
|
|
self._tables_size_bytes += BlockAccessor.for_block(table).size_bytes()
|
|
self._tables_size_cursor = len(self._tables)
|
|
return self._tables_size_bytes + self._uncompacted_size.size_bytes()
|
|
|
|
def _compact_if_needed(self) -> None:
|
|
assert self._columns
|
|
if self._uncompacted_size.size_bytes() < MAX_UNCOMPACTED_SIZE_BYTES:
|
|
return
|
|
block = self._table_from_pydict(self._columns)
|
|
self.add_block(block)
|
|
self._uncompacted_size = SizeEstimator()
|
|
self._columns.clear()
|
|
self._num_compactions += 1
|
|
self._num_uncompacted_rows = 0
|
|
|
|
|
|
class TableBlockAccessor(BlockAccessor):
|
|
def __init__(self, table: Any):
|
|
self._table = table
|
|
|
|
@staticmethod
|
|
def _munge_conflict(name, count):
|
|
return f"{name}_{count + 1}"
|
|
|
|
def to_default(self) -> Block:
|
|
# Always promote Arrow blocks to pandas for consistency, since
|
|
# we lazily convert pandas->Arrow internally for efficiency.
|
|
default = self.to_pandas()
|
|
|
|
return default
|
|
|
|
def to_cudf(self) -> Any:
|
|
"""Convert this block to a cudf.DataFrame (requires cudf to be installed)."""
|
|
from ray.data.util.data_batch_conversion import _lazy_import_cudf
|
|
|
|
cudf = _lazy_import_cudf()
|
|
if cudf is None:
|
|
raise ValueError(
|
|
"Attempted to convert data to cuDF DataFrame but cuDF "
|
|
"is not installed. Please do `pip install cudf-cu12` to "
|
|
"install cuDF (GPU required)."
|
|
)
|
|
|
|
return cudf.DataFrame.from_arrow(self.to_arrow())
|
|
|
|
def column_names(self) -> List[str]:
|
|
raise NotImplementedError
|
|
|
|
def fill_column(self, name: str, value: Any) -> Block:
|
|
raise NotImplementedError
|
|
|
|
def to_block(self) -> Block:
|
|
return self._table
|
|
|
|
def _zip(self, acc: BlockAccessor) -> "Block":
|
|
raise NotImplementedError
|
|
|
|
def zip(self, other: "Block") -> "Block":
|
|
acc = BlockAccessor.for_block(other)
|
|
if not isinstance(acc, type(self)):
|
|
if isinstance(self, TableBlockAccessor) and isinstance(
|
|
acc, TableBlockAccessor
|
|
):
|
|
# If block types are different, but still both of TableBlock type, try
|
|
# converting both to default block type before zipping.
|
|
self_norm, other_norm = TableBlockAccessor.normalize_block_types(
|
|
[self._table, other],
|
|
)
|
|
return BlockAccessor.for_block(self_norm).zip(other_norm)
|
|
else:
|
|
raise ValueError(
|
|
"Cannot zip {} with block of type {}".format(
|
|
type(self), type(other)
|
|
)
|
|
)
|
|
if acc.num_rows() != self.num_rows():
|
|
raise ValueError(
|
|
"Cannot zip self (length {}) with block of length {}".format(
|
|
self.num_rows(), acc.num_rows()
|
|
)
|
|
)
|
|
return self._zip(acc)
|
|
|
|
@staticmethod
|
|
def _empty_table() -> Any:
|
|
raise NotImplementedError
|
|
|
|
def _sample(self, n_samples: int, sort_key: "SortKey") -> Any:
|
|
raise NotImplementedError
|
|
|
|
def sample(self, n_samples: int, sort_key: "SortKey") -> Any:
|
|
if sort_key is None or callable(sort_key):
|
|
raise NotImplementedError(
|
|
f"Table sort key must be a column name, was: {sort_key}"
|
|
)
|
|
if self.num_rows() == 0:
|
|
# If the pyarrow table is empty we may not have schema
|
|
# so calling table.select() will raise an error.
|
|
return self._empty_table()
|
|
k = min(n_samples, self.num_rows())
|
|
return self._sample(k, sort_key)
|
|
|
|
def count(self, on: str, ignore_nulls: bool = False) -> Optional[U]:
|
|
accessor = BlockColumnAccessor.for_column(self._table[on])
|
|
return accessor.count(ignore_nulls=ignore_nulls)
|
|
|
|
def sum(self, on: str, ignore_nulls: bool) -> Optional[U]:
|
|
self._validate_column(on)
|
|
|
|
accessor = BlockColumnAccessor.for_column(self._table[on])
|
|
return accessor.sum(ignore_nulls=ignore_nulls)
|
|
|
|
def min(self, on: str, ignore_nulls: bool) -> Optional[U]:
|
|
self._validate_column(on)
|
|
|
|
accessor = BlockColumnAccessor.for_column(self._table[on])
|
|
return accessor.min(ignore_nulls=ignore_nulls)
|
|
|
|
def max(self, on: str, ignore_nulls: bool) -> Optional[U]:
|
|
self._validate_column(on)
|
|
|
|
accessor = BlockColumnAccessor.for_column(self._table[on])
|
|
return accessor.max(ignore_nulls=ignore_nulls)
|
|
|
|
def mean(self, on: str, ignore_nulls: bool) -> Optional[U]:
|
|
self._validate_column(on)
|
|
|
|
accessor = BlockColumnAccessor.for_column(self._table[on])
|
|
return accessor.mean(ignore_nulls=ignore_nulls)
|
|
|
|
def sum_of_squared_diffs_from_mean(
|
|
self,
|
|
on: str,
|
|
ignore_nulls: bool,
|
|
mean: Optional[U] = None,
|
|
) -> Optional[U]:
|
|
self._validate_column(on)
|
|
|
|
accessor = BlockColumnAccessor.for_column(self._table[on])
|
|
return accessor.sum_of_squared_diffs_from_mean(ignore_nulls=ignore_nulls)
|
|
|
|
def _validate_column(self, col: str):
|
|
if col is None:
|
|
raise ValueError(f"Provided `on` value has to be non-null (got '{col}')")
|
|
elif col not in self.column_names():
|
|
raise ValueError(
|
|
f"Referencing column '{col}' not present in the schema: {self.schema()}"
|
|
)
|
|
|
|
def _aggregate(self, sort_key: "SortKey", aggs: Tuple["AggregateFn"]) -> Block:
|
|
"""Applies provided aggregations to groups of rows with the same key.
|
|
|
|
This assumes the block is already sorted by key in ascending order.
|
|
|
|
Args:
|
|
sort_key: A column name or list of column names.
|
|
If this is ``None``, place all rows in a single group.
|
|
|
|
aggs: The aggregations to do.
|
|
|
|
Returns:
|
|
A sorted block of [k, v_1, ..., v_n] columns where k is the groupby
|
|
key and v_i is the partially combined accumulator for the ith given
|
|
aggregation.
|
|
If key is None then the k column is omitted.
|
|
"""
|
|
keys: List[str] = sort_key.get_columns()
|
|
|
|
def iter_groups() -> Iterator[Tuple[Sequence[KeyType], Block]]:
|
|
"""Creates an iterator over zero-copy group views."""
|
|
if not keys:
|
|
# Global aggregation consists of a single "group", so we short-circuit.
|
|
yield tuple(), self.to_block()
|
|
return
|
|
|
|
yield from self._iter_groups_sorted(sort_key)
|
|
|
|
builder = self.builder()
|
|
for group_keys, group_view in iter_groups():
|
|
# Aggregate.
|
|
init_vals = group_keys
|
|
if len(group_keys) == 1:
|
|
init_vals = group_keys[0]
|
|
|
|
accumulators = [agg.init(init_vals) for agg in aggs]
|
|
for i in range(len(aggs)):
|
|
accessor = BlockAccessor.for_block(group_view)
|
|
# Skip empty blocks
|
|
if accessor.num_rows() > 0:
|
|
accumulators[i] = aggs[i].accumulate_block(
|
|
accumulators[i], group_view
|
|
)
|
|
|
|
# Build the row.
|
|
row = {}
|
|
if keys:
|
|
for k, gk in zip(keys, group_keys):
|
|
row[k] = gk
|
|
|
|
count = collections.defaultdict(int)
|
|
for agg, accumulator in zip(aggs, accumulators):
|
|
name = agg.name
|
|
# Check for conflicts with existing aggregation name.
|
|
if count[name] > 0:
|
|
name = self._munge_conflict(name, count[name])
|
|
count[name] += 1
|
|
row[name] = accumulator
|
|
|
|
builder.add(row)
|
|
|
|
return builder.build()
|
|
|
|
@classmethod
|
|
def _combine_aggregated_blocks(
|
|
cls,
|
|
blocks: List[Block],
|
|
sort_key: "SortKey",
|
|
aggs: Tuple["AggregateFn"],
|
|
finalize: bool = True,
|
|
) -> Tuple[Block, "BlockMetadataWithSchema"]:
|
|
"""Combine previously aggregated blocks.
|
|
|
|
This assumes blocks are already sorted by key in ascending order,
|
|
so we can do merge sort to get all the rows with the same key.
|
|
|
|
Args:
|
|
blocks: A list of partially combined and sorted blocks.
|
|
sort_key: The column name of key or None for global aggregation.
|
|
aggs: The aggregations to do.
|
|
finalize: Whether to finalize the aggregation. This is used as an
|
|
optimization for cases where we repeatedly combine partially
|
|
aggregated groups.
|
|
|
|
Returns:
|
|
A block of [k, v_1, ..., v_n] columns and its metadata where k is
|
|
the groupby key and v_i is the corresponding aggregation result for
|
|
the ith given aggregation.
|
|
If key is None then the k column is omitted.
|
|
"""
|
|
|
|
# Handle blocks of different types.
|
|
blocks = TableBlockAccessor.normalize_block_types(blocks)
|
|
|
|
stats = BlockExecStats.builder()
|
|
keys = sort_key.get_columns()
|
|
|
|
def _key_fn(r):
|
|
if keys:
|
|
return tuple(r[keys])
|
|
else:
|
|
return (0,)
|
|
|
|
# Replace `None`s and `np.nan` with NULL_SENTINEL to make sure
|
|
# we can order the elements (both of these are incomparable)
|
|
def safe_key_fn(r):
|
|
values = _key_fn(r)
|
|
return tuple(
|
|
[NULL_SENTINEL if v is None or is_nan(v) else v for v in values]
|
|
)
|
|
|
|
iter = heapq.merge(
|
|
*[
|
|
BlockAccessor.for_block(block).iter_rows(public_row_format=False)
|
|
for block in blocks
|
|
],
|
|
key=safe_key_fn,
|
|
)
|
|
|
|
next_row = None
|
|
builder = BlockAccessor.for_block(blocks[0]).builder()
|
|
|
|
while True:
|
|
try:
|
|
if next_row is None:
|
|
next_row = next(iter)
|
|
|
|
next_keys = _key_fn(next_row)
|
|
next_key_columns = keys
|
|
|
|
def gen():
|
|
nonlocal iter
|
|
nonlocal next_row
|
|
while keys_equal(_key_fn(next_row), next_keys):
|
|
yield next_row
|
|
try:
|
|
next_row = next(iter)
|
|
except StopIteration:
|
|
next_row = None
|
|
break
|
|
|
|
# Merge.
|
|
first = True
|
|
accumulators = [None] * len(aggs)
|
|
resolved_agg_names = [None] * len(aggs)
|
|
for r in gen():
|
|
if first:
|
|
count = collections.defaultdict(int)
|
|
for i in range(len(aggs)):
|
|
name = aggs[i].name
|
|
# Check for conflicts with existing aggregation
|
|
# name.
|
|
if count[name] > 0:
|
|
name = TableBlockAccessor._munge_conflict(
|
|
name, count[name]
|
|
)
|
|
count[name] += 1
|
|
resolved_agg_names[i] = name
|
|
accumulators[i] = r[name]
|
|
first = False
|
|
else:
|
|
for i in range(len(aggs)):
|
|
accumulators[i] = aggs[i].merge(
|
|
accumulators[i],
|
|
r[resolved_agg_names[i]],
|
|
)
|
|
# Build the row.
|
|
row = {}
|
|
if keys:
|
|
for col_name, next_key in zip(next_key_columns, next_keys):
|
|
row[col_name] = next_key
|
|
|
|
for agg, agg_name, accumulator in zip(
|
|
aggs, resolved_agg_names, accumulators
|
|
):
|
|
if finalize:
|
|
row[agg_name] = agg.finalize(accumulator)
|
|
else:
|
|
row[agg_name] = accumulator
|
|
|
|
builder.add(row)
|
|
except StopIteration:
|
|
break
|
|
|
|
ret = builder.build()
|
|
return ret, BlockMetadataWithSchema.from_block(
|
|
ret, block_exec_stats=stats.build()
|
|
)
|
|
|
|
def _find_partitions_sorted(
|
|
self,
|
|
boundaries: List[Tuple[Any]],
|
|
sort_key: "SortKey",
|
|
):
|
|
partitions = []
|
|
|
|
# For each boundary value, count the number of items that are less
|
|
# than it. Since the block is sorted, these counts partition the items
|
|
# such that boundaries[i] <= x < boundaries[i + 1] for each x in
|
|
# partition[i]. If `descending` is true, `boundaries` would also be
|
|
# in descending order and we only need to count the number of items
|
|
# *greater than* the boundary value instead.
|
|
bounds = [
|
|
find_partition_index(self._table, boundary, sort_key)
|
|
for boundary in boundaries
|
|
]
|
|
|
|
last_idx = 0
|
|
for idx in bounds:
|
|
partitions.append(self._table[last_idx:idx])
|
|
last_idx = idx
|
|
partitions.append(self._table[last_idx:])
|
|
return partitions
|
|
|
|
@classmethod
|
|
def normalize_block_types(
|
|
cls,
|
|
blocks: List[Block],
|
|
target_block_type: Optional[BlockType] = None,
|
|
) -> List[Block]:
|
|
"""Normalize input blocks to the specified `normalize_type`. If the blocks
|
|
are already all of the same type, returns original blocks.
|
|
|
|
Args:
|
|
blocks: A list of TableBlocks to be normalized.
|
|
target_block_type: The type to normalize the blocks to. If None,
|
|
Ray Data chooses a type to minimize the amount of data conversions.
|
|
|
|
Returns:
|
|
A list of blocks of the same type.
|
|
"""
|
|
seen_types: Dict[BlockType, int] = collections.defaultdict(int)
|
|
|
|
for block in blocks:
|
|
block_accessor = BlockAccessor.for_block(block)
|
|
if not isinstance(block_accessor, TableBlockAccessor):
|
|
raise ValueError(
|
|
"Block type normalization is only supported for TableBlock, "
|
|
f"but received block of type: {type(block)}."
|
|
)
|
|
|
|
seen_types[block_accessor.block_type()] += 1
|
|
|
|
# If there's just 1 block-type and it's matching target-type, short-circuit
|
|
if len(seen_types) == 1 and (
|
|
target_block_type is None or [target_block_type] == list(seen_types.keys())
|
|
):
|
|
return blocks
|
|
|
|
# Pick the most prevalent block-type
|
|
if target_block_type is None:
|
|
_, target_block_type = sorted(
|
|
seen_types.items(),
|
|
key=lambda x: x[1],
|
|
reverse=True,
|
|
)[0]
|
|
|
|
results = [
|
|
cls.try_convert_block_type(block, target_block_type) for block in blocks
|
|
]
|
|
|
|
if any(not isinstance(block, type(results[0])) for block in results):
|
|
raise ValueError(
|
|
"Expected all blocks to be of the same type after normalization, but "
|
|
f"got different types: {[type(b) for b in results]}. "
|
|
"Try using blocks of the same type to avoid the issue "
|
|
"with block normalization."
|
|
)
|
|
return results
|
|
|
|
@classmethod
|
|
def try_convert_block_type(cls, block: Block, block_type: BlockType):
|
|
if block_type == BlockType.ARROW:
|
|
return BlockAccessor.for_block(block).to_arrow()
|
|
elif block_type == BlockType.PANDAS:
|
|
return BlockAccessor.for_block(block).to_pandas()
|
|
else:
|
|
return BlockAccessor.for_block(block).to_default()
|
|
|
|
def hstack(self, other_block: Block) -> Block:
|
|
"""Combine this table with another table horizontally (column-wise).
|
|
This will append the columns.
|
|
|
|
Args:
|
|
other_block: The table to hstack side-by-side with.
|
|
|
|
Returns:
|
|
A new table with columns from both tables combined.
|
|
"""
|
|
raise NotImplementedError
|