chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
from .common import NodeIdStr
|
||||
from .execution_options import ExecutionOptions, ExecutionResources
|
||||
from .executor import Executor, OutputIterator
|
||||
from .physical_operator import PhysicalOperator, ReportsExtraResourceUsage
|
||||
from .ref_bundle import BlockEntry, BlockSlice, RefBundle
|
||||
from .task_context import TaskContext
|
||||
from .transform_fn import AllToAllTransformFn
|
||||
|
||||
__all__ = [
|
||||
"AllToAllTransformFn",
|
||||
"BlockEntry",
|
||||
"BlockSlice",
|
||||
"ExecutionOptions",
|
||||
"ExecutionResources",
|
||||
"Executor",
|
||||
"NodeIdStr",
|
||||
"OutputIterator",
|
||||
"PhysicalOperator",
|
||||
"RefBundle",
|
||||
"ReportsExtraResourceUsage",
|
||||
"TaskContext",
|
||||
]
|
||||
@@ -0,0 +1,186 @@
|
||||
import bisect
|
||||
import json
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from ray.data._internal.util import GiB, KiB, MiB
|
||||
from ray.util.metrics import Histogram
|
||||
|
||||
# Node id string returned by `ray.get_runtime_context().get_node_id()`.
|
||||
NodeIdStr = str
|
||||
|
||||
# Used for time-based histograms (e.g. task completion time, block completion time)
|
||||
histogram_buckets_s = [
|
||||
0.1,
|
||||
0.25,
|
||||
0.5,
|
||||
1.0,
|
||||
2.5,
|
||||
5.0,
|
||||
7.5,
|
||||
10.0,
|
||||
15.0,
|
||||
20.0,
|
||||
25.0,
|
||||
50.0,
|
||||
75.0,
|
||||
100.0,
|
||||
150.0,
|
||||
500.0,
|
||||
1000.0,
|
||||
2500.0,
|
||||
5000.0,
|
||||
]
|
||||
|
||||
# Used for size-based histograms (e.g. block size in bytes)
|
||||
histogram_buckets_bytes = [
|
||||
KiB,
|
||||
8 * KiB,
|
||||
64 * KiB,
|
||||
128 * KiB,
|
||||
256 * KiB,
|
||||
512 * KiB,
|
||||
MiB,
|
||||
8 * MiB,
|
||||
64 * MiB,
|
||||
128 * MiB,
|
||||
256 * MiB,
|
||||
512 * MiB,
|
||||
GiB,
|
||||
4 * GiB,
|
||||
16 * GiB,
|
||||
64 * GiB,
|
||||
128 * GiB,
|
||||
256 * GiB,
|
||||
512 * GiB,
|
||||
1024 * GiB,
|
||||
4096 * GiB,
|
||||
]
|
||||
|
||||
# Used for row count-based histograms (e.g. block size in rows)
|
||||
histogram_bucket_rows = [
|
||||
1,
|
||||
5,
|
||||
10,
|
||||
25,
|
||||
50,
|
||||
100,
|
||||
250,
|
||||
500,
|
||||
1_000,
|
||||
2_500,
|
||||
5_000,
|
||||
10_000,
|
||||
25_000,
|
||||
50_000,
|
||||
100_000,
|
||||
250_000,
|
||||
500_000,
|
||||
1_000_000,
|
||||
2_500_000,
|
||||
5_000_000,
|
||||
10_000_000,
|
||||
]
|
||||
|
||||
|
||||
class RuntimeMetricsHistogram:
|
||||
"""
|
||||
Class that tracks a histogram of values.
|
||||
|
||||
Contains helper methods to record the values and apply those values to a `ray.util.metrics.Histogram` metric.
|
||||
"""
|
||||
|
||||
def __init__(self, boundaries: List[float]):
|
||||
self._boundaries = boundaries
|
||||
# Initialize bucket counts to 0 (+1 additional bucket to represent the +Inf bucket)
|
||||
self._bucket_counts = [0 for _ in range(len(boundaries) + 1)]
|
||||
self._memoized_avg = None
|
||||
|
||||
def observe(self, value: float, num_observations: int = 1):
|
||||
self._bucket_counts[self._find_bucket_index(value)] += num_observations
|
||||
self._memoized_avg = None
|
||||
|
||||
def export_to(
|
||||
self,
|
||||
metric: Histogram,
|
||||
tags: Dict[str, str],
|
||||
):
|
||||
"""
|
||||
This method calculates the difference between the current bucket counts and the previous bucket counts,
|
||||
and applies those observations to the metric.
|
||||
|
||||
This method stores the previous_bucket_counts in the metric as `last_applied_bucket_counts_for_tags`.
|
||||
"""
|
||||
if getattr(metric, "last_applied_bucket_counts_for_tags", None) is None:
|
||||
metric.last_applied_bucket_counts_for_tags = {}
|
||||
tags_key = json.dumps(tags, sort_keys=True)
|
||||
previous_bucket_counts = metric.last_applied_bucket_counts_for_tags.get(
|
||||
tags_key
|
||||
)
|
||||
|
||||
for i in range(len(self._bucket_counts)):
|
||||
# Pick a value between the boundaries so the sample falls into the right bucket.
|
||||
# We need to calculate the mid point because choosing the exact boundary value
|
||||
# seems to have unreliable behavior on which bucket it ends up in.
|
||||
boundary_upper_bound = (
|
||||
self._boundaries[i]
|
||||
if i < len(self._bucket_counts) - 1
|
||||
# Since choosing an exact boundary value is unreliable to if it'll
|
||||
# end up in the upper or lower bucket, we add a small buffer to the
|
||||
# last boundary. The amount of the value doesn't matter much
|
||||
# since it's the last bucket and should go to infinity.
|
||||
else self._boundaries[-1] + 100
|
||||
)
|
||||
boundary_lower_bound = self._boundaries[i - 1] if i > 0 else 0
|
||||
bucket_value = (boundary_upper_bound + boundary_lower_bound) / 2
|
||||
|
||||
# Calculate how many observations to add to the metric
|
||||
diff = (
|
||||
self._bucket_counts[i] - previous_bucket_counts[i]
|
||||
if previous_bucket_counts is not None
|
||||
else self._bucket_counts[i]
|
||||
)
|
||||
for _ in range(diff):
|
||||
metric.observe(bucket_value, tags)
|
||||
|
||||
metric.last_applied_bucket_counts_for_tags[
|
||||
tags_key
|
||||
] = self._bucket_counts.copy()
|
||||
|
||||
def __repr__(self):
|
||||
if self._memoized_avg is None:
|
||||
self._memoized_avg = self._calculate_average_value()
|
||||
total_samples, average = self._memoized_avg
|
||||
return f"(samples: {total_samples}, avg: {average:.2f})"
|
||||
|
||||
def _calculate_average_value(self) -> Tuple[int, float]:
|
||||
"""
|
||||
Calculate the average value of all samples.
|
||||
|
||||
Used to show a representative value for the histogram when
|
||||
printing the histogram as a string.
|
||||
"""
|
||||
total_samples = sum(self._bucket_counts)
|
||||
if total_samples == 0:
|
||||
return total_samples, 0
|
||||
|
||||
weighted_sum = 0.0
|
||||
for i, count in enumerate(self._bucket_counts):
|
||||
if count > 0:
|
||||
# Calculate representative value for this bucket
|
||||
if i == 0:
|
||||
# First bucket: 0 to first boundary
|
||||
bucket_value = self._boundaries[0] / 2
|
||||
elif i == len(self._bucket_counts) - 1:
|
||||
# Last bucket: last boundary to +inf
|
||||
bucket_value = self._boundaries[-1] * 1.5
|
||||
else:
|
||||
# Middle buckets: between boundaries
|
||||
bucket_value = (self._boundaries[i - 1] + self._boundaries[i]) / 2
|
||||
|
||||
weighted_sum += bucket_value * count
|
||||
|
||||
average = weighted_sum / total_samples
|
||||
return total_samples, average
|
||||
|
||||
def _find_bucket_index(self, value: float):
|
||||
return bisect.bisect_left(self._boundaries, value)
|
||||
@@ -0,0 +1,178 @@
|
||||
import math
|
||||
from typing import Dict, Optional, Union
|
||||
|
||||
try:
|
||||
from datasketches import kll_doubles_sketch
|
||||
|
||||
_DATASKETCHES_AVAILABLE = True
|
||||
except ImportError:
|
||||
_DATASKETCHES_AVAILABLE = False
|
||||
|
||||
|
||||
class DistributionTracker:
|
||||
"""Tracks the running mean, variance, min, max, and approximate percentiles of a
|
||||
stream of values using Welford's algorithm for moments and a KLL sketch for
|
||||
quantiles.
|
||||
|
||||
More on Welford's algorithm:
|
||||
https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford's_online_algorithm
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._count = 0
|
||||
self._mean = 0.0
|
||||
self._m2 = 0.0
|
||||
self._min = float("inf")
|
||||
self._max = float("-inf")
|
||||
self._sketch = kll_doubles_sketch(200) if _DATASKETCHES_AVAILABLE else None
|
||||
|
||||
def add_sample(self, value: float) -> None:
|
||||
self._count += 1
|
||||
|
||||
delta = value - self._mean
|
||||
self._mean += delta / self._count
|
||||
delta2 = value - self._mean
|
||||
self._m2 += delta * delta2
|
||||
|
||||
if value < self._min:
|
||||
self._min = value
|
||||
if value > self._max:
|
||||
self._max = value
|
||||
|
||||
if self._sketch is not None:
|
||||
self._sketch.update(value)
|
||||
|
||||
def merge(self, other: "DistributionTracker") -> None:
|
||||
"""Merge another tracker into this one (associative, commutative).
|
||||
|
||||
Uses Chan's parallel variant of Welford's algorithm for moments.
|
||||
See: https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford:~:text=Parallel%20algorithm%5Bedit%5D
|
||||
"""
|
||||
if other is self:
|
||||
# Merging an accumulator into itself would double its samples
|
||||
# (count, m2, and the sketch), so treat it as a no-op.
|
||||
return
|
||||
if other._count == 0:
|
||||
return
|
||||
if self._count == 0:
|
||||
self._count = other._count
|
||||
self._mean = other._mean
|
||||
self._m2 = other._m2
|
||||
self._min = other._min
|
||||
self._max = other._max
|
||||
else:
|
||||
delta = other._mean - self._mean
|
||||
total = self._count + other._count
|
||||
self._m2 += other._m2 + (delta**2) * self._count * other._count / total
|
||||
self._mean = (self._count * self._mean + other._count * other._mean) / total
|
||||
self._count = total
|
||||
self._min = min(self._min, other._min)
|
||||
self._max = max(self._max, other._max)
|
||||
if self._sketch is None or other._sketch is None:
|
||||
# Moments above still merged; quantile detail is lost for the
|
||||
# side(s) without a sketch.
|
||||
self._sketch = None
|
||||
else:
|
||||
try:
|
||||
self._sketch.merge(other._sketch)
|
||||
except Exception:
|
||||
self._sketch = None
|
||||
|
||||
@property
|
||||
def num_samples(self) -> int:
|
||||
return self._count
|
||||
|
||||
@property
|
||||
def mean(self) -> float:
|
||||
return self._mean
|
||||
|
||||
@property
|
||||
def variance(self) -> float:
|
||||
if self._count < 2:
|
||||
return 0.0
|
||||
return self._m2 / (self._count - 1)
|
||||
|
||||
@property
|
||||
def stddev(self) -> float:
|
||||
return math.sqrt(self.variance)
|
||||
|
||||
@property
|
||||
def min(self) -> Optional[float]:
|
||||
if self._count == 0:
|
||||
return None
|
||||
return self._min
|
||||
|
||||
@property
|
||||
def max(self) -> Optional[float]:
|
||||
if self._count == 0:
|
||||
return None
|
||||
return self._max
|
||||
|
||||
def _quantile(self, q: float) -> Optional[float]:
|
||||
if self._sketch is None or self._count == 0:
|
||||
return None
|
||||
return self._sketch.get_quantiles([q])[0]
|
||||
|
||||
@property
|
||||
def p25(self) -> Optional[float]:
|
||||
return self._quantile(0.25)
|
||||
|
||||
@property
|
||||
def p50(self) -> Optional[float]:
|
||||
return self._quantile(0.5)
|
||||
|
||||
@property
|
||||
def p75(self) -> Optional[float]:
|
||||
return self._quantile(0.75)
|
||||
|
||||
@property
|
||||
def p90(self) -> Optional[float]:
|
||||
return self._quantile(0.9)
|
||||
|
||||
@property
|
||||
def p95(self) -> Optional[float]:
|
||||
return self._quantile(0.95)
|
||||
|
||||
@property
|
||||
def p99(self) -> Optional[float]:
|
||||
return self._quantile(0.99)
|
||||
|
||||
def as_dict(self) -> Dict[str, Optional[Union[int, float]]]:
|
||||
return {
|
||||
"num_samples": self.num_samples,
|
||||
"mean": self.mean,
|
||||
"variance": self.variance,
|
||||
"min": self.min,
|
||||
"max": self.max,
|
||||
"p25": self.p25,
|
||||
"p50": self.p50,
|
||||
"p75": self.p75,
|
||||
"p90": self.p90,
|
||||
"p95": self.p95,
|
||||
"p99": self.p99,
|
||||
}
|
||||
|
||||
# ``kll_doubles_sketch`` is a C++-backed object that does not
|
||||
# pickle natively. DistributionTracker rides on DatasetStats
|
||||
# (via Timer), which is cloudpickled when Datasets cross actor /
|
||||
# process boundaries — without these hooks any such transfer
|
||||
# raises ``TypeError: cannot pickle 'kll_doubles_sketch' object``.
|
||||
# The sketch exposes its own byte serialization, so we round-trip
|
||||
# through that.
|
||||
def __getstate__(self):
|
||||
state = self.__dict__.copy()
|
||||
if self._sketch is not None:
|
||||
state["_sketch"] = self._sketch.serialize()
|
||||
return state
|
||||
|
||||
def __setstate__(self, state):
|
||||
self.__dict__.update(state)
|
||||
# If the source had datasketches but this side doesn't, drop
|
||||
# the sketch (percentiles will return None — same fallback as a
|
||||
# default construction without datasketches installed).
|
||||
if self._sketch is not None and not _DATASKETCHES_AVAILABLE:
|
||||
self._sketch = None
|
||||
elif self._sketch is not None and not isinstance(
|
||||
self._sketch, kll_doubles_sketch
|
||||
):
|
||||
self._sketch = kll_doubles_sketch.deserialize(self._sketch)
|
||||
@@ -0,0 +1,512 @@
|
||||
import functools
|
||||
import math
|
||||
import operator
|
||||
import os
|
||||
import warnings
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Union
|
||||
|
||||
from .common import NodeIdStr
|
||||
from ray.data._internal.execution.util import memory_string
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
|
||||
class ExecutionResources:
|
||||
"""Specifies resources usage or resource limits for execution.
|
||||
|
||||
By default this class represents resource usage. Use `for_limits` or
|
||||
set `default_to_inf` to True to create an object that represents resource limits.
|
||||
"""
|
||||
|
||||
# ``__slots__`` keeps instances small and makes attribute access go through
|
||||
# slot descriptors instead of a per-instance ``__dict__``. The scheduler
|
||||
# constructs many of these per iteration (every ``add``/``subtract``/
|
||||
# ``max``/``copy`` returns a new object), so this is a hot-path win.
|
||||
__slots__ = ("_cpu", "_gpu", "_object_store_memory", "_memory")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cpu: Optional[float] = None,
|
||||
gpu: Optional[float] = None,
|
||||
object_store_memory: Optional[float] = None,
|
||||
memory: Optional[float] = None,
|
||||
):
|
||||
"""Initializes ExecutionResources.
|
||||
Args:
|
||||
cpu: Amount of logical CPU slots.
|
||||
gpu: Amount of logical GPU slots.
|
||||
object_store_memory: Amount of object store memory.
|
||||
memory: Amount of logical memory in bytes.
|
||||
"""
|
||||
|
||||
# NOTE: Ray Core allocates fractional resources in up to 5th decimal
|
||||
# digit, hence we round the values here up to it
|
||||
self._cpu: Optional[float] = safe_round(cpu, 5)
|
||||
self._gpu: Optional[float] = safe_round(gpu, 5)
|
||||
self._object_store_memory: Optional[float] = safe_round(object_store_memory, 0)
|
||||
self._memory: Optional[float] = safe_round(memory, 0)
|
||||
|
||||
@classmethod
|
||||
def from_resource_dict(
|
||||
cls,
|
||||
resource_dict: Dict[str, float],
|
||||
):
|
||||
"""Create an ExecutionResources object from a resource dict."""
|
||||
return ExecutionResources(
|
||||
cpu=resource_dict.get("CPU", None) or resource_dict.get("num_cpus", None),
|
||||
gpu=resource_dict.get("GPU", None) or resource_dict.get("num_gpus", None),
|
||||
object_store_memory=resource_dict.get("object_store_memory", None),
|
||||
memory=resource_dict.get("memory", None),
|
||||
)
|
||||
|
||||
def to_resource_dict(self) -> Dict[str, float]:
|
||||
"""Convert this ExecutionResources object to a resource dict."""
|
||||
return {
|
||||
"CPU": self.cpu,
|
||||
"GPU": self.gpu,
|
||||
"object_store_memory": self.object_store_memory,
|
||||
"memory": self.memory,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def for_limits(
|
||||
cls,
|
||||
cpu: Optional[float] = None,
|
||||
gpu: Optional[float] = None,
|
||||
object_store_memory: Optional[float] = None,
|
||||
memory: Optional[float] = None,
|
||||
) -> "ExecutionResources":
|
||||
"""Create an ExecutionResources object that represents resource limits.
|
||||
|
||||
Args:
|
||||
cpu: Amount of logical CPU slots.
|
||||
gpu: Amount of logical GPU slots.
|
||||
object_store_memory: Amount of object store memory.
|
||||
memory: Amount of logical memory in bytes.
|
||||
|
||||
Returns:
|
||||
An ``ExecutionResources`` with the given limits (defaulting to
|
||||
infinity for any unspecified field).
|
||||
"""
|
||||
return ExecutionResources(
|
||||
cpu=safe_or(cpu, float("inf")),
|
||||
gpu=safe_or(gpu, float("inf")),
|
||||
object_store_memory=safe_or(object_store_memory, float("inf")),
|
||||
memory=safe_or(memory, float("inf")),
|
||||
)
|
||||
|
||||
@property
|
||||
def cpu(self) -> float:
|
||||
return self._cpu or 0.0
|
||||
|
||||
@property
|
||||
def gpu(self) -> float:
|
||||
return self._gpu or 0.0
|
||||
|
||||
@property
|
||||
def object_store_memory(self) -> float:
|
||||
return self._object_store_memory or 0
|
||||
|
||||
@property
|
||||
def memory(self) -> float:
|
||||
return self._memory or 0
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"ExecutionResources(cpu={self.cpu}, gpu={self.gpu}, "
|
||||
f"object_store_memory={self.object_store_memory_str()}, "
|
||||
f"memory={self.memory_str()})"
|
||||
)
|
||||
|
||||
def __eq__(self, other: "ExecutionResources") -> bool:
|
||||
return (
|
||||
self.cpu == other.cpu
|
||||
and self.gpu == other.gpu
|
||||
and self.object_store_memory == other.object_store_memory
|
||||
and self.memory == other.memory
|
||||
)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(
|
||||
(
|
||||
self.cpu,
|
||||
self.gpu,
|
||||
self.object_store_memory,
|
||||
self.memory,
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@functools.cache
|
||||
def zero(cls) -> "ExecutionResources":
|
||||
"""Returns an ExecutionResources object with zero resources.
|
||||
|
||||
Returns a cached, shared singleton (``functools.cache`` keyed on ``cls``)
|
||||
-- ``zero()`` is called all over the scheduler hot path (e.g.
|
||||
``.max(zero())``) and instances are immutable in practice (every
|
||||
arithmetic op returns a new object and there are no setters), so sharing
|
||||
one instance is safe and avoids the per-call allocation.
|
||||
"""
|
||||
return ExecutionResources(0.0, 0.0, 0.0, 0.0)
|
||||
|
||||
@classmethod
|
||||
@functools.cache
|
||||
def inf(cls) -> "ExecutionResources":
|
||||
"""Returns an ExecutionResources object with infinite resources.
|
||||
|
||||
Returns a cached, shared singleton (see :meth:`zero` for why this is
|
||||
safe).
|
||||
"""
|
||||
return ExecutionResources.for_limits()
|
||||
|
||||
@classmethod
|
||||
def combine(
|
||||
cls,
|
||||
resources: Iterable["ExecutionResources"],
|
||||
fn: Callable[[float, float], float],
|
||||
) -> Optional["ExecutionResources"]:
|
||||
"""Fold an iterable of ``ExecutionResources`` per dimension with ``fn``.
|
||||
|
||||
``fn(acc, value)`` combines two per-dimension floats -- e.g.
|
||||
``operator.add`` for a sum, or ``max``/``min`` for an element-wise
|
||||
max/min. Accumulates raw floats in a single pass and allocates a single
|
||||
result object, instead of one intermediate per element as
|
||||
``reduce(lambda a, b: a.<op>(b), resources)`` would.
|
||||
|
||||
Seeds with the first element (so no per-``fn`` identity is needed) and
|
||||
returns ``None`` for an empty iterable, which may be a one-shot
|
||||
generator (so it's consumed exactly once).
|
||||
"""
|
||||
iterator = iter(resources)
|
||||
first = next(iterator, None)
|
||||
if first is None:
|
||||
return None
|
||||
cpu = first.cpu
|
||||
gpu = first.gpu
|
||||
object_store_memory = first.object_store_memory
|
||||
memory = first.memory
|
||||
for r in iterator:
|
||||
cpu = fn(cpu, r.cpu)
|
||||
gpu = fn(gpu, r.gpu)
|
||||
object_store_memory = fn(object_store_memory, r.object_store_memory)
|
||||
memory = fn(memory, r.memory)
|
||||
return ExecutionResources(cpu, gpu, object_store_memory, memory)
|
||||
|
||||
@classmethod
|
||||
def combine_sum(
|
||||
cls, resources: Iterable["ExecutionResources"]
|
||||
) -> "ExecutionResources":
|
||||
"""Sum an iterable of ``ExecutionResources`` in a single pass.
|
||||
|
||||
Thin wrapper over :meth:`combine` with addition. Empty folds are common
|
||||
(e.g. completed-ops / downstream-ineligible usage rollups on most
|
||||
iterations), so an empty input reuses the shared ``zero()`` singleton
|
||||
instead of allocating.
|
||||
"""
|
||||
result = cls.combine(resources, operator.add)
|
||||
return result if result is not None else cls.zero()
|
||||
|
||||
def is_zero(self) -> bool:
|
||||
"""Returns True if all resources are zero."""
|
||||
return (
|
||||
self.cpu == 0.0
|
||||
and self.gpu == 0.0
|
||||
and self.object_store_memory == 0.0
|
||||
and self.memory == 0.0
|
||||
)
|
||||
|
||||
def is_non_negative(self) -> bool:
|
||||
"""Returns True if all resources are non-negative."""
|
||||
return (
|
||||
self.cpu >= 0
|
||||
and self.gpu >= 0
|
||||
and self.object_store_memory >= 0
|
||||
and self.memory >= 0
|
||||
)
|
||||
|
||||
def object_store_memory_str(self) -> str:
|
||||
"""Returns a human-readable string for the object store memory field."""
|
||||
if self.object_store_memory == float("inf"):
|
||||
return "inf"
|
||||
return memory_string(self.object_store_memory)
|
||||
|
||||
def memory_str(self) -> str:
|
||||
"""Returns a human-readable string for the memory field."""
|
||||
if self.memory == float("inf"):
|
||||
return "inf"
|
||||
return memory_string(self.memory)
|
||||
|
||||
def copy(
|
||||
self,
|
||||
cpu: Optional[float] = None,
|
||||
gpu: Optional[float] = None,
|
||||
memory: Optional[float] = None,
|
||||
object_store_memory: Optional[float] = None,
|
||||
) -> "ExecutionResources":
|
||||
"""Returns a copy of this ExecutionResources object allowing to override
|
||||
specific resources as necessary"""
|
||||
return ExecutionResources(
|
||||
cpu=safe_or(cpu, self.cpu),
|
||||
gpu=safe_or(gpu, self.gpu),
|
||||
object_store_memory=safe_or(object_store_memory, self.object_store_memory),
|
||||
memory=safe_or(memory, self.memory),
|
||||
)
|
||||
|
||||
def add(self, other: "ExecutionResources") -> "ExecutionResources":
|
||||
"""Adds execution resources.
|
||||
|
||||
Args:
|
||||
other: The other ``ExecutionResources`` to add to this one.
|
||||
|
||||
Returns:
|
||||
A new ExecutionResource object with summed resources.
|
||||
"""
|
||||
return ExecutionResources(
|
||||
cpu=self.cpu + other.cpu,
|
||||
gpu=self.gpu + other.gpu,
|
||||
object_store_memory=self.object_store_memory + other.object_store_memory,
|
||||
memory=self.memory + other.memory,
|
||||
)
|
||||
|
||||
def subtract(self, other: "ExecutionResources") -> "ExecutionResources":
|
||||
"""Subtracts execution resources.
|
||||
|
||||
Args:
|
||||
other: The other ``ExecutionResources`` to subtract from this one.
|
||||
|
||||
Returns:
|
||||
A new ExecutionResource object with subtracted resources.
|
||||
"""
|
||||
return ExecutionResources(
|
||||
cpu=self.cpu - other.cpu,
|
||||
gpu=self.gpu - other.gpu,
|
||||
object_store_memory=self.object_store_memory - other.object_store_memory,
|
||||
memory=self.memory - other.memory,
|
||||
)
|
||||
|
||||
def max(self, other: "ExecutionResources") -> "ExecutionResources":
|
||||
"""Returns the maximum for each resource type."""
|
||||
return ExecutionResources(
|
||||
cpu=max(self.cpu, other.cpu),
|
||||
gpu=max(self.gpu, other.gpu),
|
||||
object_store_memory=max(
|
||||
self.object_store_memory, other.object_store_memory
|
||||
),
|
||||
memory=max(self.memory, other.memory),
|
||||
)
|
||||
|
||||
def min(self, other: "ExecutionResources") -> "ExecutionResources":
|
||||
"""Returns the minimum for each resource type."""
|
||||
return ExecutionResources(
|
||||
cpu=min(self.cpu, other.cpu),
|
||||
gpu=min(self.gpu, other.gpu),
|
||||
object_store_memory=min(
|
||||
self.object_store_memory, other.object_store_memory
|
||||
),
|
||||
memory=min(self.memory, other.memory),
|
||||
)
|
||||
|
||||
def satisfies_limit(
|
||||
self,
|
||||
limit: "ExecutionResources",
|
||||
*,
|
||||
ignore_object_store_memory: bool = False,
|
||||
) -> bool:
|
||||
"""Return if this resource struct meets the specified limits.
|
||||
|
||||
Note that None for a field means no limit.
|
||||
|
||||
Args:
|
||||
limit: The resource limits to check against.
|
||||
ignore_object_store_memory: If True, ignore the object store memory
|
||||
limit when checking if this resource struct meets the limits.
|
||||
|
||||
Returns:
|
||||
``True`` if every resource is within the corresponding limit.
|
||||
"""
|
||||
return (
|
||||
self.cpu <= limit.cpu
|
||||
and self.gpu <= limit.gpu
|
||||
and (
|
||||
ignore_object_store_memory
|
||||
or self.object_store_memory <= limit.object_store_memory
|
||||
)
|
||||
and self.memory <= limit.memory
|
||||
)
|
||||
|
||||
def scale(self, f: float) -> "ExecutionResources":
|
||||
"""Return copy with all set values scaled by `f`."""
|
||||
if f < 0:
|
||||
raise ValueError("Scaling factor must be non-negative.")
|
||||
if f == 0:
|
||||
# Explicitly handle the zero case, because `0 * inf` is undefined.
|
||||
return ExecutionResources.zero()
|
||||
|
||||
return ExecutionResources(
|
||||
cpu=self.cpu * f,
|
||||
gpu=self.gpu * f,
|
||||
object_store_memory=self.object_store_memory * f,
|
||||
memory=self.memory * f,
|
||||
)
|
||||
|
||||
def floordiv(self, other: "ExecutionResources") -> "ExecutionResources":
|
||||
"""Returns the floor division of resources."""
|
||||
|
||||
def _div(a, b):
|
||||
if b == 0:
|
||||
return float("inf")
|
||||
if a == float("inf"):
|
||||
return float("inf")
|
||||
return math.floor(a / b)
|
||||
|
||||
return ExecutionResources(
|
||||
cpu=_div(self.cpu, other.cpu),
|
||||
gpu=_div(self.gpu, other.gpu),
|
||||
object_store_memory=_div(
|
||||
self.object_store_memory, other.object_store_memory
|
||||
),
|
||||
memory=_div(self.memory, other.memory),
|
||||
)
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class ExecutionOptions:
|
||||
"""Common options for execution.
|
||||
|
||||
Some options may not be supported on all executors (e.g., resource limits).
|
||||
|
||||
Attributes:
|
||||
resource_limits: Set a limit on the logical resources a Dataset can use.
|
||||
Autodetected by default.
|
||||
exclude_resources: Amount of resources to exclude from Ray Data.
|
||||
Set this if you have other workloads running on the same cluster.
|
||||
Note,
|
||||
- If using Ray Data with Ray Train, training resources are
|
||||
automatically reserved and you don't need to set exclude_resources
|
||||
for them.
|
||||
- For each resource type, resource_limits and exclude_resources can
|
||||
not be both set.
|
||||
preserve_order: Set this to preserve the ordering between blocks processed by
|
||||
operators. Off by default.
|
||||
actor_locality_enabled: Whether to enable locality-aware task dispatch to
|
||||
actors (off by default). This parameter applies to both stateful map and
|
||||
streaming_split operations.
|
||||
verbose_progress: Whether to report progress individually per operator. By
|
||||
default, only AllToAll operators and global progress is reported. This
|
||||
option is useful for performance debugging. On by default.
|
||||
label_selector: A mapping of label key to label value. When set, every task
|
||||
and actor launched by this Dataset (including shuffle, sort, and
|
||||
aggregator actors) carries this label selector in its remote args,
|
||||
constraining placement to nodes whose labels satisfy the selector.
|
||||
Used to scope a Dataset to a labeled subset of the cluster (e.g.
|
||||
``{"ray-subcluster": "training"}``). Operator-level ``label_selector``
|
||||
entries in ``ray_remote_args`` take precedence on key conflicts so
|
||||
existing node-pin selectors are preserved.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource_limits: Optional[ExecutionResources] = None,
|
||||
exclude_resources: Optional[ExecutionResources] = None,
|
||||
preserve_order: bool = False,
|
||||
actor_locality_enabled: bool = True,
|
||||
verbose_progress: Optional[bool] = None,
|
||||
label_selector: Optional[Dict[str, str]] = None,
|
||||
):
|
||||
"""Initialize execution options.
|
||||
|
||||
Args:
|
||||
resource_limits: Limit on logical resources a Dataset can use.
|
||||
Defaults to auto-detected limits.
|
||||
exclude_resources: Resources to exclude from Ray Data.
|
||||
preserve_order: Whether to preserve block processing order.
|
||||
actor_locality_enabled: Whether to enable locality-aware dispatch for
|
||||
stateful map and streaming split operations.
|
||||
verbose_progress: Whether to report progress per operator. If None,
|
||||
read from ``RAY_DATA_VERBOSE_PROGRESS``.
|
||||
label_selector: Per-Dataset label selector applied to every task and
|
||||
actor launched by Ray Data. ``None`` means no selector is added.
|
||||
"""
|
||||
if resource_limits is None:
|
||||
resource_limits = ExecutionResources.for_limits()
|
||||
self.resource_limits = resource_limits
|
||||
if exclude_resources is None:
|
||||
exclude_resources = ExecutionResources.zero()
|
||||
self.exclude_resources = exclude_resources
|
||||
self.preserve_order = preserve_order
|
||||
self.actor_locality_enabled = actor_locality_enabled
|
||||
if verbose_progress is None:
|
||||
verbose_progress = bool(
|
||||
int(os.environ.get("RAY_DATA_VERBOSE_PROGRESS", "1"))
|
||||
)
|
||||
self.verbose_progress = verbose_progress
|
||||
self.label_selector = label_selector
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"ExecutionOptions(resource_limits={self.resource_limits}, "
|
||||
f"exclude_resources={self.exclude_resources}, "
|
||||
f"preserve_order={self.preserve_order}, "
|
||||
f"actor_locality_enabled={self.actor_locality_enabled}, "
|
||||
f"verbose_progress={self.verbose_progress}, "
|
||||
f"label_selector={self.label_selector})"
|
||||
)
|
||||
|
||||
@property
|
||||
def resource_limits(self) -> ExecutionResources:
|
||||
return self._resource_limits
|
||||
|
||||
@resource_limits.setter
|
||||
def resource_limits(self, value: ExecutionResources) -> None:
|
||||
self._resource_limits = ExecutionResources.for_limits(
|
||||
cpu=value._cpu,
|
||||
gpu=value._gpu,
|
||||
object_store_memory=value._object_store_memory,
|
||||
memory=value._memory,
|
||||
)
|
||||
|
||||
def is_resource_limits_default(self):
|
||||
"""Returns True if resource_limits is the default value."""
|
||||
return self._resource_limits == ExecutionResources.for_limits()
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validate the options."""
|
||||
for attr in ["cpu", "gpu", "object_store_memory"]:
|
||||
if (
|
||||
getattr(self.resource_limits, attr) != float("inf")
|
||||
and getattr(self.exclude_resources, attr, 0) > 0
|
||||
):
|
||||
raise ValueError(
|
||||
"resource_limits and exclude_resources cannot "
|
||||
f" both be set for {attr} resource."
|
||||
)
|
||||
|
||||
@property
|
||||
def locality_with_output(self) -> bool:
|
||||
return False
|
||||
|
||||
@locality_with_output.setter
|
||||
def locality_with_output(self, value: Union[bool, List[NodeIdStr]]) -> None:
|
||||
if value:
|
||||
warnings.warn(
|
||||
"`ExecutionOptions.locality_with_output` has been removed and is now "
|
||||
"a no-op. We don't recommend using it anymore, but if you still want "
|
||||
"to replicate its behavior, follow the instructions in this gist: "
|
||||
"https://gist.github.com/bveeramani/51e0383bb3680dd78fdfb92d76ea22a8.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
|
||||
def safe_or(value: Optional[Any], alt: Any) -> Any:
|
||||
return value if value is not None else alt
|
||||
|
||||
|
||||
def safe_round(
|
||||
value: Optional[float], ndigits: Optional[int] = None
|
||||
) -> Optional[float]:
|
||||
if value is None:
|
||||
return None
|
||||
elif ndigits is None or math.isinf(value):
|
||||
return value
|
||||
else:
|
||||
return round(value, ndigits)
|
||||
@@ -0,0 +1,106 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import ContextManager, Iterator, List, Optional
|
||||
|
||||
from .execution_options import ExecutionOptions
|
||||
from .physical_operator import PhysicalOperator
|
||||
from .ref_bundle import RefBundle
|
||||
from ray.data._internal.stats import DatasetStats
|
||||
|
||||
|
||||
class OutputIterator(Iterator[RefBundle], ABC):
|
||||
"""Iterator used to access the output of an Executor execution.
|
||||
|
||||
This is a blocking iterator. Datasets guarantees that all its iterators are
|
||||
thread-safe (i.e., multiple threads can block on them at the same time).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_next(self, output_split_idx: Optional[int] = None) -> RefBundle:
|
||||
"""Can be used to pull outputs by a specified output index.
|
||||
|
||||
This is used to support the streaming_split() API, where the output of a
|
||||
streaming execution is to be consumed by multiple processes.
|
||||
|
||||
Args:
|
||||
output_split_idx: The output split index to get results for. This arg is
|
||||
only allowed for iterators created by `Dataset.streaming_split()`.
|
||||
|
||||
Returns:
|
||||
The next ``RefBundle`` of outputs for the given split index.
|
||||
|
||||
Raises:
|
||||
StopIteration: If there are no more outputs to return.
|
||||
"""
|
||||
...
|
||||
|
||||
def __next__(self) -> RefBundle:
|
||||
return self.get_next()
|
||||
|
||||
|
||||
class Executor(ContextManager, ABC):
|
||||
"""Abstract class for executors, which implement physical operator execution.
|
||||
|
||||
Subclasses:
|
||||
StreamingExecutor
|
||||
"""
|
||||
|
||||
def __init__(self, options: ExecutionOptions):
|
||||
"""Create the executor."""
|
||||
options.validate()
|
||||
self._options = options
|
||||
|
||||
@abstractmethod
|
||||
def execute(
|
||||
self,
|
||||
dag: PhysicalOperator,
|
||||
initial_stats: Optional[DatasetStats] = None,
|
||||
callbacks: Optional[List] = None,
|
||||
) -> OutputIterator:
|
||||
"""Start execution.
|
||||
|
||||
Args:
|
||||
dag: The operator graph to execute.
|
||||
initial_stats: The DatasetStats to prepend to the stats returned by the
|
||||
executor. These stats represent actions done to compute inputs.
|
||||
callbacks: A list of ExecutionCallbacks to run during execution.
|
||||
This method keeps and uses the exact list you pass in, so do not
|
||||
pass an empty list like ``[]`` directly. Create the list first,
|
||||
then pass it.
|
||||
|
||||
Returns:
|
||||
An ``OutputIterator`` over the execution's output ref bundles.
|
||||
"""
|
||||
...
|
||||
|
||||
def shutdown(self, force: bool, exception: Optional[Exception] = None):
|
||||
"""Shutdown an executor, which may still be running.
|
||||
|
||||
This should interrupt execution and clean up any used resources.
|
||||
|
||||
Args:
|
||||
force: Controls whether shutdown should forcefully terminate all execution
|
||||
activity (making sure that upon returning from this method all
|
||||
activities are stopped). When force=False, some activities could be
|
||||
terminated asynchronously (ie this method won't provide guarantee
|
||||
that they stop executing before returning from this method)
|
||||
exception: The exception that causes the executor to shut down, or None if
|
||||
the executor finishes successfully.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_stats(self) -> DatasetStats:
|
||||
"""Return stats for the execution so far.
|
||||
|
||||
This is generally called after `execute` has completed, but may be called
|
||||
while iterating over `execute` results for streaming execution.
|
||||
"""
|
||||
...
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback, /):
|
||||
# NOTE: ``ContextManager`` semantic must guarantee that executor
|
||||
# fully shutdown upon returning from this method
|
||||
self.shutdown(force=True, exception=exc_value)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,481 @@
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Iterable, Iterator, List, Optional, Tuple
|
||||
|
||||
import ray
|
||||
from .common import NodeIdStr
|
||||
from ray.data._internal.memory_tracing import trace_deallocation
|
||||
from ray.data.block import (
|
||||
Block,
|
||||
BlockAccessor,
|
||||
BlockMetadata,
|
||||
Schema,
|
||||
_take_first_non_empty_schema,
|
||||
)
|
||||
from ray.data.context import DataContext
|
||||
from ray.types import ObjectRef
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BlockSlice:
|
||||
"""A slice of a block."""
|
||||
|
||||
# Starting row offset (inclusive) within the block.
|
||||
start_offset: int
|
||||
# Ending row offset (exclusive) within the block.
|
||||
end_offset: int
|
||||
|
||||
@property
|
||||
def num_rows(self) -> int:
|
||||
return self.end_offset - self.start_offset
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BlockEntry:
|
||||
"""One block delivery: the ref + the block's measured metadata.
|
||||
|
||||
Used as the element type of ``RefBundle.blocks`` (replaces the legacy
|
||||
``(ObjectRef, BlockMetadata)`` 2-tuple shape). Naming the fields makes
|
||||
every call site self-describing and reserves room for the bundle entry
|
||||
to grow without disturbing the surrounding shape.
|
||||
"""
|
||||
|
||||
ref: ObjectRef[Block]
|
||||
metadata: BlockMetadata
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RefBundle:
|
||||
"""A group of data block references and their metadata.
|
||||
|
||||
Operators take in and produce streams of RefBundles.
|
||||
|
||||
Most commonly a RefBundle consists of a single block object reference.
|
||||
In some cases, e.g., due to block splitting, or for a reduce task, there may
|
||||
be more than one block.
|
||||
|
||||
Block bundles have ownership semantics, i.e., shared ownership (similar to C++
|
||||
shared_ptr, multiple operators share the same block bundle), or unique ownership
|
||||
(similar to C++ unique_ptr, only one operator owns the block bundle). This
|
||||
allows operators to know whether they can destroy blocks when they don't need
|
||||
them. Destroying blocks eagerly is more efficient than waiting for Python GC /
|
||||
Ray reference counting to kick in.
|
||||
"""
|
||||
|
||||
# Per-block entries. The size_bytes must be known in the metadata,
|
||||
# num_rows is optional. Legacy ``(ref, metadata)`` 2-tuples are no longer
|
||||
# accepted at construction and must be explicitly wrapped in ``BlockEntry``
|
||||
# (``__post_init__`` rejects anything else with an actionable assertion).
|
||||
blocks: Tuple[BlockEntry, ...]
|
||||
|
||||
# The schema of the blocks in this bundle. This is optional, and may be None
|
||||
# if blocks are empty.
|
||||
schema: Optional["Schema"]
|
||||
|
||||
# Whether we own the blocks (can safely destroy them).
|
||||
owns_blocks: bool
|
||||
|
||||
# The slices of the blocks in this bundle. After __post_init__, this is always
|
||||
# a list with length equal to len(blocks). Individual entries can be None to
|
||||
# represent a full block (equivalent to BlockSlice(0, num_rows)).
|
||||
# Pass None during construction to initialize all slices as None (full blocks).
|
||||
slices: Optional[Tuple[Optional[BlockSlice], ...]] = None
|
||||
|
||||
# This attribute is used by the split() operator to assign bundles to logical
|
||||
# output splits. It is otherwise None.
|
||||
output_split_idx: Optional[int] = None
|
||||
|
||||
# Object metadata (size, locations, spilling status)
|
||||
_cached_object_meta: Optional[Dict[ObjectRef, "_ObjectMetadata"]] = None
|
||||
|
||||
# Preferred locations for this bundle determined based on the locations
|
||||
# of individual objects and their corresponding size, ie location with the
|
||||
# largest total number of bytes present there has the highest preference.
|
||||
_cached_preferred_locations: Optional[Dict[NodeIdStr, int]] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.schema is not None:
|
||||
import pyarrow as pa
|
||||
|
||||
from ray.data._internal.pandas_block import PandasBlockSchema
|
||||
|
||||
assert isinstance(
|
||||
self.schema, (pa.lib.Schema, PandasBlockSchema)
|
||||
), f"Schema must be a pyarrow or PandasBlockSchema, got {type(self.schema)}"
|
||||
|
||||
if not isinstance(self.blocks, tuple):
|
||||
object.__setattr__(self, "blocks", tuple(self.blocks))
|
||||
for entry in self.blocks:
|
||||
assert isinstance(entry, BlockEntry), (
|
||||
f"RefBundle.blocks must contain BlockEntry instances; got {type(entry).__name__}. "
|
||||
"Construct entries with `BlockEntry(ref=..., metadata=...)`."
|
||||
)
|
||||
|
||||
if self.slices is None:
|
||||
object.__setattr__(self, "slices", (None,) * len(self.blocks))
|
||||
else:
|
||||
if not isinstance(self.slices, tuple):
|
||||
object.__setattr__(self, "slices", tuple(self.slices))
|
||||
|
||||
assert len(self.blocks) == len(
|
||||
self.slices
|
||||
), "Number of blocks and slices must match"
|
||||
# Validate slice ranges
|
||||
for entry, block_slice in zip(self.blocks, self.slices):
|
||||
if block_slice is not None:
|
||||
assert (
|
||||
block_slice.start_offset >= 0
|
||||
), f"Slice start_offset must be non-negative: {block_slice.start_offset}"
|
||||
assert (
|
||||
block_slice.end_offset >= block_slice.start_offset
|
||||
), f"Slice end_offset must be >= start_offset: [{block_slice.start_offset}, {block_slice.end_offset})"
|
||||
if entry.metadata.num_rows is not None:
|
||||
assert (
|
||||
block_slice.end_offset <= entry.metadata.num_rows
|
||||
), f"Slice range [{block_slice.start_offset}, {block_slice.end_offset}) exceeds block num_rows: {entry.metadata.num_rows}"
|
||||
|
||||
for entry in self.blocks:
|
||||
if entry.metadata.size_bytes is None:
|
||||
raise ValueError(
|
||||
"The size in bytes of the block must be known: {}".format(entry)
|
||||
)
|
||||
|
||||
@property
|
||||
def block_refs(self) -> List[ObjectRef[Block]]:
|
||||
"""List of block references in this bundle."""
|
||||
return [entry.ref for entry in self.blocks]
|
||||
|
||||
@property
|
||||
def metadata(self) -> List[BlockMetadata]:
|
||||
"""List of block metadata in this bundle."""
|
||||
return [entry.metadata for entry in self.blocks]
|
||||
|
||||
def num_rows(self) -> Optional[int]:
|
||||
"""Number of rows present in this bundle, if known.
|
||||
|
||||
Iterates through blocks and their corresponding slices to calculate the total.
|
||||
Note: Block metadata always refers to the full block, not the slice.
|
||||
|
||||
- If block_slice is None, uses the full block's metadata.num_rows
|
||||
- If block_slice is present, uses the slice's num_rows (partial block portion)
|
||||
- Returns None if any full block has unknown row count (metadata.num_rows is None)
|
||||
"""
|
||||
total = 0
|
||||
for metadata, block_slice in zip(self.metadata, self.slices):
|
||||
if block_slice is None:
|
||||
if metadata.num_rows is None:
|
||||
return None
|
||||
total += metadata.num_rows
|
||||
else:
|
||||
total += block_slice.num_rows
|
||||
return total
|
||||
|
||||
def size_bytes(self) -> int:
|
||||
"""Size of the blocks of this bundle in bytes.
|
||||
|
||||
Iterates through blocks and their corresponding slices to calculate the total size.
|
||||
Note: Block metadata always refers to the full block, not the slice.
|
||||
|
||||
- If block_slice is None, uses the full block's metadata.size_bytes
|
||||
- If block_slice is present but num_rows is unknown or zero, uses full metadata.size_bytes
|
||||
- If block_slice represents a partial block, estimates size proportionally based on
|
||||
(metadata.size_bytes / metadata.num_rows) * block_slice.num_rows
|
||||
- Otherwise, uses the full metadata.size_bytes
|
||||
"""
|
||||
total = 0
|
||||
for entry, block_slice in zip(self.blocks, self.slices):
|
||||
metadata = entry.metadata
|
||||
if block_slice is None:
|
||||
# Full block
|
||||
total += metadata.size_bytes
|
||||
elif metadata.num_rows is None or metadata.num_rows == 0:
|
||||
# Unknown num_rows or empty block - use full metadata size
|
||||
total += metadata.size_bytes
|
||||
elif metadata.num_rows != block_slice.num_rows:
|
||||
# Partial block - estimate size based on rows
|
||||
per_row = metadata.size_bytes / metadata.num_rows
|
||||
total += max(1, round(per_row * block_slice.num_rows))
|
||||
else:
|
||||
total += metadata.size_bytes
|
||||
return total
|
||||
|
||||
def destroy_if_owned(self) -> int:
|
||||
"""Clears the object store memory for these blocks if owned.
|
||||
|
||||
Returns:
|
||||
The number of bytes freed.
|
||||
"""
|
||||
should_free = self.owns_blocks and DataContext.get_current().eager_free
|
||||
for block_ref in self.block_refs:
|
||||
trace_deallocation(
|
||||
block_ref, "RefBundle.destroy_if_owned", free=should_free
|
||||
)
|
||||
return self.size_bytes() if should_free else 0
|
||||
|
||||
def get_preferred_object_locations(self) -> Dict[NodeIdStr, int]:
|
||||
"""Returns a mapping of node IDs to total bytes stored on each node.
|
||||
|
||||
Returns:
|
||||
Dict mapping node ID to total bytes stored on that node
|
||||
"""
|
||||
meta = self._get_cached_metadata()
|
||||
|
||||
if self._cached_preferred_locations is None:
|
||||
preferred_locs: Dict[NodeIdStr, int] = defaultdict(int)
|
||||
|
||||
for ref, obj_meta in meta.items():
|
||||
for loc in obj_meta.locs:
|
||||
preferred_locs[loc] += obj_meta.size
|
||||
|
||||
# NOTE: We're working around object being immutable to update cached
|
||||
# values (safe)
|
||||
object.__setattr__(self, "_cached_preferred_locations", preferred_locs)
|
||||
|
||||
return self._cached_preferred_locations
|
||||
|
||||
def _get_cached_metadata(self) -> Dict[ObjectRef, "_ObjectMetadata"]:
|
||||
if self._cached_object_meta is None:
|
||||
# This call is pretty fast for owned objects (~5k/s), so we don't need to
|
||||
# batch it for now.
|
||||
meta = ray.experimental.get_local_object_locations(self.block_refs)
|
||||
# Extract locations
|
||||
object_metas: Dict[ObjectRef, _ObjectMetadata] = {
|
||||
ref: _ObjectMetadata(
|
||||
size=meta[ref]["object_size"],
|
||||
spilled=meta[ref]["did_spill"],
|
||||
locs=meta[ref]["node_ids"],
|
||||
)
|
||||
for ref in self.block_refs
|
||||
}
|
||||
|
||||
# NOTE: We're working around object being immutable to update cached
|
||||
# values (safe)
|
||||
object.__setattr__(self, "_cached_object_meta", object_metas)
|
||||
|
||||
return self._cached_object_meta
|
||||
|
||||
def slice(self, needed_rows: int) -> Tuple["RefBundle", "RefBundle"]:
|
||||
"""Slice a Ref Bundle into the first bundle containing the first `needed_rows` rows and the remaining bundle containing the remaining rows.
|
||||
|
||||
Args:
|
||||
needed_rows: Number of rows to take from the head of the bundle.
|
||||
|
||||
Returns:
|
||||
A tuple of (sliced_bundle, remaining_bundle). The needed rows must be less than the number of rows in the bundle.
|
||||
"""
|
||||
assert needed_rows > 0, "needed_rows must be positive."
|
||||
assert (
|
||||
self.num_rows() is not None
|
||||
), "Cannot slice a RefBundle with unknown number of rows."
|
||||
assert (
|
||||
needed_rows < self.num_rows()
|
||||
), f"To slice a RefBundle, the number of requested rows must be less than the number of rows in the bundle. Requested {needed_rows} rows but bundle only has {self.num_rows()} rows."
|
||||
|
||||
block_slices = []
|
||||
for metadata, block_slice in zip(self.metadata, self.slices):
|
||||
if block_slice is None:
|
||||
# None represents a full block, convert to explicit BlockSlice
|
||||
assert (
|
||||
metadata.num_rows is not None
|
||||
), "Cannot derive block slice for a RefBundle with unknown block row counts."
|
||||
block_slices.append(
|
||||
BlockSlice(start_offset=0, end_offset=metadata.num_rows)
|
||||
)
|
||||
else:
|
||||
block_slices.append(block_slice)
|
||||
|
||||
consumed_blocks: List[BlockEntry] = []
|
||||
consumed_slices: List[BlockSlice] = []
|
||||
remaining_blocks: List[BlockEntry] = []
|
||||
remaining_slices: List[BlockSlice] = []
|
||||
|
||||
rows_to_take = needed_rows
|
||||
|
||||
for entry, block_slice in zip(self.blocks, block_slices):
|
||||
block_rows = block_slice.num_rows
|
||||
if rows_to_take >= block_rows:
|
||||
consumed_blocks.append(entry)
|
||||
consumed_slices.append(block_slice)
|
||||
rows_to_take -= block_rows
|
||||
else:
|
||||
if rows_to_take == 0:
|
||||
remaining_blocks.append(entry)
|
||||
remaining_slices.append(block_slice)
|
||||
continue
|
||||
consume_slice = BlockSlice(
|
||||
start_offset=block_slice.start_offset,
|
||||
end_offset=block_slice.start_offset + rows_to_take,
|
||||
)
|
||||
consumed_blocks.append(entry)
|
||||
consumed_slices.append(consume_slice)
|
||||
|
||||
leftover_rows = block_rows - rows_to_take
|
||||
if leftover_rows > 0:
|
||||
remainder_slice = BlockSlice(
|
||||
start_offset=consume_slice.end_offset,
|
||||
end_offset=block_slice.end_offset,
|
||||
)
|
||||
remaining_blocks.append(entry)
|
||||
remaining_slices.append(remainder_slice)
|
||||
|
||||
rows_to_take = 0
|
||||
|
||||
sliced_bundle = RefBundle(
|
||||
blocks=tuple(consumed_blocks),
|
||||
schema=self.schema,
|
||||
owns_blocks=False,
|
||||
slices=tuple(consumed_slices) if consumed_slices else None,
|
||||
)
|
||||
|
||||
remaining_bundle = RefBundle(
|
||||
blocks=tuple(remaining_blocks),
|
||||
schema=self.schema,
|
||||
owns_blocks=False,
|
||||
slices=tuple(remaining_slices) if remaining_slices else None,
|
||||
)
|
||||
|
||||
return sliced_bundle, remaining_bundle
|
||||
|
||||
@classmethod
|
||||
def merge_ref_bundles(cls, bundles: Iterable["RefBundle"]) -> "RefBundle":
|
||||
"""Merge multiple RefBundles into a single RefBundle.
|
||||
|
||||
Args:
|
||||
bundles: An iterable of RefBundles to merge.
|
||||
|
||||
Returns:
|
||||
A single RefBundle containing all blocks from the input bundles.
|
||||
owns_blocks is True only if all input bundles own their blocks.
|
||||
schema is the first non-empty schema found.
|
||||
"""
|
||||
|
||||
bundles = list(bundles)
|
||||
if not bundles:
|
||||
return cls(blocks=(), owns_blocks=True, schema=None)
|
||||
|
||||
merged_blocks = list(
|
||||
itertools.chain.from_iterable(bundle.blocks for bundle in bundles)
|
||||
)
|
||||
merged_slices = list(
|
||||
itertools.chain.from_iterable(bundle.slices for bundle in bundles)
|
||||
)
|
||||
# Ray Data uses the `owns_blocks` flag to determine if the system can eagerly
|
||||
# destroy blocks when they're no longer needed. To be safe, we only set this
|
||||
# to True if all input bundles own their blocks.
|
||||
owns_blocks = all(bundle.owns_blocks for bundle in bundles)
|
||||
# TODO: Reconcile the schemas rather than taking the first non-empty schema.
|
||||
schema = _take_first_non_empty_schema(bundle.schema for bundle in bundles)
|
||||
return cls(
|
||||
blocks=tuple(merged_blocks),
|
||||
schema=schema,
|
||||
owns_blocks=owns_blocks,
|
||||
slices=merged_slices,
|
||||
)
|
||||
|
||||
def __eq__(self, other: "RefBundle"):
|
||||
if self is other:
|
||||
return True
|
||||
elif not isinstance(other, RefBundle):
|
||||
return False
|
||||
|
||||
return (
|
||||
self.blocks == other.blocks
|
||||
and self.slices == other.slices
|
||||
# NOTE: We're establishing a requirement of schemas for `RefBundle`
|
||||
# to be exactly the same object for it to be considered equal.
|
||||
#
|
||||
# This is necessary to avoid a full schema equality check that
|
||||
# is computationally intensive.
|
||||
and self.schema is other.schema
|
||||
and self.owns_blocks == other.owns_blocks
|
||||
and self.output_split_idx == other.output_split_idx
|
||||
)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(
|
||||
(
|
||||
# Only hash block refs
|
||||
*[entry.ref for entry in self.blocks],
|
||||
*self.slices,
|
||||
# Check out comment in ``__eq__``
|
||||
id(self.schema),
|
||||
self.owns_blocks,
|
||||
self.output_split_idx,
|
||||
)
|
||||
)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.blocks)
|
||||
|
||||
def __str__(self) -> str:
|
||||
lines = [
|
||||
f"RefBundle({len(self.blocks)} blocks,",
|
||||
f" {self.num_rows()} rows,",
|
||||
f" schema={self.schema},",
|
||||
f" owns_blocks={self.owns_blocks},",
|
||||
" blocks=(",
|
||||
]
|
||||
|
||||
# Loop through each block and show details
|
||||
for i, (entry, block_slice) in enumerate(zip(self.blocks, self.slices)):
|
||||
metadata = entry.metadata
|
||||
row_str = (
|
||||
f"{metadata.num_rows} rows"
|
||||
if metadata.num_rows is not None
|
||||
else "unknown rows"
|
||||
)
|
||||
bytes_str = f"{metadata.size_bytes} bytes"
|
||||
slice_str = (
|
||||
f"slice={block_slice}"
|
||||
if block_slice is not None
|
||||
else "slice=None (full block)"
|
||||
)
|
||||
|
||||
lines.append(f" {i}: {row_str}, {bytes_str}, {slice_str}")
|
||||
|
||||
lines.append(" )")
|
||||
lines.append(")")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ObjectMetadata:
|
||||
# Object size in bytes
|
||||
size: int
|
||||
# Flag whether object has been spilled
|
||||
spilled: bool
|
||||
# List of nodes object exists on
|
||||
locs: List[NodeIdStr] = None
|
||||
|
||||
|
||||
def _ref_bundles_iterator_to_block_refs_list(
|
||||
ref_bundles: Iterator[RefBundle],
|
||||
) -> List[ObjectRef[Block]]:
|
||||
"""Convert an iterator of RefBundles to a list of Block object references."""
|
||||
return [
|
||||
block_ref for ref_bundle in ref_bundles for block_ref in ref_bundle.block_refs
|
||||
]
|
||||
|
||||
|
||||
def _iter_sliced_blocks(
|
||||
blocks: Iterable[Block],
|
||||
slices: List[Optional[BlockSlice]],
|
||||
) -> Iterator[Block]:
|
||||
blocks_list = list(blocks)
|
||||
for block, block_slice in zip(blocks_list, slices):
|
||||
if block_slice is None:
|
||||
# None represents a full block - yield it as is
|
||||
yield block
|
||||
else:
|
||||
accessor = BlockAccessor.for_block(block)
|
||||
start = block_slice.start_offset
|
||||
end = block_slice.end_offset
|
||||
assert start <= end, "start must be less than end"
|
||||
assert start >= 0, "start must be non-negative"
|
||||
assert (
|
||||
end <= accessor.num_rows()
|
||||
), "end must be less than or equal to the number of rows in the block"
|
||||
|
||||
yield accessor.slice(start, end, copy=False)
|
||||
@@ -0,0 +1,88 @@
|
||||
import contextlib
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterator, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ray.data._internal.execution.operators.map_transformer import MapTransformer
|
||||
from ray.data._internal.progress.base_progress import BaseProgressBar
|
||||
|
||||
|
||||
_thread_local = threading.local()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskContext:
|
||||
"""This describes the information of a task running block transform."""
|
||||
|
||||
# The index of task. Each task has a unique task index within the same
|
||||
# operator.
|
||||
task_idx: int
|
||||
|
||||
# Name of the operator that this task belongs to.
|
||||
op_name: str
|
||||
|
||||
# The dictionary of sub progress bar to update. The key is name of sub progress
|
||||
# bar. Note this is only used on driver side.
|
||||
# TODO(chengsu): clean it up from TaskContext with new optimizer framework.
|
||||
sub_progress_bar_dict: Optional[Dict[str, "BaseProgressBar"]] = None
|
||||
|
||||
# NOTE(hchen): `upstream_map_transformer` and `upstream_map_ray_remote_args`
|
||||
# are only used for `RandomShuffle`. DO NOT use them for other operators.
|
||||
# Ideally, they should be handled by the optimizer, and should be transparent
|
||||
# to the specific operators.
|
||||
# But for `RandomShuffle`, the AllToAllOperator doesn't do the shuffle itself.
|
||||
# It uses `ExchangeTaskScheduler` to launch new tasks to do the shuffle.
|
||||
# That's why we need to pass them to `ExchangeTaskScheduler`.
|
||||
# TODO(hchen): Use a physical operator to do the shuffle directly.
|
||||
|
||||
# The underlying function called in a MapOperator; this is used when fusing
|
||||
# an AllToAllOperator with an upstream MapOperator.
|
||||
upstream_map_transformer: Optional["MapTransformer"] = None
|
||||
|
||||
# The Ray remote arguments of the fused upstream MapOperator.
|
||||
# This should be set if upstream_map_transformer is set.
|
||||
upstream_map_ray_remote_args: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Override of the target max-block-size for the task
|
||||
target_max_block_size_override: Optional[int] = None
|
||||
|
||||
# Additional keyword arguments passed to the task.
|
||||
kwargs: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def get_current(cls) -> Optional["TaskContext"]:
|
||||
"""Get the TaskContext for the current thread.
|
||||
Returns None if no TaskContext has been set.
|
||||
"""
|
||||
|
||||
return getattr(_thread_local, "task_context", None)
|
||||
|
||||
@classmethod
|
||||
def set_current(cls, context: "TaskContext") -> None:
|
||||
"""Set the TaskContext for the current thread.
|
||||
|
||||
Args:
|
||||
context: The TaskContext instance to set for this thread
|
||||
"""
|
||||
|
||||
_thread_local.task_context = context
|
||||
|
||||
@classmethod
|
||||
def reset_current(cls):
|
||||
"""Clear the current thread's TaskContext."""
|
||||
|
||||
if hasattr(_thread_local, "task_context"):
|
||||
delattr(_thread_local, "task_context")
|
||||
|
||||
@classmethod
|
||||
@contextlib.contextmanager
|
||||
def current(cls, context: "TaskContext") -> Iterator["TaskContext"]:
|
||||
"""Sets this TaskContext as current for the scope
|
||||
of the context block and resets it on exit.
|
||||
"""
|
||||
cls.set_current(context)
|
||||
try:
|
||||
yield context
|
||||
finally:
|
||||
cls.reset_current()
|
||||
@@ -0,0 +1,14 @@
|
||||
from typing import Callable, List, Tuple
|
||||
|
||||
from .ref_bundle import RefBundle
|
||||
from .task_context import TaskContext
|
||||
from ray.data._internal.stats import StatsDict
|
||||
|
||||
# Result type of AllToAllTransformFn.
|
||||
AllToAllTransformFnResult = Tuple[List[RefBundle], StatsDict]
|
||||
|
||||
# Block transform function applied in AllToAllOperator.
|
||||
AllToAllTransformFn = Callable[
|
||||
[List[RefBundle], TaskContext],
|
||||
AllToAllTransformFnResult,
|
||||
]
|
||||
Reference in New Issue
Block a user