1997 lines
71 KiB
Python
1997 lines
71 KiB
Python
import functools
|
|
import importlib
|
|
import logging
|
|
import math
|
|
import os
|
|
import pathlib
|
|
import platform
|
|
import random
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.parse
|
|
import uuid
|
|
from queue import Empty, Full, Queue
|
|
from types import ModuleType
|
|
from typing import (
|
|
TYPE_CHECKING,
|
|
Any,
|
|
Callable,
|
|
Dict,
|
|
Generator,
|
|
Iterable,
|
|
Iterator,
|
|
List,
|
|
Optional,
|
|
Tuple,
|
|
TypeVar,
|
|
Union,
|
|
overload,
|
|
)
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
# NOTE: pyarrow.fs module needs to be explicitly imported!
|
|
import pyarrow
|
|
import pyarrow.fs
|
|
|
|
import ray
|
|
from ray._common.retry import call_with_retry, format_exception, matches_error
|
|
from ray.data.context import DEFAULT_READ_OP_MIN_NUM_BLOCKS, WARN_PREFIX, DataContext
|
|
from ray.util.annotations import DeveloperAPI
|
|
|
|
import psutil
|
|
|
|
# TypeVar for preserving function/class signatures through decorators
|
|
F = TypeVar("F", bound=Callable[..., Any])
|
|
|
|
if TYPE_CHECKING:
|
|
import pandas
|
|
|
|
from ray.data._internal.compute import ComputeStrategy
|
|
from ray.data._internal.execution.interfaces import ExecutionResources, RefBundle
|
|
from ray.data._internal.logical.interfaces.logical_plan import LogicalPlan
|
|
from ray.data._internal.planner.exchange.sort_task_spec import SortKey
|
|
from ray.data.block import (
|
|
Block,
|
|
BlockMetadataWithSchema,
|
|
Schema,
|
|
UserDefinedFunction,
|
|
)
|
|
from ray.data.datasource import Datasource, Reader
|
|
from ray.util.placement_group import PlacementGroup
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
KiB = 1024 # bytes
|
|
MiB = 1024 * KiB
|
|
GiB = 1024 * MiB
|
|
|
|
|
|
SENTINEL = object()
|
|
|
|
|
|
_LOCAL_SCHEME = "local"
|
|
_EXAMPLE_SCHEME = "example"
|
|
|
|
|
|
LazyModule = Union[None, bool, ModuleType]
|
|
_pyarrow_dataset: LazyModule = None
|
|
|
|
|
|
class _OrderedNullSentinel:
|
|
"""Sentinel value that sorts greater than any other non-null value.
|
|
|
|
NOTE: Semantic of this sentinel is closely mirroring that one of
|
|
``np.nan`` for the purpose of consistency in handling of
|
|
``None``s and ``np.nan``s.
|
|
"""
|
|
|
|
def __eq__(self, other):
|
|
return False
|
|
|
|
def __lt__(self, other):
|
|
# not None < _OrderedNullSentinel
|
|
# _OrderedNullSentinel < _OrderedNullSentinel
|
|
# _OrderedNullSentinel < None
|
|
# _OrderedNullSentinel < np.nan
|
|
return isinstance(other, _OrderedNullSentinel) or is_null(other)
|
|
|
|
def __le__(self, other):
|
|
# NOTE: This is just a shortened version of
|
|
# self < other or self == other
|
|
return self.__lt__(other)
|
|
|
|
def __gt__(self, other):
|
|
return not self.__le__(other)
|
|
|
|
def __ge__(self, other):
|
|
return not self.__lt__(other)
|
|
|
|
def __hash__(self):
|
|
return id(self)
|
|
|
|
|
|
NULL_SENTINEL = _OrderedNullSentinel()
|
|
|
|
|
|
def _lazy_import_pyarrow_dataset() -> LazyModule:
|
|
global _pyarrow_dataset
|
|
if _pyarrow_dataset is None:
|
|
try:
|
|
from pyarrow import dataset as _pyarrow_dataset
|
|
except ModuleNotFoundError:
|
|
# If module is not found, set _pyarrow to False so we won't
|
|
# keep trying to import it on every _lazy_import_pyarrow() call.
|
|
_pyarrow_dataset = False
|
|
return _pyarrow_dataset
|
|
|
|
|
|
def _check_pyarrow_version():
|
|
ray.data._internal.utils.arrow_utils._check_pyarrow_version()
|
|
|
|
|
|
def _autodetect_parallelism(
|
|
parallelism: int,
|
|
target_max_block_size: Optional[int],
|
|
ctx: DataContext,
|
|
datasource_or_legacy_reader: Optional[Union["Datasource", "Reader"]] = None,
|
|
mem_size: Optional[int] = None,
|
|
placement_group: Optional["PlacementGroup"] = None,
|
|
avail_cpus: Optional[int] = None,
|
|
) -> Tuple[int, str, Optional[int]]:
|
|
"""Returns parallelism to use and the min safe parallelism to avoid OOMs.
|
|
|
|
This detects parallelism using the following heuristics, applied in order:
|
|
|
|
1) We start with the default value of 200. This can be overridden by
|
|
setting the `read_op_min_num_blocks` attribute of
|
|
:class:`~ray.data.context.DataContext`.
|
|
2) Min block size. If the parallelism would make blocks smaller than this
|
|
threshold, the parallelism is reduced to avoid the overhead of tiny blocks.
|
|
3) Max block size. If the parallelism would make blocks larger than this
|
|
threshold, the parallelism is increased to avoid OOMs during processing.
|
|
4) Available CPUs. If the parallelism cannot make use of all the available
|
|
CPUs in the cluster, the parallelism is increased until it can.
|
|
|
|
Args:
|
|
parallelism: The user-requested parallelism, or -1 for auto-detection.
|
|
target_max_block_size: The target max block size to
|
|
produce. We pass this separately from the
|
|
DatasetContext because it may be set per-op instead of
|
|
per-Dataset.
|
|
ctx: The current Dataset context to use for configs.
|
|
datasource_or_legacy_reader: The datasource or legacy reader, to be used for
|
|
data size estimation.
|
|
mem_size: If passed, then used to compute the parallelism according to
|
|
target_max_block_size.
|
|
placement_group: The placement group that this Dataset
|
|
will execute inside, if any.
|
|
avail_cpus: Override avail cpus detection (for testing only).
|
|
|
|
Returns:
|
|
Tuple of detected parallelism (only if -1 was specified), the reason
|
|
for the detected parallelism (only if -1 was specified), and the estimated
|
|
inmemory size of the dataset.
|
|
"""
|
|
min_safe_parallelism = 1
|
|
max_reasonable_parallelism = sys.maxsize
|
|
|
|
if mem_size is None and datasource_or_legacy_reader:
|
|
mem_size = datasource_or_legacy_reader.estimate_inmemory_data_size()
|
|
if (
|
|
mem_size is not None
|
|
# Guard against non-scalar types (e.g. numpy arrays) that would cause
|
|
# np.isnan() to raise TypeError in newer numpy versions.
|
|
and isinstance(mem_size, (int, float))
|
|
and not np.isnan(mem_size)
|
|
and target_max_block_size is not None
|
|
):
|
|
min_safe_parallelism = max(1, int(mem_size / target_max_block_size))
|
|
max_reasonable_parallelism = max(1, int(mem_size / ctx.target_min_block_size))
|
|
|
|
reason = ""
|
|
if parallelism < 0:
|
|
if parallelism != -1:
|
|
raise ValueError("`parallelism` must either be -1 or a positive integer.")
|
|
|
|
if (
|
|
ctx.min_parallelism is not None
|
|
and ctx.min_parallelism != DEFAULT_READ_OP_MIN_NUM_BLOCKS
|
|
and ctx.read_op_min_num_blocks == DEFAULT_READ_OP_MIN_NUM_BLOCKS
|
|
):
|
|
logger.warning(
|
|
"``DataContext.min_parallelism`` is deprecated in Ray 2.10. "
|
|
"Please specify ``DataContext.read_op_min_num_blocks`` instead."
|
|
)
|
|
ctx.read_op_min_num_blocks = ctx.min_parallelism
|
|
|
|
# Start with 2x the number of cores as a baseline, with a min floor.
|
|
if placement_group is None:
|
|
placement_group = ray.util.get_current_placement_group()
|
|
avail_cpus = avail_cpus or _estimate_avail_cpus(placement_group)
|
|
parallelism = max(
|
|
min(ctx.read_op_min_num_blocks, max_reasonable_parallelism),
|
|
min_safe_parallelism,
|
|
avail_cpus * 2,
|
|
)
|
|
|
|
if parallelism == ctx.read_op_min_num_blocks:
|
|
reason = (
|
|
"DataContext.get_current().read_op_min_num_blocks="
|
|
f"{ctx.read_op_min_num_blocks}"
|
|
)
|
|
elif parallelism == max_reasonable_parallelism:
|
|
reason = (
|
|
"output blocks of size at least "
|
|
"DataContext.get_current().target_min_block_size="
|
|
f"{ctx.target_min_block_size / MiB} MiB"
|
|
)
|
|
elif parallelism == min_safe_parallelism:
|
|
# Handle ``None`` (unlimited) gracefully in the log message.
|
|
if ctx.target_max_block_size is None:
|
|
display_val = "unlimited"
|
|
else:
|
|
display_val = f"{ctx.target_max_block_size / MiB} MiB"
|
|
reason = (
|
|
"output blocks of size at most "
|
|
"DataContext.get_current().target_max_block_size="
|
|
f"{display_val}"
|
|
)
|
|
else:
|
|
reason = (
|
|
"parallelism at least twice the available number "
|
|
f"of CPUs ({avail_cpus})"
|
|
)
|
|
|
|
logger.debug(
|
|
f"Autodetected parallelism={parallelism} based on "
|
|
f"estimated_available_cpus={avail_cpus} and "
|
|
f"estimated_data_size={mem_size}."
|
|
)
|
|
|
|
return parallelism, reason, mem_size
|
|
|
|
|
|
def _estimate_avail_cpus(cur_pg: Optional["PlacementGroup"]) -> int:
|
|
"""Estimates the available CPU parallelism for this Dataset in the cluster.
|
|
|
|
If we aren't in a placement group, this is trivially the number of CPUs in the
|
|
cluster. Otherwise, we try to calculate how large the placement group is relative
|
|
to the size of the cluster.
|
|
|
|
Args:
|
|
cur_pg: The current placement group, if any.
|
|
|
|
Returns:
|
|
The estimated number of available CPU slots usable by this Dataset.
|
|
"""
|
|
cluster_cpus = int(ray.cluster_resources().get("CPU", 1))
|
|
cluster_gpus = int(ray.cluster_resources().get("GPU", 0))
|
|
|
|
# If we're in a placement group, we shouldn't assume the entire cluster's
|
|
# resources are available for us to use. Estimate an upper bound on what's
|
|
# reasonable to assume is available for datasets to use.
|
|
if cur_pg:
|
|
pg_cpus = 0
|
|
for bundle in cur_pg.bundle_specs:
|
|
# Calculate the proportion of the cluster this placement group "takes up".
|
|
# Then scale our cluster_cpus proportionally to avoid over-parallelizing
|
|
# if there are many parallel Tune trials using the cluster.
|
|
cpu_fraction = bundle.get("CPU", 0) / max(1, cluster_cpus)
|
|
gpu_fraction = bundle.get("GPU", 0) / max(1, cluster_gpus)
|
|
max_fraction = max(cpu_fraction, gpu_fraction)
|
|
# Over-parallelize by up to a factor of 2, but no more than that. It's
|
|
# preferrable to over-estimate than under-estimate.
|
|
pg_cpus += 2 * int(max_fraction * cluster_cpus)
|
|
|
|
return min(cluster_cpus, pg_cpus)
|
|
|
|
return cluster_cpus
|
|
|
|
|
|
def _estimate_available_parallelism() -> int:
|
|
"""Estimates the available CPU parallelism for this Dataset in the cluster.
|
|
If we are currently in a placement group, take that into account."""
|
|
cur_pg = ray.util.get_current_placement_group()
|
|
return _estimate_avail_cpus(cur_pg)
|
|
|
|
|
|
def _warn_on_high_parallelism(requested_parallelism, num_read_tasks):
|
|
available_cpu_slots = ray.available_resources().get("CPU", 1)
|
|
if (
|
|
requested_parallelism
|
|
and num_read_tasks > available_cpu_slots * 4
|
|
and num_read_tasks >= 5000
|
|
):
|
|
logger.warning(
|
|
f"{WARN_PREFIX} The requested parallelism of {requested_parallelism} "
|
|
"is more than 4x the number of available CPU slots in the cluster of "
|
|
f"{available_cpu_slots}. This can "
|
|
"lead to slowdowns during the data reading phase due to excessive "
|
|
"task creation. Reduce the parallelism to match with the available "
|
|
"CPU slots in the cluster, or set parallelism to -1 for Ray Data "
|
|
"to automatically determine the parallelism. "
|
|
"You can ignore this message if the cluster is expected to autoscale."
|
|
)
|
|
|
|
|
|
def _check_import(obj: Any, *, module: str, package: str) -> None:
|
|
"""Check if a required dependency is installed.
|
|
|
|
If `module` can't be imported, this function raises an `ImportError` instructing
|
|
the user to install `package` from PyPI.
|
|
|
|
Args:
|
|
obj: The object that has a dependency.
|
|
module: The name of the module to import.
|
|
package: The name of the package on PyPI.
|
|
"""
|
|
try:
|
|
importlib.import_module(module)
|
|
except ImportError:
|
|
raise ImportError(
|
|
f"`{obj.__class__.__name__}` depends on '{module}', but Ray Data couldn't "
|
|
f"import it. Install '{module}' by running `pip install {package}`."
|
|
)
|
|
|
|
|
|
def _resolve_custom_scheme(path: str) -> str:
|
|
"""Returns the resolved path if the given path follows a Ray-specific custom
|
|
scheme. Othewise, returns the path unchanged.
|
|
|
|
The supported custom schemes are: "local", "example".
|
|
"""
|
|
parsed_uri = urllib.parse.urlparse(path)
|
|
if parsed_uri.scheme == _LOCAL_SCHEME:
|
|
path = parsed_uri.netloc + parsed_uri.path
|
|
elif parsed_uri.scheme == _EXAMPLE_SCHEME:
|
|
example_data_path = pathlib.Path(__file__).parent.parent / "examples" / "data"
|
|
path = example_data_path / (parsed_uri.netloc + parsed_uri.path)
|
|
path = str(path.resolve())
|
|
return path
|
|
|
|
|
|
def _normalize_paths_to_strings(
|
|
paths: Union[str, pathlib.Path, List[Union[str, pathlib.Path]]]
|
|
) -> List[str]:
|
|
"""Normalize path input to a list of strings.
|
|
|
|
Accepts a single path (str or pathlib.Path) or a list of paths.
|
|
Returns a list of string paths. Raises ValueError if paths is empty
|
|
or contains invalid types.
|
|
"""
|
|
if isinstance(paths, str):
|
|
return [paths]
|
|
elif isinstance(paths, pathlib.Path):
|
|
return [str(paths)]
|
|
elif isinstance(paths, list):
|
|
normalized = [str(p) if isinstance(p, pathlib.Path) else p for p in paths]
|
|
if not normalized:
|
|
raise ValueError("Must provide at least one path.")
|
|
if any(not isinstance(p, str) for p in normalized):
|
|
raise ValueError("All paths must be str or pathlib.Path")
|
|
return normalized
|
|
else:
|
|
raise ValueError(f"paths must be str, pathlib.Path, or list, got {type(paths)}")
|
|
|
|
|
|
def _is_local_scheme(paths: Union[str, List[str]]) -> bool:
|
|
"""Returns True if the given paths are in local scheme.
|
|
Note: The paths must be in same scheme, i.e. it's invalid and
|
|
will raise error if paths are mixed with different schemes.
|
|
"""
|
|
paths = _normalize_paths_to_strings(paths)
|
|
|
|
num = sum(urllib.parse.urlparse(path).scheme == _LOCAL_SCHEME for path in paths)
|
|
if num > 0 and num < len(paths):
|
|
raise ValueError(
|
|
"The paths must all be local-scheme or not local-scheme, "
|
|
f"but found mixed {paths}"
|
|
)
|
|
return num == len(paths)
|
|
|
|
|
|
def _truncated_repr(obj: Any) -> str:
|
|
"""Utility to return a truncated object representation for error messages."""
|
|
msg = str(obj)
|
|
if len(msg) > 200:
|
|
msg = msg[:200] + "..."
|
|
return msg
|
|
|
|
|
|
def _insert_doc_at_pattern(
|
|
obj,
|
|
*,
|
|
message: str,
|
|
pattern: str,
|
|
insert_after: bool = True,
|
|
directive: Optional[str] = None,
|
|
skip_matches: int = 0,
|
|
) -> str:
|
|
if "\n" in message:
|
|
raise ValueError(
|
|
"message shouldn't contain any newlines, since this function will insert "
|
|
f"its own linebreaks when text wrapping: {message}"
|
|
)
|
|
|
|
doc = obj.__doc__.strip()
|
|
if not doc:
|
|
doc = ""
|
|
|
|
if pattern == "" and insert_after:
|
|
# Empty pattern + insert_after means that we want to append the message to the
|
|
# end of the docstring.
|
|
head = doc
|
|
tail = ""
|
|
else:
|
|
tail = doc
|
|
i = tail.find(pattern)
|
|
skip_matches_left = skip_matches
|
|
while i != -1:
|
|
if insert_after:
|
|
# Set offset to the first character after the pattern.
|
|
offset = i + len(pattern)
|
|
else:
|
|
# Set offset to the first character in the matched line.
|
|
offset = tail[:i].rfind("\n") + 1
|
|
head = tail[:offset]
|
|
tail = tail[offset:]
|
|
skip_matches_left -= 1
|
|
if skip_matches_left <= 0:
|
|
break
|
|
elif not insert_after:
|
|
# Move past the found pattern, since we're skipping it.
|
|
tail = tail[i - offset + len(pattern) :]
|
|
i = tail.find(pattern)
|
|
else:
|
|
raise ValueError(
|
|
f"Pattern {pattern} not found after {skip_matches} skips in docstring "
|
|
f"{doc}"
|
|
)
|
|
# Get indentation of the to-be-inserted text.
|
|
after_lines = list(filter(bool, tail.splitlines()))
|
|
if len(after_lines) > 0:
|
|
lines = after_lines
|
|
else:
|
|
lines = list(filter(bool, reversed(head.splitlines())))
|
|
# Should always have at least one non-empty line in the docstring.
|
|
assert len(lines) > 0
|
|
indent = " " * (len(lines[0]) - len(lines[0].lstrip()))
|
|
# Handle directive.
|
|
message = message.strip("\n")
|
|
if directive is not None:
|
|
base = f"{indent}.. {directive}::\n"
|
|
message = message.replace("\n", "\n" + indent + " " * 4)
|
|
message = base + indent + " " * 4 + message
|
|
else:
|
|
message = indent + message.replace("\n", "\n" + indent)
|
|
# Add two blank lines before/after message, if necessary.
|
|
if insert_after ^ (pattern == "\n\n"):
|
|
# Only two blank lines before message if:
|
|
# 1. Inserting message after pattern and pattern is not two blank lines.
|
|
# 2. Inserting message before pattern and pattern is two blank lines.
|
|
message = "\n\n" + message
|
|
if (not insert_after) ^ (pattern == "\n\n"):
|
|
# Only two blank lines after message if:
|
|
# 1. Inserting message before pattern and pattern is not two blank lines.
|
|
# 2. Inserting message after pattern and pattern is two blank lines.
|
|
message = message + "\n\n"
|
|
|
|
# Insert message before/after pattern.
|
|
parts = [head, message, tail]
|
|
# Build new docstring.
|
|
obj.__doc__ = "".join(parts)
|
|
|
|
|
|
def _consumption_api(
|
|
if_more_than_read: bool = False,
|
|
datasource_metadata: Optional[str] = None,
|
|
extra_condition: Optional[str] = None,
|
|
delegate: Optional[str] = None,
|
|
pattern: str = "Examples:",
|
|
insert_after: bool = False,
|
|
) -> Callable[[F], F]:
|
|
"""Annotate the function with an indication that it's a consumption API, and that it
|
|
will trigger Dataset execution.
|
|
"""
|
|
base = (
|
|
" will trigger execution of the lazy transformations performed on "
|
|
"this dataset."
|
|
)
|
|
if delegate:
|
|
message = delegate + base
|
|
elif not if_more_than_read:
|
|
message = "This operation" + base
|
|
else:
|
|
condition = "If this dataset consists of more than a read, "
|
|
if datasource_metadata is not None:
|
|
condition += (
|
|
f"or if the {datasource_metadata} can't be determined from the "
|
|
"metadata provided by the datasource, "
|
|
)
|
|
if extra_condition is not None:
|
|
condition += extra_condition + ", "
|
|
message = condition + "then this operation" + base
|
|
|
|
def wrap(obj: F) -> F:
|
|
_insert_doc_at_pattern(
|
|
obj,
|
|
message=message,
|
|
pattern=pattern,
|
|
insert_after=insert_after,
|
|
directive="note",
|
|
)
|
|
return obj
|
|
|
|
return wrap
|
|
|
|
|
|
@overload
|
|
def ConsumptionAPI(obj: F) -> F:
|
|
...
|
|
|
|
|
|
@overload
|
|
def ConsumptionAPI(
|
|
*,
|
|
if_more_than_read: bool = False,
|
|
datasource_metadata: Optional[str] = None,
|
|
extra_condition: Optional[str] = None,
|
|
delegate: Optional[str] = None,
|
|
) -> Callable[[F], F]:
|
|
...
|
|
|
|
|
|
def ConsumptionAPI(*args, **kwargs):
|
|
"""Annotate the function with an indication that it's a consumption API, and that it
|
|
will trigger Dataset execution.
|
|
"""
|
|
if len(args) == 1 and len(kwargs) == 0 and callable(args[0]):
|
|
return _consumption_api()(args[0])
|
|
return _consumption_api(*args, **kwargs)
|
|
|
|
|
|
def _all_to_all_api() -> Callable[[F], F]:
|
|
"""Annotate the function with an indication that it's a all to all API, and that it
|
|
is an operation that requires all inputs to be materialized in-memory to execute.
|
|
"""
|
|
|
|
def wrap(obj: F) -> F:
|
|
_insert_doc_at_pattern(
|
|
obj,
|
|
message=(
|
|
"This operation requires all inputs to be "
|
|
"materialized in object store for it to execute."
|
|
),
|
|
pattern="Examples:",
|
|
insert_after=False,
|
|
directive="note",
|
|
)
|
|
return obj
|
|
|
|
return wrap
|
|
|
|
|
|
@overload
|
|
def AllToAllAPI(obj: F) -> F:
|
|
...
|
|
|
|
|
|
def AllToAllAPI(*args, **kwargs):
|
|
"""Annotate the function with an indication that it's a all to all API, and that it
|
|
is an operation that requires all inputs to be materialized in-memory to execute.
|
|
"""
|
|
# This should only be used as a decorator for dataset methods.
|
|
assert len(args) == 1 and len(kwargs) == 0 and callable(args[0])
|
|
return _all_to_all_api()(args[0])
|
|
|
|
|
|
def get_compute_strategy(
|
|
fn: "UserDefinedFunction",
|
|
fn_constructor_args: Optional[Iterable[Any]] = None,
|
|
compute: Optional[Union[str, "ComputeStrategy"]] = None,
|
|
concurrency: Optional[Union[int, Tuple[int, int], Tuple[int, int, int]]] = None,
|
|
) -> "ComputeStrategy":
|
|
"""Get `ComputeStrategy` based on the function or class, and concurrency
|
|
information.
|
|
|
|
Args:
|
|
fn: The function or generator to apply to a record batch, or a class type
|
|
that can be instantiated to create such a callable.
|
|
fn_constructor_args: Positional arguments to pass to ``fn``'s constructor.
|
|
compute: Either "tasks" (default) to use Ray Tasks or an
|
|
:class:`~ray.data.ActorPoolStrategy` to use an autoscaling actor pool.
|
|
concurrency: The number of Ray workers to use concurrently.
|
|
|
|
Returns:
|
|
The `ComputeStrategy` for execution.
|
|
"""
|
|
# Lazily import these objects to avoid circular imports.
|
|
from ray.data._internal.compute import ActorPoolStrategy, TaskPoolStrategy
|
|
from ray.data.block import CallableClass
|
|
|
|
if isinstance(fn, CallableClass):
|
|
is_callable_class = True
|
|
else:
|
|
# TODO(chengsu): disallow object that is not a function. For example,
|
|
# An object instance of class often indicates a bug in user code.
|
|
is_callable_class = False
|
|
if fn_constructor_args is not None:
|
|
raise ValueError(
|
|
"``fn_constructor_args`` can only be specified if providing a "
|
|
f"callable class instance for ``fn``, but got: {fn}."
|
|
)
|
|
|
|
if compute is not None:
|
|
if is_callable_class and (
|
|
compute == "tasks" or isinstance(compute, TaskPoolStrategy)
|
|
):
|
|
raise ValueError(
|
|
f"You specified the callable class {fn} as your UDF with the compute "
|
|
f"{compute}, but Ray Data can't schedule callable classes with the task "
|
|
f"pool strategy. To fix this error, pass an ActorPoolStrategy to compute or "
|
|
f"None to use the default compute strategy."
|
|
)
|
|
elif not is_callable_class and (
|
|
compute == "actors" or isinstance(compute, ActorPoolStrategy)
|
|
):
|
|
raise ValueError(
|
|
f"You specified the function {fn} as your UDF with the compute "
|
|
f"{compute}, but Ray Data can't schedule regular functions with the actor "
|
|
f"pool strategy. To fix this error, pass a TaskPoolStrategy to compute or "
|
|
f"None to use the default compute strategy."
|
|
)
|
|
return compute
|
|
elif concurrency is not None:
|
|
# Legacy code path to support `concurrency` argument.
|
|
logger.warning(
|
|
"The argument ``concurrency`` is deprecated in Ray 2.51. Please specify "
|
|
"argument ``compute`` instead. For more information, see "
|
|
"https://docs.ray.io/en/master/data/transforming-data.html#"
|
|
"stateful-transforms."
|
|
)
|
|
if isinstance(concurrency, tuple):
|
|
# Validate tuple length and that all elements are integers
|
|
if len(concurrency) not in (2, 3) or not all(
|
|
isinstance(c, int) for c in concurrency
|
|
):
|
|
raise ValueError(
|
|
"``concurrency`` is expected to be set as a tuple of "
|
|
f"integers, but got: {concurrency}."
|
|
)
|
|
|
|
# Check if function is callable class (common validation)
|
|
if not is_callable_class:
|
|
raise ValueError(
|
|
"``concurrency`` is set as a tuple of integers, but ``fn`` "
|
|
f"is not a callable class: {fn}. Use ``concurrency=n`` to "
|
|
"control maximum number of workers to use."
|
|
)
|
|
|
|
# Create ActorPoolStrategy based on tuple length
|
|
if len(concurrency) == 2:
|
|
return ActorPoolStrategy(
|
|
min_size=concurrency[0], max_size=concurrency[1]
|
|
)
|
|
else: # len(concurrency) == 3
|
|
return ActorPoolStrategy(
|
|
min_size=concurrency[0],
|
|
max_size=concurrency[1],
|
|
initial_size=concurrency[2],
|
|
)
|
|
elif isinstance(concurrency, int):
|
|
if is_callable_class:
|
|
return ActorPoolStrategy(size=concurrency)
|
|
else:
|
|
return TaskPoolStrategy(size=concurrency)
|
|
else:
|
|
raise ValueError(
|
|
"``concurrency`` is expected to be set as an integer or a "
|
|
f"tuple of integers, but got: {concurrency}."
|
|
)
|
|
else:
|
|
if is_callable_class:
|
|
return ActorPoolStrategy(min_size=1, max_size=None)
|
|
else:
|
|
return TaskPoolStrategy()
|
|
|
|
|
|
def get_compute_strategy_for_read_api(
|
|
compute: Optional["ComputeStrategy"] = None,
|
|
concurrency: Optional[int] = None,
|
|
) -> "ComputeStrategy":
|
|
"""Get `ComputeStrategy` for read APIs.
|
|
|
|
This function is used to support both TaskPoolStrategy and ActorPoolStrategy for read APIs.
|
|
The default behavior is to use TaskPoolStrategy, with size set to ``concurrency`` (integer).
|
|
To use ActorPoolStrategy, pass an ActorPoolStrategy instance to the ``compute`` parameter. The
|
|
``concurrency`` parameter takes precedence over the ``compute`` parameter.
|
|
|
|
Args:
|
|
compute: The compute strategy to use for reading. Pass an
|
|
:class:`~ray.data.ActorPoolStrategy` instance to use an actor pool,
|
|
or a :class:`~ray.data.TaskPoolStrategy` instance (default) to use Ray tasks.
|
|
If not specified, defaults to ``TaskPoolStrategy(concurrency)``.
|
|
concurrency: The maximum number of Ray tasks to run concurrently. Set this
|
|
to control number of tasks to run concurrently. This parameter takes precedence
|
|
over the ``compute`` parameter. If both are specified, the ``concurrency`` parameter
|
|
is used.
|
|
|
|
Returns:
|
|
The `ComputeStrategy` for reading.
|
|
"""
|
|
from ray.data._internal.compute import ComputeStrategy, TaskPoolStrategy
|
|
|
|
# ``concurrency`` parameter takes precedence over the ``compute`` parameter.
|
|
if concurrency is not None:
|
|
if compute is not None:
|
|
logger.warning(
|
|
"Both ``compute`` and ``concurrency`` are specified. The ``compute`` parameter will be ignored."
|
|
)
|
|
return TaskPoolStrategy(concurrency)
|
|
|
|
# When ``concurrency`` is not specified:
|
|
if compute is None:
|
|
return TaskPoolStrategy()
|
|
elif isinstance(compute, ComputeStrategy):
|
|
return compute
|
|
else:
|
|
raise ValueError(
|
|
f"compute must be a ComputeStrategy instance (e.g. ActorPoolStrategy or TaskPoolStrategy), but "
|
|
f"got {compute}"
|
|
)
|
|
|
|
|
|
def capfirst(s: str):
|
|
"""Capitalize the first letter of a string
|
|
|
|
Args:
|
|
s: String to capitalize
|
|
|
|
Returns:
|
|
Capitalized string
|
|
"""
|
|
return s[0].upper() + s[1:]
|
|
|
|
|
|
def capitalize(s: str):
|
|
"""Capitalize a string, removing '_' and keeping camelcase.
|
|
|
|
Args:
|
|
s: String to capitalize
|
|
|
|
Returns:
|
|
Capitalized string with no underscores.
|
|
"""
|
|
return "".join(capfirst(x) for x in s.split("_"))
|
|
|
|
|
|
def pandas_df_to_arrow_block(
|
|
df: "pandas.DataFrame",
|
|
) -> Tuple["Block", "BlockMetadataWithSchema"]:
|
|
from ray.data.block import BlockAccessor, BlockExecStats, BlockMetadataWithSchema
|
|
|
|
block = BlockAccessor.for_block(df).to_arrow()
|
|
stats = BlockExecStats.builder()
|
|
return block, BlockMetadataWithSchema.from_block(
|
|
block, block_exec_stats=stats.build()
|
|
)
|
|
|
|
|
|
def ndarray_to_block(
|
|
ndarray: np.ndarray, ctx: DataContext
|
|
) -> Tuple["Block", "BlockMetadataWithSchema"]:
|
|
from ray.data.block import BlockAccessor, BlockExecStats, BlockMetadataWithSchema
|
|
|
|
DataContext._set_current(ctx)
|
|
|
|
stats = BlockExecStats.builder()
|
|
block = BlockAccessor.batch_to_block({"data": ndarray})
|
|
return block, BlockMetadataWithSchema.from_block(
|
|
block, block_exec_stats=stats.build()
|
|
)
|
|
|
|
|
|
def get_table_block_metadata_schema(
|
|
table: Union["pyarrow.Table", "pandas.DataFrame"],
|
|
) -> "BlockMetadataWithSchema":
|
|
from ray.data.block import BlockExecStats, BlockMetadataWithSchema
|
|
|
|
stats = BlockExecStats.builder()
|
|
return BlockMetadataWithSchema.from_block(table, block_exec_stats=stats.build())
|
|
|
|
|
|
def unify_block_metadata_schema(
|
|
block_metadata_with_schemas: List["BlockMetadataWithSchema"],
|
|
) -> Optional["Schema"]:
|
|
"""For the input list of BlockMetadata, return a unified schema of the
|
|
corresponding blocks. If the metadata have no valid schema, returns None.
|
|
|
|
Args:
|
|
block_metadata_with_schemas: List of BlockMetadata to unify
|
|
|
|
Returns:
|
|
A unified schema of the input list of schemas, or None if no valid schemas
|
|
are provided.
|
|
"""
|
|
# Some blocks could be empty, in which case we cannot get their schema.
|
|
# TODO(ekl) validate schema is the same across different blocks.
|
|
|
|
# First check if there are blocks with computed schemas, then unify
|
|
# valid schemas from all such blocks.
|
|
|
|
schemas_to_unify = []
|
|
for m in block_metadata_with_schemas:
|
|
if m.schema is not None and (m.num_rows is None or m.num_rows > 0):
|
|
schemas_to_unify.append(m.schema)
|
|
return unify_schemas_with_validation(schemas_to_unify)
|
|
|
|
|
|
def unify_schemas_with_validation(
|
|
schemas_to_unify: Iterable["Schema"],
|
|
) -> Optional["Schema"]:
|
|
if schemas_to_unify:
|
|
from ray.data._internal.arrow_ops.transform_pyarrow import unify_schemas
|
|
|
|
# Check valid pyarrow installation before attempting schema unification
|
|
try:
|
|
import pyarrow as pa
|
|
except ImportError:
|
|
pa = None
|
|
# If the result contains PyArrow schemas, unify them
|
|
if pa is not None and all(isinstance(s, pa.Schema) for s in schemas_to_unify):
|
|
return unify_schemas(schemas_to_unify, promote_types=True)
|
|
# Otherwise, if the resulting schemas are simple types (e.g. int),
|
|
# return the first schema.
|
|
return schemas_to_unify[0]
|
|
return None
|
|
|
|
|
|
def unify_ref_bundles_schema(
|
|
ref_bundles: List["RefBundle"],
|
|
) -> Optional["Schema"]:
|
|
schemas_to_unify = []
|
|
for bundle in ref_bundles:
|
|
if bundle.schema is not None and (
|
|
bundle.num_rows() is None or bundle.num_rows() > 0
|
|
):
|
|
schemas_to_unify.append(bundle.schema)
|
|
return unify_schemas_with_validation(schemas_to_unify)
|
|
|
|
|
|
def find_partition_index(
|
|
table: Union["pyarrow.Table", "pandas.DataFrame"],
|
|
desired: Tuple[Union[int, float]],
|
|
sort_key: "SortKey",
|
|
) -> int:
|
|
"""For the given block, find the index where the desired value should be
|
|
added, to maintain sorted order.
|
|
|
|
We do this by iterating over each column, starting with the primary sort key,
|
|
and binary searching for the desired value in the column. Each binary search
|
|
shortens the "range" of indices (represented by ``left`` and ``right``, which
|
|
are indices of rows) where the desired value could be inserted.
|
|
|
|
Args:
|
|
table: The block to search in.
|
|
desired: A single tuple representing the boundary to partition at.
|
|
``len(desired)`` must be less than or equal to the number of columns
|
|
being sorted.
|
|
sort_key: The sort key to use for sorting, providing the columns to be
|
|
sorted and their directions.
|
|
|
|
Returns:
|
|
The index where the desired value should be inserted to maintain sorted
|
|
order.
|
|
"""
|
|
columns = sort_key.get_columns()
|
|
descending = sort_key.get_descending()
|
|
|
|
left, right = 0, len(table)
|
|
for i in range(len(desired)):
|
|
if left == right:
|
|
return right
|
|
col_name = columns[i]
|
|
col_vals = table[col_name].to_numpy()[left:right]
|
|
desired_val = desired[i]
|
|
|
|
# Nulls and NaN sort last in Arrow, so they accumulate at the tail of
|
|
# col_vals. Strip them before np.searchsorted to avoid incorrect bounds.
|
|
# Use O(1) null_count as a fast path, and fall back to np.isnan for
|
|
# float columns that may contain NaN without Arrow nulls.
|
|
column = table[col_name]
|
|
if hasattr(column, "null_count") and column.null_count > 0:
|
|
col_vals = col_vals[~pd.isna(col_vals)]
|
|
elif col_vals.dtype.kind == "f" and np.isnan(col_vals).any():
|
|
col_vals = col_vals[~np.isnan(col_vals)]
|
|
if desired_val is None:
|
|
return left + len(col_vals)
|
|
|
|
prevleft = left
|
|
if descending[i] is True:
|
|
# ``np.searchsorted`` expects the array to be sorted in ascending
|
|
# order, so we pass ``sorter``, which is an array of integer indices
|
|
# that sort ``col_vals`` into ascending order. The returned index
|
|
# is an index into the ascending order of ``col_vals``, so we need
|
|
# to subtract it from ``len(col_vals)`` to get the index in the
|
|
# original descending order of ``col_vals``.
|
|
sorter = np.arange(len(col_vals) - 1, -1, -1)
|
|
left = prevleft + (
|
|
len(col_vals)
|
|
- np.searchsorted(
|
|
col_vals,
|
|
desired_val,
|
|
side="right",
|
|
sorter=sorter,
|
|
)
|
|
)
|
|
right = prevleft + (
|
|
len(col_vals)
|
|
- np.searchsorted(
|
|
col_vals,
|
|
desired_val,
|
|
side="left",
|
|
sorter=sorter,
|
|
)
|
|
)
|
|
else:
|
|
left = prevleft + np.searchsorted(col_vals, desired_val, side="left")
|
|
right = prevleft + np.searchsorted(col_vals, desired_val, side="right")
|
|
|
|
return right if descending[0] is True else left
|
|
|
|
|
|
def get_attribute_from_class_name(class_name: str) -> Any:
|
|
"""Get Python attribute from the provided class name.
|
|
|
|
The caller needs to make sure the provided class name includes
|
|
full module name, and can be imported successfully.
|
|
"""
|
|
from importlib import import_module
|
|
|
|
paths = class_name.split(".")
|
|
if len(paths) < 2:
|
|
raise ValueError(f"Cannot create object from {class_name}.")
|
|
|
|
module_name = ".".join(paths[:-1])
|
|
attribute_name = paths[-1]
|
|
return getattr(import_module(module_name), attribute_name)
|
|
|
|
|
|
T = TypeVar("T")
|
|
U = TypeVar("U")
|
|
|
|
|
|
class _InterruptibleQueue(Queue):
|
|
"""Extension of Python's `queue.Queue` providing ability to get interrupt its
|
|
method callers in other threads"""
|
|
|
|
INTERRUPTION_CHECK_FREQUENCY_SEC = 0.5
|
|
|
|
def __init__(
|
|
self, max_size: int, interrupted_event: Optional[threading.Event] = None
|
|
):
|
|
super().__init__(maxsize=max_size)
|
|
self._interrupted_event = interrupted_event or threading.Event()
|
|
|
|
def get(self, block=True, timeout=None):
|
|
if not block or timeout is not None:
|
|
return super().get(block, timeout)
|
|
|
|
# In case when the call is blocking and no timeout is specified (ie blocking
|
|
# indefinitely) we apply the following protocol to make it interruptible:
|
|
#
|
|
# 1. `Queue.get` is invoked w/ 500ms timeout
|
|
# 2. `Empty` exception is intercepted (will be raised upon timeout elapsing)
|
|
# 3. If interrupted flag is set `InterruptedError` is raised
|
|
# 4. Otherwise, protocol retried (until interrupted or queue
|
|
# becoming non-empty)
|
|
while True:
|
|
if self._interrupted_event.is_set():
|
|
raise InterruptedError()
|
|
|
|
try:
|
|
return super().get(
|
|
block=True, timeout=self.INTERRUPTION_CHECK_FREQUENCY_SEC
|
|
)
|
|
except Empty:
|
|
pass
|
|
|
|
def put(self, item, block=True, timeout=None):
|
|
if not block or timeout is not None:
|
|
super().put(item, block, timeout)
|
|
return
|
|
|
|
# In case when the call is blocking and no timeout is specified (ie blocking
|
|
# indefinitely) we apply the following protocol to make it interruptible:
|
|
#
|
|
# 1. `Queue.pet` is invoked w/ 500ms timeout
|
|
# 2. `Full` exception is intercepted (will be raised upon timeout elapsing)
|
|
# 3. If interrupted flag is set `InterruptedError` is raised
|
|
# 4. Otherwise, protocol retried (until interrupted or queue
|
|
# becomes non-full)
|
|
while True:
|
|
if self._interrupted_event.is_set():
|
|
raise InterruptedError()
|
|
|
|
try:
|
|
super().put(
|
|
item, block=True, timeout=self.INTERRUPTION_CHECK_FREQUENCY_SEC
|
|
)
|
|
return
|
|
except Full:
|
|
pass
|
|
|
|
|
|
def _arrow_batcher(table: "pyarrow.Table", output_batch_size: int):
|
|
"""Batch a PyArrow table into smaller tables of size n using zero-copy slicing."""
|
|
num_rows = table.num_rows
|
|
for i in range(0, num_rows, output_batch_size):
|
|
end_idx = min(i + output_batch_size, num_rows)
|
|
# Use PyArrow's zero-copy slice operation
|
|
batch_table = table.slice(i, end_idx - i)
|
|
yield batch_table
|
|
|
|
|
|
def _iter_arrow_table_for_target_max_block_size(
|
|
table: "pyarrow.Table",
|
|
target_max_block_size: Optional[int],
|
|
) -> Iterator["pyarrow.Table"]:
|
|
"""Yield *table* as one block, or row-split when it exceeds the byte budget.
|
|
|
|
Splits by estimating how many blocks are needed from ``table.nbytes`` vs
|
|
``target_max_block_size``, then batches rows evenly via :func:`_arrow_batcher`.
|
|
Used by download paths so block sizing stays consistent.
|
|
"""
|
|
output_block_size = table.nbytes
|
|
max_bytes = target_max_block_size
|
|
if max_bytes is not None and max_bytes > 0 and output_block_size > max_bytes:
|
|
num_blocks = math.ceil(output_block_size / max_bytes)
|
|
num_rows = table.num_rows
|
|
yield from _arrow_batcher(table, int(math.ceil(num_rows / num_blocks)))
|
|
else:
|
|
yield table
|
|
|
|
|
|
def make_async_gen(
|
|
base_iterator: Iterator[T],
|
|
fn: Callable[[Iterator[T]], Iterator[U]],
|
|
preserve_ordering: bool,
|
|
num_workers: int = 1,
|
|
buffer_size: int = 1,
|
|
) -> Generator[U, None, None]:
|
|
"""Returns a generator (iterator) mapping items from the
|
|
provided iterator applying provided transformation in parallel (using a
|
|
thread-pool).
|
|
|
|
NOTE: There are some important constraints that needs to be carefully
|
|
understood before using this method
|
|
|
|
1. If `preserve_ordering` is True
|
|
a. This method would unroll input iterator eagerly (irrespective
|
|
of the speed of resulting generator being consumed). This is necessary
|
|
as we can not guarantee liveness of the algorithm AND preserving of the
|
|
original ordering at the same time.
|
|
|
|
b. Resulting ordering of the output will "match" ordering of the input, ie
|
|
that:
|
|
iterator = [A1, A2, ... An]
|
|
output iterator = [map(A1), map(A2), ..., map(An)]
|
|
|
|
2. If `preserve_ordering` is False
|
|
a. No more than `num_workers * (queue_buffer_size + 1)` elements will be
|
|
fetched from the iterator
|
|
|
|
b. Resulting ordering of the output is unspecified (and is
|
|
non-deterministic)
|
|
|
|
Args:
|
|
base_iterator: Iterator yielding elements to map
|
|
fn: Transformation to apply to each element
|
|
preserve_ordering: Whether ordering has to be preserved
|
|
num_workers: The number of threads to use in the threadpool (defaults to 1)
|
|
buffer_size: Number of objects to be buffered in its input/output
|
|
queues (per queue; defaults to 2). Total number of objects held
|
|
in memory could be calculated as:
|
|
|
|
num_workers * buffer_size * 2 (input and output)
|
|
|
|
Yields:
|
|
U: Elements corresponding to the source elements mapped by the provided
|
|
transformation (while *preserving the ordering* when requested).
|
|
"""
|
|
|
|
gen_id = random.randint(0, 2**31 - 1)
|
|
|
|
if num_workers < 1:
|
|
raise ValueError("Size of threadpool must be at least 1.")
|
|
|
|
# Signal handler used to interrupt workers when terminating
|
|
interrupted_event = threading.Event()
|
|
|
|
# To apply transformations to elements in parallel *and* preserve the ordering
|
|
# following invariants are established:
|
|
# - Every worker is handled by standalone thread
|
|
# - Every worker is assigned an input and an output queue
|
|
#
|
|
# And following protocol is implemented:
|
|
# - Filling worker traverses input iterator round-robin'ing elements across
|
|
# the input queues (in order!)
|
|
# - Transforming workers traverse respective input queue in-order: de-queueing
|
|
# element, applying transformation and enqueuing the result into the output
|
|
# queue
|
|
# - Generator (returned from this method) traverses output queues (in the same
|
|
# order as input queues) dequeues 1 mapped element at a time from each output
|
|
# queue and yields it
|
|
#
|
|
# However, in case when we're preserving the ordering we can not enforce the input
|
|
# queue size as this could result in deadlocks since transformations could be
|
|
# producing sequences of arbitrary length.
|
|
#
|
|
# Check `test_make_async_gen_varying_seq_length_stress_test` for more context on
|
|
# this problem.
|
|
if preserve_ordering:
|
|
input_queue_buf_size = -1
|
|
num_input_queues = num_workers
|
|
else:
|
|
input_queue_buf_size = (buffer_size + 1) * num_workers
|
|
num_input_queues = 1
|
|
|
|
input_queues = [
|
|
_InterruptibleQueue(input_queue_buf_size, interrupted_event)
|
|
for _ in range(num_input_queues)
|
|
]
|
|
|
|
output_queues = [
|
|
_InterruptibleQueue(buffer_size, interrupted_event) for _ in range(num_workers)
|
|
]
|
|
|
|
# Filling worker
|
|
def _run_filling_worker():
|
|
try:
|
|
# First, round-robin elements from the iterator into
|
|
# corresponding input queues (one by one)
|
|
for idx, item in enumerate(base_iterator):
|
|
input_queues[idx % num_input_queues].put(item)
|
|
|
|
# NOTE: We have to Enqueue sentinel objects for every transforming
|
|
# worker:
|
|
# - In case of preserving order of ``num_queues`` == ``num_workers``
|
|
# we will enqueue 1 sentinel per queue
|
|
# - In case of NOT preserving order all ``num_workers`` sentinels
|
|
# will be enqueued into a single queue
|
|
for idx in range(num_workers):
|
|
input_queues[idx % num_input_queues].put(SENTINEL)
|
|
|
|
except InterruptedError:
|
|
pass
|
|
|
|
except Exception as e:
|
|
logger.warning("Caught exception in filling worker!", exc_info=e)
|
|
# In case of filling worker encountering an exception we have to propagate
|
|
# it back to the (main) iterating thread. To achieve that we're traversing
|
|
# output queues *backwards* relative to the order of iterator-thread such
|
|
# that they are more likely to meet w/in a single iteration.
|
|
for output_queue in reversed(output_queues):
|
|
output_queue.put(e)
|
|
|
|
# Transforming worker
|
|
def _run_transforming_worker(input_queue, output_queue):
|
|
try:
|
|
# Create iterator draining the queue, until it receives sentinel
|
|
#
|
|
# NOTE: `queue.get` is blocking!
|
|
input_queue_iter = iter(input_queue.get, SENTINEL)
|
|
|
|
for result in fn(input_queue_iter):
|
|
# Enqueue result of the transformation
|
|
output_queue.put(result)
|
|
|
|
# Enqueue sentinel (to signal that transformations are completed)
|
|
output_queue.put(SENTINEL)
|
|
|
|
except InterruptedError:
|
|
pass
|
|
|
|
except Exception as e:
|
|
logger.warning("Caught exception in transforming worker!", exc_info=e)
|
|
# NOTE: In this case we simply enqueue the exception rather than
|
|
# interrupting
|
|
output_queue.put(e)
|
|
|
|
# Start workers threads
|
|
filling_worker_thread = threading.Thread(
|
|
target=_run_filling_worker,
|
|
name=f"map_tp_filling_worker-{gen_id}",
|
|
daemon=True,
|
|
)
|
|
filling_worker_thread.start()
|
|
|
|
transforming_worker_threads = [
|
|
threading.Thread(
|
|
target=_run_transforming_worker,
|
|
name=f"map_tp_transforming_worker-{gen_id}-{idx}",
|
|
args=(input_queues[idx % num_input_queues], output_queues[idx]),
|
|
daemon=True,
|
|
)
|
|
for idx in range(num_workers)
|
|
]
|
|
|
|
for t in transforming_worker_threads:
|
|
t.start()
|
|
|
|
# Use main thread to yield output batches
|
|
try:
|
|
# Keep track of remaining non-empty output queues
|
|
remaining_output_queues = output_queues
|
|
|
|
while len(remaining_output_queues) > 0:
|
|
# To provide deterministic ordering of the produced iterator we rely
|
|
# on the following invariants:
|
|
#
|
|
# - Elements from the original iterator are round-robin'd into
|
|
# input queues (in order)
|
|
# - Individual workers drain their respective input queues populating
|
|
# output queues with the results of applying transformation to the
|
|
# original item (and hence preserving original ordering of the input
|
|
# queue)
|
|
# - To yield from the generator output queues are traversed in the same
|
|
# order and one single element is dequeued (in a blocking way!) at a
|
|
# time from every individual output queue
|
|
#
|
|
empty_queues = []
|
|
|
|
# At every iteration only remaining non-empty queues
|
|
# are traversed (to prevent blocking on exhausted queue)
|
|
for output_queue in remaining_output_queues:
|
|
# NOTE: This is blocking!
|
|
item = output_queue.get()
|
|
|
|
if isinstance(item, Exception):
|
|
raise item
|
|
|
|
if item is SENTINEL:
|
|
empty_queues.append(output_queue)
|
|
else:
|
|
yield item
|
|
|
|
if empty_queues:
|
|
remaining_output_queues = [
|
|
q for q in remaining_output_queues if q not in empty_queues
|
|
]
|
|
|
|
finally:
|
|
# Set flag to interrupt workers (to make sure no dangling
|
|
# threads holding the objects are left behind)
|
|
#
|
|
# NOTE: Interrupted event is set to interrupt the running threads
|
|
# that might be blocked otherwise waiting on inputs from respective
|
|
# queues. However, even though we're interrupting the threads we can't
|
|
# guarantee that threads will be interrupted in time (as this is
|
|
# dependent on Python's GC finalizer to close the generator by raising
|
|
# `GeneratorExit`) and hence we can't join on either filling or
|
|
# transforming workers.
|
|
interrupted_event.set()
|
|
|
|
|
|
class RetryingContextManager:
|
|
def __init__(
|
|
self,
|
|
f: pyarrow.NativeFile,
|
|
context: DataContext,
|
|
max_attempts: int = 10,
|
|
max_backoff_s: int = 32,
|
|
):
|
|
self._f = f
|
|
self._data_context = context
|
|
self._max_attempts = max_attempts
|
|
self._max_backoff_s = max_backoff_s
|
|
|
|
def __repr__(self):
|
|
return f"<{self.__class__.__name__} fs={self.handler.unwrap()}>"
|
|
|
|
def _retry_operation(self, operation: Callable, description: str):
|
|
"""Execute an operation with retries."""
|
|
return call_with_retry(
|
|
operation,
|
|
description=description,
|
|
match=self._data_context.retried_io_errors,
|
|
max_attempts=self._max_attempts,
|
|
max_backoff_s=self._max_backoff_s,
|
|
)
|
|
|
|
def __enter__(self):
|
|
return self._retry_operation(self._f.__enter__, "enter file context")
|
|
|
|
def __exit__(self, exc_type, exc_value, traceback):
|
|
self._retry_operation(
|
|
lambda: self._f.__exit__(exc_type, exc_value, traceback),
|
|
"exit file context",
|
|
)
|
|
|
|
|
|
class RetryingPyFileSystem(pyarrow.fs.PyFileSystem):
|
|
def __init__(self, handler: "RetryingPyFileSystemHandler"):
|
|
if not isinstance(handler, RetryingPyFileSystemHandler):
|
|
assert ValueError("handler must be a RetryingPyFileSystemHandler")
|
|
super().__init__(handler)
|
|
|
|
@property
|
|
def retryable_errors(self) -> List[str]:
|
|
return self.handler._retryable_errors
|
|
|
|
def unwrap(self):
|
|
return self.handler.unwrap()
|
|
|
|
@classmethod
|
|
def wrap(
|
|
cls,
|
|
fs: "pyarrow.fs.FileSystem",
|
|
retryable_errors: List[str],
|
|
max_attempts: int = 10,
|
|
max_backoff_s: int = 32,
|
|
):
|
|
if isinstance(fs, RetryingPyFileSystem):
|
|
return fs
|
|
handler = RetryingPyFileSystemHandler(
|
|
fs, retryable_errors, max_attempts, max_backoff_s
|
|
)
|
|
return cls(handler)
|
|
|
|
def __reduce__(self):
|
|
# Serialization of this class breaks for some reason without this
|
|
return (self.__class__, (self.handler,))
|
|
|
|
@classmethod
|
|
def __setstate__(cls, state):
|
|
# Serialization of this class breaks for some reason without this
|
|
return cls(*state)
|
|
|
|
|
|
class RetryingPyFileSystemHandler(pyarrow.fs.FileSystemHandler):
|
|
"""Wrapper for filesystem objects that adds retry functionality for file operations.
|
|
|
|
This class wraps any filesystem object and adds automatic retries for common
|
|
file operations that may fail transiently.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
fs: "pyarrow.fs.FileSystem",
|
|
retryable_errors: List[str] = tuple(),
|
|
max_attempts: int = 10,
|
|
max_backoff_s: int = 32,
|
|
):
|
|
"""Initialize the retrying filesystem wrapper.
|
|
|
|
Args:
|
|
fs: The underlying filesystem to wrap
|
|
retryable_errors: Error substrings that should trigger a retry
|
|
max_attempts: Maximum number of retry attempts
|
|
max_backoff_s: Maximum backoff time in seconds
|
|
"""
|
|
assert not isinstance(
|
|
fs, RetryingPyFileSystem
|
|
), "Cannot wrap a RetryingPyFileSystem"
|
|
self._fs = fs
|
|
self._retryable_errors = retryable_errors
|
|
self._max_attempts = max_attempts
|
|
self._max_backoff_s = max_backoff_s
|
|
|
|
def _retry_operation(self, operation: Callable, description: str):
|
|
"""Execute an operation with retries."""
|
|
return call_with_retry(
|
|
operation,
|
|
description=description,
|
|
match=self._retryable_errors,
|
|
max_attempts=self._max_attempts,
|
|
max_backoff_s=self._max_backoff_s,
|
|
)
|
|
|
|
def unwrap(self):
|
|
return self._fs
|
|
|
|
def copy_file(self, src: str, dest: str):
|
|
"""Copy a file."""
|
|
return self._retry_operation(
|
|
lambda: self._fs.copy_file(src, dest), f"copy file from {src} to {dest}"
|
|
)
|
|
|
|
def create_dir(self, path: str, recursive: bool):
|
|
"""Create a directory and subdirectories."""
|
|
return self._retry_operation(
|
|
lambda: self._fs.create_dir(path, recursive=recursive),
|
|
f"create directory {path}",
|
|
)
|
|
|
|
def delete_dir(self, path: str):
|
|
"""Delete a directory and its contents, recursively."""
|
|
return self._retry_operation(
|
|
lambda: self._fs.delete_dir(path), f"delete directory {path}"
|
|
)
|
|
|
|
def delete_dir_contents(self, path: str, missing_dir_ok: bool = False):
|
|
"""Delete a directory's contents, recursively."""
|
|
return self._retry_operation(
|
|
lambda: self._fs.delete_dir_contents(path, missing_dir_ok=missing_dir_ok),
|
|
f"delete directory contents {path}",
|
|
)
|
|
|
|
def delete_file(self, path: str):
|
|
"""Delete a file."""
|
|
return self._retry_operation(
|
|
lambda: self._fs.delete_file(path), f"delete file {path}"
|
|
)
|
|
|
|
def delete_root_dir_contents(self):
|
|
return self._retry_operation(
|
|
lambda: self._fs.delete_dir_contents("/", accept_root_dir=True),
|
|
"delete root dir contents",
|
|
)
|
|
|
|
def equals(self, other: "pyarrow.fs.FileSystem") -> bool:
|
|
"""Test if this filesystem equals another."""
|
|
return self._fs.equals(other)
|
|
|
|
def get_file_info(self, paths: List[str]):
|
|
"""Get info for the given files."""
|
|
return self._retry_operation(
|
|
lambda: self._fs.get_file_info(paths),
|
|
f"get file info for {paths}",
|
|
)
|
|
|
|
def get_file_info_selector(self, selector):
|
|
return self._retry_operation(
|
|
lambda: self._fs.get_file_info(selector),
|
|
f"get file info for {selector}",
|
|
)
|
|
|
|
def get_type_name(self):
|
|
return "RetryingPyFileSystem"
|
|
|
|
def move(self, src: str, dest: str):
|
|
"""Move / rename a file or directory."""
|
|
return self._retry_operation(
|
|
lambda: self._fs.move(src, dest), f"move from {src} to {dest}"
|
|
)
|
|
|
|
def normalize_path(self, path: str) -> str:
|
|
"""Normalize filesystem path."""
|
|
return self._retry_operation(
|
|
lambda: self._fs.normalize_path(path), f"normalize path {path}"
|
|
)
|
|
|
|
def open_append_stream(
|
|
self,
|
|
path: str,
|
|
metadata=None,
|
|
) -> "pyarrow.NativeFile":
|
|
"""Open an output stream for appending.
|
|
|
|
Compression is disabled in this method because it is handled in the
|
|
PyFileSystem abstract class.
|
|
"""
|
|
return self._retry_operation(
|
|
lambda: self._fs.open_append_stream(
|
|
path,
|
|
compression=None,
|
|
metadata=metadata,
|
|
),
|
|
f"open append stream for {path}",
|
|
)
|
|
|
|
def open_input_stream(
|
|
self,
|
|
path: str,
|
|
) -> "pyarrow.NativeFile":
|
|
"""Open an input stream for sequential reading.
|
|
|
|
Compression is disabled in this method because it is handled in the
|
|
PyFileSystem abstract class.
|
|
"""
|
|
return self._retry_operation(
|
|
lambda: self._fs.open_input_stream(path, compression=None),
|
|
f"open input stream for {path}",
|
|
)
|
|
|
|
def open_output_stream(
|
|
self,
|
|
path: str,
|
|
metadata=None,
|
|
) -> "pyarrow.NativeFile":
|
|
"""Open an output stream for sequential writing."
|
|
|
|
Compression is disabled in this method because it is handled in the
|
|
PyFileSystem abstract class.
|
|
"""
|
|
return self._retry_operation(
|
|
lambda: self._fs.open_output_stream(
|
|
path,
|
|
compression=None,
|
|
metadata=metadata,
|
|
),
|
|
f"open output stream for {path}",
|
|
)
|
|
|
|
def open_input_file(self, path: str) -> "pyarrow.NativeFile":
|
|
"""Open an input file for random access reading."""
|
|
return self._retry_operation(
|
|
lambda: self._fs.open_input_file(path), f"open input file {path}"
|
|
)
|
|
|
|
|
|
def iterate_with_retry(
|
|
iterable_factory: Callable[[], Iterable],
|
|
description: str,
|
|
*,
|
|
match: Optional[List[str]] = None,
|
|
max_attempts: int = 10,
|
|
max_backoff_s: int = 32,
|
|
unwrap_cause: bool = False,
|
|
) -> Any:
|
|
"""Iterate through an iterable with retries.
|
|
|
|
If the iterable raises an exception, this function recreates and re-iterates
|
|
through the iterable, while skipping the items that have already been yielded.
|
|
|
|
Args:
|
|
iterable_factory: A no-argument function that creates the iterable.
|
|
description: An imperitive description of the function being retried. For
|
|
example, "open the file".
|
|
match: A list of patterns to match in the exception message. Each pattern
|
|
is first checked as a substring, then as a regex. If ``None``, any
|
|
error is retried.
|
|
max_attempts: The maximum number of attempts to retry.
|
|
max_backoff_s: The maximum number of seconds to backoff.
|
|
unwrap_cause: If ``True``, include ``e.__cause__`` in the string matched
|
|
against ``match``. Use this when exceptions are wrapped (e.g.
|
|
``UserCodeException``) and the original error is in the cause chain.
|
|
"""
|
|
assert max_attempts >= 1, f"`max_attempts` must be positive. Got {max_attempts}."
|
|
|
|
num_items_yielded = 0
|
|
for attempt in range(max_attempts):
|
|
try:
|
|
iterable = iterable_factory()
|
|
for item_index, item in enumerate(iterable):
|
|
if item_index < num_items_yielded:
|
|
# Skip items that have already been yielded.
|
|
continue
|
|
|
|
num_items_yielded += 1
|
|
yield item
|
|
return
|
|
except Exception as e:
|
|
error_str = format_exception(e, include_cause=unwrap_cause)
|
|
is_retryable = match is None or any(
|
|
matches_error(pattern, error_str) for pattern in match
|
|
)
|
|
if is_retryable and attempt + 1 < max_attempts:
|
|
# Retry with binary expoential backoff with random jitter.
|
|
backoff = min((2 ** (attempt + 1)), max_backoff_s) * random.random()
|
|
logger.debug(
|
|
f"Retrying attempt {attempt + 1} to {description} "
|
|
f"after {backoff:.1f}s due to: {error_str}"
|
|
)
|
|
time.sleep(backoff)
|
|
else:
|
|
if unwrap_cause:
|
|
raise e
|
|
raise e from None
|
|
|
|
|
|
def convert_bytes_to_human_readable_str(num_bytes: int) -> str:
|
|
if num_bytes >= 1e9:
|
|
num_bytes_str = f"{round(num_bytes / 1e9)}GB"
|
|
elif num_bytes >= 1e6:
|
|
num_bytes_str = f"{round(num_bytes / 1e6)}MB"
|
|
else:
|
|
num_bytes_str = f"{round(num_bytes / 1e3)}KB"
|
|
return num_bytes_str
|
|
|
|
|
|
def _validate_rows_per_file_args(
|
|
*,
|
|
num_rows_per_file: Optional[int] = None,
|
|
min_rows_per_file: Optional[int] = None,
|
|
max_rows_per_file: Optional[int] = None,
|
|
) -> Tuple[Optional[int], Optional[int]]:
|
|
"""Helper method to validate and handle rows per file arguments.
|
|
|
|
Args:
|
|
num_rows_per_file: Deprecated parameter for number of rows per file
|
|
min_rows_per_file: New parameter for minimum rows per file
|
|
max_rows_per_file: New parameter for maximum rows per file
|
|
|
|
Returns:
|
|
A tuple of (effective_min_rows_per_file, effective_max_rows_per_file)
|
|
"""
|
|
if num_rows_per_file is not None:
|
|
import warnings
|
|
|
|
warnings.warn(
|
|
"`num_rows_per_file` is deprecated and will be removed in a future release. "
|
|
"Use `min_rows_per_file` instead.",
|
|
DeprecationWarning,
|
|
stacklevel=3,
|
|
)
|
|
if min_rows_per_file is not None:
|
|
raise ValueError(
|
|
"Cannot specify both `num_rows_per_file` and `min_rows_per_file`. "
|
|
"Use `min_rows_per_file` as `num_rows_per_file` is deprecated."
|
|
)
|
|
min_rows_per_file = num_rows_per_file
|
|
|
|
# Validate max_rows_per_file
|
|
if max_rows_per_file is not None and max_rows_per_file <= 0:
|
|
raise ValueError("max_rows_per_file must be a positive integer")
|
|
|
|
# Validate min_rows_per_file
|
|
if min_rows_per_file is not None and min_rows_per_file <= 0:
|
|
raise ValueError("min_rows_per_file must be a positive integer")
|
|
|
|
# Validate that max >= min if both are specified
|
|
if (
|
|
min_rows_per_file is not None
|
|
and max_rows_per_file is not None
|
|
and min_rows_per_file > max_rows_per_file
|
|
):
|
|
raise ValueError(
|
|
f"min_rows_per_file ({min_rows_per_file}) cannot be greater than "
|
|
f"max_rows_per_file ({max_rows_per_file})"
|
|
)
|
|
|
|
return min_rows_per_file, max_rows_per_file
|
|
|
|
|
|
def is_nan(value) -> bool:
|
|
"""Returns true if provide value is ``np.nan``"""
|
|
|
|
try:
|
|
return isinstance(value, float) and np.isnan(value)
|
|
except TypeError:
|
|
return False
|
|
|
|
|
|
def is_null(value: Any) -> bool:
|
|
"""This generalization of ``is_nan`` util qualifying both None and np.nan
|
|
as null values"""
|
|
return value is None or is_nan(value)
|
|
|
|
|
|
def keys_equal(keys1, keys2):
|
|
if len(keys1) != len(keys2):
|
|
return False
|
|
for k1, k2 in zip(keys1, keys2):
|
|
if not ((is_nan(k1) and is_nan(k2)) or k1 == k2):
|
|
return False
|
|
return True
|
|
|
|
|
|
def get_total_obj_store_mem_on_node() -> int:
|
|
"""Return the total object store memory on the current node.
|
|
|
|
This function incurs an RPC. Use it cautiously.
|
|
"""
|
|
node_id = ray.get_runtime_context().get_node_id()
|
|
total_resources_per_node = ray._private.state.total_resources_per_node()
|
|
assert (
|
|
node_id in total_resources_per_node
|
|
), f"Expected node '{node_id}' to be in resources: {total_resources_per_node}"
|
|
return total_resources_per_node[node_id]["object_store_memory"]
|
|
|
|
|
|
class MemoryProfiler:
|
|
"""A context manager that polls the USS of the current process.
|
|
|
|
This class approximates the max USS by polling memory and subtracting the amount
|
|
of shared memory from the resident set size (RSS). It's not a
|
|
perfect estimate (it can underestimate, e.g., if you use Torch tensors), but
|
|
estimating the USS is much cheaper than computing the actual USS.
|
|
|
|
.. warning::
|
|
|
|
This class only works with Linux. If you use it on another platform,
|
|
`estimate_max_uss` always returns ``None``.
|
|
|
|
Example:
|
|
|
|
.. testcode::
|
|
|
|
with MemoryProfiler(poll_interval_s=1.0) as profiler:
|
|
for i in range(10):
|
|
... # Your code here
|
|
print(f"Max USS: {profiler.estimate_max_uss()}")
|
|
profiler.reset()
|
|
"""
|
|
|
|
def __init__(self, poll_interval_s: Optional[float]):
|
|
"""Initialize the memory profiler.
|
|
|
|
Args:
|
|
poll_interval_s: The interval to poll the USS of the process. If `None`,
|
|
this class won't poll the USS.
|
|
"""
|
|
self._poll_interval_s = poll_interval_s
|
|
|
|
self._process = psutil.Process(os.getpid())
|
|
self._max_uss = None
|
|
self._max_uss_lock = threading.Lock()
|
|
|
|
self._uss_poll_thread = None
|
|
self._stop_uss_poll_event = None
|
|
|
|
def __repr__(self):
|
|
return f"MemoryProfiler(poll_interval_s={self._poll_interval_s})"
|
|
|
|
def __enter__(self):
|
|
if self._can_estimate_uss() and self._poll_interval_s is not None:
|
|
(
|
|
self._uss_poll_thread,
|
|
self._stop_uss_poll_event,
|
|
) = self._start_uss_poll_thread()
|
|
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
if self._uss_poll_thread is not None:
|
|
self._stop_uss_poll_thread()
|
|
|
|
def estimate_max_uss(self) -> Optional[int]:
|
|
"""Get an estimate of the max USS of the current process.
|
|
|
|
Returns:
|
|
An estimate of the max USS of the process in bytes, or ``None`` if an
|
|
estimate isn't available.
|
|
"""
|
|
if not self._can_estimate_uss():
|
|
assert self._max_uss is None
|
|
return None
|
|
|
|
with self._max_uss_lock:
|
|
if self._max_uss is None:
|
|
self._max_uss = self._estimate_uss()
|
|
else:
|
|
self._max_uss = max(self._max_uss, self._estimate_uss())
|
|
|
|
assert self._max_uss is not None
|
|
return self._max_uss
|
|
|
|
def reset(self):
|
|
with self._max_uss_lock:
|
|
self._max_uss = None
|
|
|
|
def _start_uss_poll_thread(self) -> Tuple[threading.Thread, threading.Event]:
|
|
assert self._poll_interval_s is not None
|
|
assert self._can_estimate_uss()
|
|
|
|
stop_event = threading.Event()
|
|
|
|
def poll_uss():
|
|
while not stop_event.is_set():
|
|
with self._max_uss_lock:
|
|
if self._max_uss is None:
|
|
self._max_uss = self._estimate_uss()
|
|
else:
|
|
self._max_uss = max(self._max_uss, self._estimate_uss())
|
|
stop_event.wait(self._poll_interval_s)
|
|
|
|
thread = threading.Thread(target=poll_uss, daemon=True)
|
|
thread.start()
|
|
|
|
return thread, stop_event
|
|
|
|
def _stop_uss_poll_thread(self):
|
|
if self._stop_uss_poll_event is not None:
|
|
self._stop_uss_poll_event.set()
|
|
self._uss_poll_thread.join()
|
|
|
|
def _estimate_uss(self) -> int:
|
|
assert self._can_estimate_uss()
|
|
memory_info = self._process.memory_info()
|
|
# Estimate the USS (the amount of memory that'd be free if we killed the
|
|
# process right now) as the difference between the RSS (total physical memory)
|
|
# and amount of shared physical memory.
|
|
return memory_info.rss - memory_info.shared
|
|
|
|
@staticmethod
|
|
@functools.cache
|
|
def _can_estimate_uss() -> bool:
|
|
# MacOS and Windows don't have the 'shared' attribute of `memory_info()`.
|
|
return platform.system() == "Linux"
|
|
|
|
|
|
def unzip(data: List[Tuple[Any, ...]]) -> Tuple[List[Any], ...]:
|
|
"""Unzips a list of tuples into a tuple of lists
|
|
|
|
Args:
|
|
data: A list of tuples to unzip.
|
|
|
|
Returns:
|
|
A tuple of lists, where each list corresponds to one element of the tuples in
|
|
the input list.
|
|
"""
|
|
return tuple(map(list, zip(*data)))
|
|
|
|
|
|
def _sort_df(df: pd.DataFrame) -> pd.DataFrame:
|
|
"""Sort DataFrame by columns and rows, and also handle unhashable types."""
|
|
df = df.copy()
|
|
|
|
def to_sortable(x):
|
|
if isinstance(x, (list, np.ndarray)):
|
|
return tuple(to_sortable(i) for i in x)
|
|
if isinstance(x, dict):
|
|
return tuple(sorted((k, to_sortable(v)) for k, v in x.items()))
|
|
return x
|
|
|
|
def needs_proxy(dtype: "np.dtype | pd.api.extensions.ExtensionDtype") -> bool:
|
|
if dtype == "object":
|
|
return True
|
|
if isinstance(dtype, pd.ArrowDtype):
|
|
pa_type = dtype.pyarrow_dtype
|
|
return (
|
|
pyarrow.types.is_list(pa_type)
|
|
or pyarrow.types.is_large_list(pa_type)
|
|
or pyarrow.types.is_fixed_size_list(pa_type)
|
|
or pyarrow.types.is_struct(pa_type)
|
|
or pyarrow.types.is_map(pa_type)
|
|
)
|
|
return False
|
|
|
|
# Cast Arrow-backed *float* columns to numpy floats — pandas's multi-column
|
|
# ``sort_values`` builds an ordered Categorical per key column, which rejects
|
|
# arrow-backed floats containing both ``-0.0`` and ``0.0`` ("categories must
|
|
# be unique") because they're stored distinctly but compare equal under
|
|
# numpy. We deliberately leave other Arrow scalar types alone: int columns
|
|
# may contain ``<NA>`` (which can't fit in numpy ``int64``), and string
|
|
# columns sort ``<NA>`` first whereas object-with-``None`` sorts last,
|
|
# which would diverge from the expected DataFrame on the other side.
|
|
arrow_to_numpy = {}
|
|
for col in df.columns:
|
|
dtype = df[col].dtype
|
|
if isinstance(dtype, pd.ArrowDtype) and pyarrow.types.is_floating(
|
|
dtype.pyarrow_dtype
|
|
):
|
|
numpy_dtype = getattr(dtype, "numpy_dtype", None)
|
|
if numpy_dtype is not None:
|
|
arrow_to_numpy[col] = numpy_dtype
|
|
if arrow_to_numpy:
|
|
df = df.astype(arrow_to_numpy)
|
|
|
|
sort_cols = []
|
|
temp_cols = []
|
|
# Sort by all columns to ensure deterministic order.
|
|
columns = sorted(df.columns)
|
|
|
|
for col in columns:
|
|
if needs_proxy(df[col].dtype):
|
|
# Create a temporary column for sorting to handle unhashable types.
|
|
# Use UUID to avoid collisions with existing column names.
|
|
temp_col = f"__sort_proxy_{uuid.uuid4().hex}_{col}__"
|
|
df[temp_col] = df[col].map(to_sortable)
|
|
sort_cols.append(temp_col)
|
|
temp_cols.append(temp_col)
|
|
else:
|
|
sort_cols.append(col)
|
|
|
|
sorted_df = df.sort_values(sort_cols)
|
|
|
|
if temp_cols:
|
|
sorted_df = sorted_df.drop(columns=temp_cols)
|
|
|
|
return sorted_df
|
|
|
|
|
|
def rows_same(actual: pd.DataFrame, expected: pd.DataFrame) -> bool:
|
|
"""Check if two DataFrames have the same rows.
|
|
|
|
Unlike the built-in pandas equals method, this function ignores indices and the
|
|
order of rows. This is useful for testing Ray Data because its interface doesn't
|
|
usually guarantee the order of rows.
|
|
"""
|
|
if len(actual) != len(expected):
|
|
return False
|
|
|
|
if len(actual) == 0:
|
|
return True
|
|
|
|
pd.testing.assert_frame_equal(
|
|
_sort_df(actual).reset_index(drop=True),
|
|
_sort_df(expected).reset_index(drop=True),
|
|
check_dtype=False,
|
|
)
|
|
return True
|
|
|
|
|
|
def merge_resources_to_ray_remote_args(
|
|
num_cpus: Optional[int],
|
|
num_gpus: Optional[int],
|
|
memory: Optional[int],
|
|
ray_remote_args: Dict[str, Any],
|
|
) -> Dict[str, Any]:
|
|
"""Convert the given resources to Ray remote args.
|
|
|
|
Args:
|
|
num_cpus: The number of CPUs to be added to the Ray remote args.
|
|
num_gpus: The number of GPUs to be added to the Ray remote args.
|
|
memory: The memory to be added to the Ray remote args.
|
|
ray_remote_args: The Ray remote args to be merged.
|
|
|
|
Returns:
|
|
The converted arguments.
|
|
"""
|
|
ray_remote_args = ray_remote_args.copy()
|
|
if num_cpus is not None:
|
|
ray_remote_args["num_cpus"] = num_cpus
|
|
if num_gpus is not None:
|
|
ray_remote_args["num_gpus"] = num_gpus
|
|
if memory is not None:
|
|
ray_remote_args["memory"] = memory
|
|
return ray_remote_args
|
|
|
|
|
|
@DeveloperAPI
|
|
def infer_compression(path: str) -> Optional[str]:
|
|
import pyarrow as pa
|
|
|
|
compression = None
|
|
try:
|
|
# Try to detect compression codec from path.
|
|
compression = pa.Codec.detect(path).name
|
|
except (ValueError, TypeError):
|
|
# Arrow's compression inference on the file path doesn't work for Snappy, so we double-check ourselves.
|
|
import pathlib
|
|
|
|
suffix = pathlib.Path(path).suffix
|
|
if suffix and suffix[1:] == "snappy":
|
|
compression = "snappy"
|
|
return compression
|
|
|
|
|
|
def get_max_task_capacity(
|
|
allocated_resources: Optional["ExecutionResources"],
|
|
min_scheduling_resources: "ExecutionResources",
|
|
) -> float:
|
|
if allocated_resources is None:
|
|
return 0
|
|
|
|
if min_scheduling_resources.copy(object_store_memory=0).is_zero():
|
|
return float("inf")
|
|
|
|
capacity = allocated_resources.floordiv(min_scheduling_resources)
|
|
return min(capacity.cpu, capacity.gpu, capacity.memory)
|
|
|
|
|
|
def explain_plan(logical_plan: "LogicalPlan") -> str:
|
|
"""Return a string representation of the logical and physical plan."""
|
|
from ray.data._internal.dataset_repr import _format_operator_dag
|
|
from ray.data._internal.logical.optimizers import (
|
|
LogicalOptimizer,
|
|
PhysicalOptimizer,
|
|
)
|
|
from ray.data._internal.planner import create_planner
|
|
|
|
sections = []
|
|
|
|
def _add_section(title, plan):
|
|
plan_str, _ = _format_operator_dag(plan.dag, show_op_repr=True)
|
|
banner = f"\n-------- {title} --------\n"
|
|
sections.append(f"{banner}{plan_str}")
|
|
|
|
_add_section("Logical Plan", logical_plan)
|
|
|
|
optimized_logical = LogicalOptimizer().optimize(logical_plan)
|
|
_add_section("Logical Plan (Optimized)", optimized_logical)
|
|
|
|
physical_plan, _ = create_planner().plan(optimized_logical)
|
|
_add_section("Physical Plan", physical_plan)
|
|
|
|
optimized_physical = PhysicalOptimizer().optimize(physical_plan)
|
|
_add_section("Physical Plan (Optimized)", optimized_physical)
|
|
|
|
return "".join(sections)
|