2175 lines
86 KiB
Python
2175 lines
86 KiB
Python
import collections
|
|
import copy
|
|
import logging
|
|
import time
|
|
from collections import defaultdict
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass, fields
|
|
from enum import Enum
|
|
from typing import (
|
|
TYPE_CHECKING,
|
|
Any,
|
|
DefaultDict,
|
|
Dict,
|
|
Iterator,
|
|
List,
|
|
Mapping,
|
|
Optional,
|
|
Set,
|
|
Tuple,
|
|
Union,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from ray.data._internal.scheduling_overhead import BucketedSchedulingOverhead
|
|
from uuid import uuid4
|
|
|
|
import ray
|
|
from ray.actor import ActorHandle
|
|
from ray.data._internal.execution.dataset_state import DatasetState
|
|
from ray.data._internal.execution.interfaces.common import RuntimeMetricsHistogram
|
|
from ray.data._internal.execution.interfaces.distribution_tracker import (
|
|
DistributionTracker,
|
|
)
|
|
from ray.data._internal.execution.interfaces.execution_options import safe_round
|
|
from ray.data._internal.execution.interfaces.op_runtime_metrics import (
|
|
NODE_UNKNOWN,
|
|
MetricsGroup,
|
|
MetricsType,
|
|
NodeMetrics,
|
|
OpRuntimeMetrics,
|
|
)
|
|
from ray.data._internal.metadata_exporter import (
|
|
DataContextMetadata,
|
|
DatasetMetadata,
|
|
Topology,
|
|
get_dataset_metadata_exporter,
|
|
)
|
|
from ray.data._internal.util import capfirst
|
|
from ray.data.block import BlockStats
|
|
from ray.data.context import DataContext
|
|
from ray.util.annotations import DeveloperAPI
|
|
from ray.util.metrics import Counter, Gauge, Histogram, Metric
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
STATS_ACTOR_NAME = "datasets_stats_actor"
|
|
STATS_ACTOR_NAMESPACE = "_dataset_stats_actor"
|
|
UNKNOWN = "unknown"
|
|
UNKNOWN_UUID = "unknown_uuid"
|
|
|
|
|
|
StatsDict = Dict[str, List[BlockStats]]
|
|
|
|
|
|
def fmt(seconds: float) -> str:
|
|
if seconds > 1:
|
|
return str(round(seconds, 2)) + "s"
|
|
elif seconds > 0.001:
|
|
return str(round(seconds * 1000, 2)) + "ms"
|
|
else:
|
|
return str(round(seconds * 1000 * 1000, 2)) + "us"
|
|
|
|
|
|
def leveled_indent(lvl: int = 0, spaces_per_indent: int = 3) -> str:
|
|
"""Returns a string of spaces which contains `level` indents,
|
|
each indent containing `spaces_per_indent` spaces. For example:
|
|
>>> leveled_indent(2, 3)
|
|
' '
|
|
"""
|
|
return (" " * spaces_per_indent) * lvl
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class StatsSummary:
|
|
"""Immutable summary of min/max/mean/sum/count statistics."""
|
|
|
|
min: int | float
|
|
max: int | float
|
|
mean: float
|
|
sum: int | float
|
|
count: int
|
|
|
|
def to_dict(
|
|
self,
|
|
*,
|
|
mean_as_int: bool = False,
|
|
include_sum: bool = True,
|
|
include_count: bool = True,
|
|
) -> dict[str, int | float]:
|
|
"""Serialize to a plain dict, with optional formatting.
|
|
|
|
Args:
|
|
mean_as_int: If True, the ``mean`` value is truncated to ``int``.
|
|
include_sum: If True, include a ``sum`` key.
|
|
include_count: If True, include a ``count`` key.
|
|
|
|
Returns:
|
|
A dict with ``min``, ``max``, ``mean``, and optionally ``sum``
|
|
and ``count``.
|
|
"""
|
|
result: dict[str, int | float] = {
|
|
"min": self.min,
|
|
"max": self.max,
|
|
"mean": int(self.mean) if mean_as_int else self.mean,
|
|
}
|
|
if include_sum:
|
|
result["sum"] = self.sum
|
|
if include_count:
|
|
result["count"] = self.count
|
|
return result
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _StatsAccumulator:
|
|
"""Tracks min/max/sum/count for incremental stats computation."""
|
|
|
|
min_value: int | float = float("inf")
|
|
max_value: int | float = float("-inf")
|
|
acc_sum: int | float = 0
|
|
count: int = 0
|
|
|
|
def add(self, value: int | float) -> None:
|
|
self.min_value = min(self.min_value, value)
|
|
self.max_value = max(self.max_value, value)
|
|
self.acc_sum += value
|
|
self.count += 1
|
|
|
|
def get(
|
|
self,
|
|
*,
|
|
round_digits: Optional[int] = None,
|
|
) -> Optional[StatsSummary]:
|
|
"""Build a ``StatsSummary`` from accumulated values.
|
|
|
|
Args:
|
|
round_digits: If set, round ``min``, ``max``, ``mean``, and ``sum``
|
|
to this many decimal places.
|
|
|
|
Returns:
|
|
A :class:`StatsSummary`, or ``None`` if no values were added via
|
|
:meth:`add`.
|
|
"""
|
|
if not self.count:
|
|
return None
|
|
mean = self.acc_sum / self.count
|
|
return StatsSummary(
|
|
min=safe_round(self.min_value, round_digits),
|
|
max=safe_round(self.max_value, round_digits),
|
|
mean=safe_round(mean, round_digits),
|
|
sum=safe_round(self.acc_sum, round_digits),
|
|
count=self.count,
|
|
)
|
|
|
|
|
|
class IterationStage(Enum):
|
|
"""Stages of the iter_batches pipeline, used to attribute training-thread
|
|
blocked time. Each value is the Prometheus label for the corresponding
|
|
``data_iter_blocked_<stage>_seconds`` gauge.
|
|
"""
|
|
|
|
PRODUCTION_WAIT = "production_wait" # waiting on upstream data production
|
|
DATA_TRANSFER = "data_transfer" # cross-node ray.get() transfer
|
|
BATCHING = "batching" # slicing/shuffling blocks into batches
|
|
FORMAT = "format" # converting blocks to batch format
|
|
COLLATE = "collate" # applying user collate_fn
|
|
FINALIZE = "finalize" # applying user finalize_fn
|
|
|
|
|
|
@dataclass
|
|
class TimeSpan:
|
|
"""A measured wall-clock interval (start_s, end_s)."""
|
|
|
|
start_s: float = 0.0
|
|
end_s: float = 0.0
|
|
|
|
@property
|
|
def duration(self) -> float:
|
|
return self.end_s - self.start_s
|
|
|
|
|
|
@contextmanager
|
|
def _maybe_time(timer: Optional["Timer"]) -> Iterator[Optional[TimeSpan]]:
|
|
"""Time a block, yielding a TimeSpan (or None if timer is None)."""
|
|
if timer is None:
|
|
yield None
|
|
else:
|
|
with timer.timer() as span:
|
|
yield span
|
|
|
|
|
|
class Timer:
|
|
"""Helper class for tracking accumulated time (in seconds).
|
|
|
|
Every value passed to :meth:`add` is also fed into an internal
|
|
:class:`DistributionTracker` (a KLL sketch with bounded memory) so
|
|
:meth:`percentile` can return an approximate p-th percentile at any
|
|
time. The sketch uses O(k log(n/k)) memory (k=200 by default), so it
|
|
stays a few kilobytes regardless of how many samples are added —
|
|
safe for long-running production jobs.
|
|
|
|
Percentile accuracy is the KLL guarantee — roughly 1.65% rank error
|
|
at the default k=200. When the optional ``datasketches`` dependency
|
|
is not installed, :meth:`percentile` returns 0 (the other stats are
|
|
unaffected).
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._total: float = 0
|
|
self._min: float = float("inf")
|
|
self._max: float = 0
|
|
self._total_count: float = 0
|
|
# Bounded-memory percentile backend. add() forwards every value
|
|
# to ``add_sample`` and ``percentile`` reads from it.
|
|
self._distribution: DistributionTracker = DistributionTracker()
|
|
|
|
@contextmanager
|
|
def timer(self) -> Iterator[TimeSpan]:
|
|
"""Time a block, yielding a fresh ``TimeSpan`` per call.
|
|
|
|
The returned span is a distinct instance each call, so multiple
|
|
threads sharing the same ``Timer`` don't race on span fields.
|
|
The duration is also accumulated into ``self`` via ``add``.
|
|
"""
|
|
span = TimeSpan(start_s=time.perf_counter())
|
|
try:
|
|
yield span
|
|
finally:
|
|
span.end_s = time.perf_counter()
|
|
self.add(span.duration)
|
|
|
|
def add(self, value: float) -> None:
|
|
self._total += value
|
|
if value < self._min:
|
|
self._min = value
|
|
if value > self._max:
|
|
self._max = value
|
|
self._total_count += 1
|
|
self._distribution.add_sample(value)
|
|
|
|
def get(self) -> float:
|
|
return self._total
|
|
|
|
def min(self) -> float:
|
|
return self._min
|
|
|
|
def max(self) -> float:
|
|
return self._max
|
|
|
|
def avg(self) -> float:
|
|
return self._total / self._total_count if self._total_count else float("inf")
|
|
|
|
def percentile(self, p: float) -> float:
|
|
"""Approximate ``p``-th percentile in seconds.
|
|
|
|
Backed by the internal :class:`DistributionTracker`'s KLL
|
|
sketch. Returns 0 when no samples have been added or the
|
|
optional ``datasketches`` package is unavailable.
|
|
|
|
Args:
|
|
p: Percentile as a fraction in ``[0.0, 1.0]`` (e.g. ``0.9``
|
|
for p90 — not ``90``). Values outside this range raise
|
|
``ValueError``.
|
|
|
|
Returns:
|
|
The approximate p-th percentile of all samples seen, or 0
|
|
when the sketch has no data / no backend.
|
|
|
|
Raises:
|
|
ValueError: If ``p`` is outside ``[0.0, 1.0]``.
|
|
"""
|
|
if not 0.0 <= p <= 1.0:
|
|
raise ValueError(
|
|
f"p must be in [0.0, 1.0], got {p!r}. "
|
|
"Pass a fraction like 0.9, not a percent like 90."
|
|
)
|
|
q = self._distribution._quantile(p)
|
|
return q if q is not None else 0
|
|
|
|
def as_dict(self) -> Dict[str, Optional[float]]:
|
|
"""Return a JSON-serializable snapshot of the accumulated stats.
|
|
|
|
Only the scalar fields are included. ``_distribution`` (a
|
|
:class:`DistributionTracker`) is intentionally omitted because it
|
|
is not JSON-serializable and its sketch is not meant to be
|
|
persisted across checkpoints. ``_min`` / ``_max`` are reported as
|
|
``None`` when no samples have been added (rather than the ``inf``
|
|
sentinel, which JSON cannot represent).
|
|
"""
|
|
return {
|
|
"_total": self._total,
|
|
"_min": self._min if self._total_count > 0 else None,
|
|
"_max": self._max if self._total_count > 0 else None,
|
|
"_total_count": self._total_count,
|
|
}
|
|
|
|
def from_dict(self, state: Optional[Dict[str, Optional[float]]]) -> None:
|
|
"""Restore the scalar stats from a dict produced by :meth:`as_dict`.
|
|
|
|
``_distribution`` is left untouched (it keeps the empty tracker
|
|
created in ``__init__``), mirroring that the sketch is not
|
|
persisted. A non-dict ``state`` is ignored, and a ``None`` value
|
|
for any field falls back to its empty-Timer default (``.get``'s
|
|
default only fires on a missing key, not a present ``None``).
|
|
"""
|
|
if not isinstance(state, dict):
|
|
return
|
|
_total = state.get("_total")
|
|
self._total = _total if _total is not None else 0.0
|
|
_total_count = state.get("_total_count")
|
|
self._total_count = _total_count if _total_count is not None else 0.0
|
|
_min = state.get("_min")
|
|
self._min = _min if _min is not None else float("inf")
|
|
_max = state.get("_max")
|
|
self._max = _max if _max is not None else 0.0
|
|
|
|
|
|
class _DatasetStatsBuilder:
|
|
"""Helper class for building dataset stats.
|
|
|
|
When this class is created, we record the start time. When build() is
|
|
called with the final blocks of the new dataset, the time delta is
|
|
saved as part of the stats."""
|
|
|
|
def __init__(
|
|
self,
|
|
operator_name: str,
|
|
parent: "DatasetStats",
|
|
override_start_time: Optional[float],
|
|
):
|
|
self.operator_name = operator_name
|
|
self.parent = parent
|
|
self.start_time = override_start_time or time.perf_counter()
|
|
|
|
def build_multioperator(self, metadata: StatsDict) -> "DatasetStats":
|
|
op_metadata = {}
|
|
for i, (k, v) in enumerate(metadata.items()):
|
|
capped_k = capfirst(k)
|
|
if len(metadata) > 1:
|
|
if i == 0:
|
|
op_metadata[self.operator_name + capped_k] = v
|
|
else:
|
|
op_metadata[self.operator_name.split("->")[-1] + capped_k] = v
|
|
else:
|
|
op_metadata[self.operator_name] = v
|
|
stats = DatasetStats(
|
|
metadata=op_metadata,
|
|
parent=self.parent,
|
|
base_name=self.operator_name,
|
|
)
|
|
stats.time_total_s = time.perf_counter() - self.start_time
|
|
return stats
|
|
|
|
|
|
@ray.remote(num_cpus=0)
|
|
class _StatsActor:
|
|
"""Actor holding stats for blocks created by LazyBlockList.
|
|
|
|
This actor is shared across all datasets created in the same cluster.
|
|
In order to cap memory usage, we set a max number of stats to keep
|
|
in the actor. When this limit is exceeded, the stats will be garbage
|
|
collected in FIFO order.
|
|
|
|
TODO(ekl) we should consider refactoring LazyBlockList so stats can be
|
|
extracted without using an out-of-band actor."""
|
|
|
|
def __init__(self, max_stats=1000):
|
|
# Mapping from uuid -> (task_id -> list of blocks statistics).
|
|
self.metadata = collections.defaultdict(dict)
|
|
self.last_time = {}
|
|
self.start_time = {}
|
|
self.max_stats = max_stats
|
|
|
|
# Assign dataset uuids with a global counter.
|
|
self.next_dataset_id = 0
|
|
# Dataset metadata to be queried directly by DashboardHead api.
|
|
self.datasets: Dict[str, Any] = {}
|
|
|
|
# Cache of calls to ray.nodes() to prevent unnecessary network calls
|
|
self._ray_nodes_cache: Dict[str, str] = {}
|
|
|
|
# Initialize the metadata exporter
|
|
self._metadata_exporter = get_dataset_metadata_exporter()
|
|
self.dataset_metadatas: Dict[str, DatasetMetadata] = {}
|
|
|
|
# A FIFO queue of dataset_tags for finished datasets. This is used to
|
|
# efficiently evict the oldest finished datasets when max_stats is reached.
|
|
self.finished_datasets_queue = collections.deque()
|
|
|
|
# Ray Data dashboard metrics
|
|
# Everything is a gauge because we need to reset all of
|
|
# a dataset's metrics to 0 after each finishes execution.
|
|
op_tags_keys = ("dataset", "operator")
|
|
|
|
# TODO(scottjlee): move these overvie metrics as fields in a
|
|
# separate dataclass, similar to OpRuntimeMetrics.
|
|
self.spilled_bytes = Gauge(
|
|
"data_spilled_bytes",
|
|
description="""Bytes spilled by dataset operators.
|
|
DataContext.enable_get_object_locations_for_metrics
|
|
must be set to True to report this metric""",
|
|
tag_keys=op_tags_keys,
|
|
)
|
|
self.freed_bytes = Gauge(
|
|
"data_freed_bytes",
|
|
description="Bytes freed by dataset operators",
|
|
tag_keys=op_tags_keys,
|
|
)
|
|
self.current_bytes = Gauge(
|
|
"data_current_bytes",
|
|
description="Bytes currently in memory store used by dataset operators",
|
|
tag_keys=op_tags_keys,
|
|
)
|
|
self.cpu_usage_cores = Gauge(
|
|
"data_cpu_usage_cores",
|
|
description="CPUs allocated to dataset operators",
|
|
tag_keys=op_tags_keys,
|
|
)
|
|
self.gpu_usage_cores = Gauge(
|
|
"data_gpu_usage_cores",
|
|
description="GPUs allocated to dataset operators",
|
|
tag_keys=op_tags_keys,
|
|
)
|
|
self.output_bytes = Gauge(
|
|
"data_output_bytes",
|
|
description="Bytes outputted by dataset operators",
|
|
tag_keys=op_tags_keys,
|
|
)
|
|
self.output_rows = Gauge(
|
|
"data_output_rows",
|
|
description="Rows outputted by dataset operators",
|
|
tag_keys=op_tags_keys,
|
|
)
|
|
|
|
# === Metrics from OpRuntimeMetrics ===
|
|
# Inputs-related metrics
|
|
self.execution_metrics_inputs = (
|
|
self._create_prometheus_metrics_for_execution_metrics(
|
|
metrics_group=MetricsGroup.INPUTS,
|
|
tag_keys=op_tags_keys,
|
|
)
|
|
)
|
|
|
|
# Outputs-related metrics
|
|
self.execution_metrics_outputs = (
|
|
self._create_prometheus_metrics_for_execution_metrics(
|
|
metrics_group=MetricsGroup.OUTPUTS,
|
|
tag_keys=op_tags_keys,
|
|
)
|
|
)
|
|
|
|
# Task-related metrics
|
|
self.execution_metrics_tasks = (
|
|
self._create_prometheus_metrics_for_execution_metrics(
|
|
metrics_group=MetricsGroup.TASKS,
|
|
tag_keys=op_tags_keys,
|
|
)
|
|
)
|
|
|
|
# Object store memory-related metrics
|
|
self.execution_metrics_obj_store_memory = (
|
|
self._create_prometheus_metrics_for_execution_metrics(
|
|
metrics_group=MetricsGroup.OBJECT_STORE_MEMORY,
|
|
tag_keys=op_tags_keys,
|
|
)
|
|
)
|
|
|
|
# Actor related metrics
|
|
self.execution_metrics_actors = (
|
|
self._create_prometheus_metrics_for_execution_metrics(
|
|
metrics_group=MetricsGroup.ACTORS,
|
|
tag_keys=op_tags_keys,
|
|
)
|
|
)
|
|
|
|
# Miscellaneous metrics
|
|
self.execution_metrics_misc = (
|
|
self._create_prometheus_metrics_for_execution_metrics(
|
|
metrics_group=MetricsGroup.MISC,
|
|
tag_keys=op_tags_keys,
|
|
)
|
|
)
|
|
|
|
# Per Node metrics
|
|
self.per_node_metrics = self._create_prometheus_metrics_for_per_node_metrics()
|
|
|
|
iter_tag_keys = ("dataset",)
|
|
# TODO: add a per-streaming-split-worker ``rank`` label to iteration
|
|
# metrics so users can distinguish which split worker blocked on
|
|
# which stage.
|
|
|
|
self.time_to_first_batch_s = Gauge(
|
|
"data_iter_time_to_first_batch_seconds",
|
|
description="Total time spent waiting for the first batch after starting iteration. "
|
|
"This includes the dataset pipeline warmup time. This metric is accumulated across different epochs.",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
|
|
self.iter_block_fetching_s = Gauge(
|
|
"data_iter_block_fetching_seconds",
|
|
description="Seconds taken to fetch (with ray.get) blocks by iter_batches()",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_batch_shaping_s = Gauge(
|
|
"data_iter_batch_shaping_seconds",
|
|
description="Seconds taken to shape batch from incoming blocks by iter_batches()",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_batch_formatting_s = Gauge(
|
|
"data_iter_batch_formatting_seconds",
|
|
description="Seconds taken to format batches by iter_batches()",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_batch_collating_s = Gauge(
|
|
"data_iter_batch_collating_seconds",
|
|
description="Seconds taken to collate batches by iter_batches()",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_batch_finalizing_s = Gauge(
|
|
"data_iter_batch_finalizing_seconds",
|
|
description="Seconds taken to collate batches by iter_batches()",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
|
|
self.iter_total_blocked_s = Gauge(
|
|
"data_iter_total_blocked_seconds",
|
|
description="Seconds user thread is blocked by iter_batches()",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_total_s = Gauge(
|
|
"data_iter_total_seconds",
|
|
description="Total wall-clock seconds spent in the dataset iterator",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_blocked_production_wait_s = Gauge(
|
|
"data_iter_blocked_production_wait_seconds",
|
|
description="Seconds user thread is blocked on upstream data production",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_blocked_data_transfer_s = Gauge(
|
|
"data_iter_blocked_data_transfer_seconds",
|
|
description="Seconds user thread is blocked on cross-node data transfer (ray.get)",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_blocked_batching_s = Gauge(
|
|
"data_iter_blocked_batching_seconds",
|
|
description="Seconds user thread is blocked on batch creation",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_blocked_format_s = Gauge(
|
|
"data_iter_blocked_format_seconds",
|
|
description="Seconds user thread is blocked on batch formatting",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_blocked_collate_s = Gauge(
|
|
"data_iter_blocked_collate_seconds",
|
|
description="Seconds user thread is blocked on batch collation",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_blocked_finalize_s = Gauge(
|
|
"data_iter_blocked_finalize_seconds",
|
|
description="Seconds user thread is blocked on batch finalization",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_batches_total = Gauge(
|
|
"data_iter_batches_total",
|
|
description="Total batches delivered to the user thread",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_rows_total = Gauge(
|
|
"data_iter_rows_total",
|
|
description="Total rows delivered to the user thread",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_user_s = Gauge(
|
|
"data_iter_user_seconds",
|
|
description="Seconds spent in user code",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_initialize_s = Gauge(
|
|
"data_iter_initialize_seconds",
|
|
description="Seconds spent in iterator initialization code",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_get_ref_bundles_s = Gauge(
|
|
"data_iter_get_ref_bundles_seconds",
|
|
description="Seconds spent getting RefBundles from the dataset iterator",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_get_s = Gauge(
|
|
"data_iter_get_seconds",
|
|
description="Seconds spent in ray.get() while resolving block references",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_next_batch_s = Gauge(
|
|
"data_iter_next_batch_seconds",
|
|
description="Seconds spent getting the next batch from the block buffer",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_format_batch_s = Gauge(
|
|
"data_iter_format_batch_seconds",
|
|
description="Seconds spent formatting the batch",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_collate_batch_s = Gauge(
|
|
"data_iter_collate_batch_seconds",
|
|
description="Seconds spent collating the batch",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_finalize_batch_s = Gauge(
|
|
"data_iter_finalize_batch_seconds",
|
|
description="Seconds spent finalizing the batch",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_blocks_local = Gauge(
|
|
"data_iter_blocks_local",
|
|
description="Number of blocks already on the local node",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_blocks_remote = Gauge(
|
|
"data_iter_blocks_remote",
|
|
description="Number of blocks that require fetching from another node",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_unknown_location = Gauge(
|
|
"data_iter_unknown_location",
|
|
description="Number of blocks that have unknown locations",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
self.iter_prefetched_bytes = Gauge(
|
|
"data_iter_prefetched_bytes",
|
|
description="Current bytes of prefetched blocks in the iterator",
|
|
tag_keys=iter_tag_keys,
|
|
)
|
|
|
|
# === Dataset and Operator Metadata Metrics ===
|
|
dataset_tags = ("dataset", "job_id", "start_time")
|
|
self.data_dataset_estimated_total_blocks = Gauge(
|
|
"data_dataset_estimated_total_blocks",
|
|
description="Total work units in blocks for dataset",
|
|
tag_keys=dataset_tags,
|
|
)
|
|
self.data_dataset_estimated_total_rows = Gauge(
|
|
"data_dataset_estimated_total_rows",
|
|
description="Total work units in rows for dataset",
|
|
tag_keys=dataset_tags,
|
|
)
|
|
self.data_dataset_state = Gauge(
|
|
"data_dataset_state",
|
|
description=f"State of dataset ({', '.join([f'{s.value}={s.name}' for s in DatasetState])})",
|
|
tag_keys=dataset_tags,
|
|
)
|
|
|
|
operator_tags = ("dataset", "operator")
|
|
self.data_operator_estimated_total_blocks = Gauge(
|
|
"data_operator_estimated_total_blocks",
|
|
description="Total work units in blocks for operator",
|
|
tag_keys=operator_tags,
|
|
)
|
|
self.data_operator_estimated_total_rows = Gauge(
|
|
"data_operator_estimated_total_rows",
|
|
description="Total work units in rows for operator",
|
|
tag_keys=operator_tags,
|
|
)
|
|
self.data_operator_queued_blocks = Gauge(
|
|
"data_operator_queued_blocks",
|
|
description="Number of queued blocks for operator",
|
|
tag_keys=operator_tags,
|
|
)
|
|
self.data_operator_state = Gauge(
|
|
"data_operator_state",
|
|
description=f"State of operator ({', '.join([f'{s.value}={s.name}' for s in DatasetState])})",
|
|
tag_keys=operator_tags,
|
|
)
|
|
|
|
def _create_prometheus_metrics_for_execution_metrics(
|
|
self, metrics_group: MetricsGroup, tag_keys: Tuple[str, ...]
|
|
) -> Dict[str, Metric]:
|
|
metrics = {}
|
|
for metric in OpRuntimeMetrics.get_metrics():
|
|
if not metric.metrics_group == metrics_group:
|
|
continue
|
|
if metric.metrics_type == MetricsType.Unsupported:
|
|
continue
|
|
metric_name = f"data_{metric.name}"
|
|
metric_description = metric.description
|
|
if metric.metrics_type == MetricsType.Gauge:
|
|
metrics[metric.name] = Gauge(
|
|
metric_name,
|
|
description=metric_description,
|
|
tag_keys=tag_keys,
|
|
)
|
|
elif metric.metrics_type == MetricsType.Histogram:
|
|
metrics[metric.name] = Histogram(
|
|
metric_name,
|
|
description=metric_description,
|
|
tag_keys=tag_keys,
|
|
**metric.metrics_args,
|
|
)
|
|
elif metric.metrics_type == MetricsType.Counter:
|
|
metrics[metric.name] = Counter(
|
|
metric_name,
|
|
description=metric_description,
|
|
tag_keys=tag_keys,
|
|
)
|
|
return metrics
|
|
|
|
def _create_prometheus_metrics_for_per_node_metrics(self) -> Dict[str, Gauge]:
|
|
metrics = {}
|
|
for field in fields(NodeMetrics):
|
|
metric_name = f"data_{field.name}_per_node"
|
|
metrics[field.name] = Gauge(
|
|
metric_name,
|
|
description="",
|
|
tag_keys=("dataset", "node_ip"),
|
|
)
|
|
return metrics
|
|
|
|
def gen_dataset_id(self) -> str:
|
|
"""Generate a unique dataset_id for tracking datasets."""
|
|
dataset_id = str(self.next_dataset_id)
|
|
self.next_dataset_id += 1
|
|
return dataset_id
|
|
|
|
def update_execution_metrics(
|
|
self,
|
|
dataset_tag: str,
|
|
op_metrics: List[Dict[str, int | float]],
|
|
operator_tags: List[str],
|
|
state: Dict[str, Any],
|
|
per_node_metrics: Optional[Dict[str, Dict[str, int | float]]] = None,
|
|
):
|
|
def _record(
|
|
prom_metric: Metric,
|
|
value: Union[int, float, List[int]],
|
|
tags: Dict[str, str] = None,
|
|
):
|
|
if isinstance(prom_metric, Gauge):
|
|
prom_metric.set(value, tags)
|
|
elif isinstance(prom_metric, Counter):
|
|
prom_metric.inc(value, tags)
|
|
elif isinstance(prom_metric, Histogram):
|
|
if isinstance(value, RuntimeMetricsHistogram):
|
|
value.export_to(prom_metric, tags)
|
|
|
|
for stats, operator_tag in zip(op_metrics, operator_tags):
|
|
tags = self._create_tags(dataset_tag, operator_tag)
|
|
|
|
self.spilled_bytes.set(stats.get("obj_store_mem_spilled", 0), tags)
|
|
self.freed_bytes.set(stats.get("obj_store_mem_freed", 0), tags)
|
|
self.current_bytes.set(stats.get("obj_store_mem_used", 0), tags)
|
|
self.output_bytes.set(stats.get("bytes_task_outputs_generated", 0), tags)
|
|
self.output_rows.set(stats.get("row_outputs_taken", 0), tags)
|
|
self.cpu_usage_cores.set(stats.get("cpu_usage", 0), tags)
|
|
self.gpu_usage_cores.set(stats.get("gpu_usage", 0), tags)
|
|
for field_name, prom_metric in self.execution_metrics_inputs.items():
|
|
_record(prom_metric, stats.get(field_name, 0), tags)
|
|
for field_name, prom_metric in self.execution_metrics_outputs.items():
|
|
_record(prom_metric, stats.get(field_name, 0), tags)
|
|
|
|
for field_name, prom_metric in self.execution_metrics_tasks.items():
|
|
_record(prom_metric, stats.get(field_name, 0), tags)
|
|
|
|
for (
|
|
field_name,
|
|
prom_metric,
|
|
) in self.execution_metrics_obj_store_memory.items():
|
|
_record(prom_metric, stats.get(field_name, 0), tags)
|
|
|
|
for field_name, prom_metric in self.execution_metrics_actors.items():
|
|
_record(prom_metric, stats.get(field_name, 0), tags)
|
|
|
|
for field_name, prom_metric in self.execution_metrics_misc.items():
|
|
_record(prom_metric, stats.get(field_name, 0), tags)
|
|
|
|
# Update per node metrics if they exist, the creation of these metrics is controlled
|
|
# by the _data_context.enable_per_node_metrics flag in the streaming executor but
|
|
# that is not exposed in the _StatsActor so here we simply check if the metrics exist
|
|
# and if so, update them
|
|
if per_node_metrics is not None:
|
|
for node_id, node_metrics in per_node_metrics.items():
|
|
# Translate node_id into node_name (the node ip), cache node info
|
|
if node_id not in self._ray_nodes_cache:
|
|
# Rebuilding this cache will fetch all nodes, this
|
|
# only needs to be done up to once per loop
|
|
self._rebuild_ray_nodes_cache()
|
|
|
|
node_ip = self._ray_nodes_cache.get(node_id, NODE_UNKNOWN)
|
|
|
|
tags = self._create_tags(dataset_tag=dataset_tag, node_ip_tag=node_ip)
|
|
for metric_name, metric_value in node_metrics.items():
|
|
prom_metric = self.per_node_metrics[metric_name]
|
|
_record(prom_metric, metric_value, tags)
|
|
|
|
# This update is called from a dataset's executor,
|
|
# so all tags should contain the same dataset
|
|
self.update_dataset(dataset_tag, state)
|
|
|
|
def _rebuild_ray_nodes_cache(self) -> None:
|
|
current_nodes = ray.nodes()
|
|
for node in current_nodes:
|
|
node_id = node.get("NodeID", None)
|
|
node_name = node.get("NodeName", None)
|
|
if node_id is not None and node_name is not None:
|
|
self._ray_nodes_cache[node_id] = node_name
|
|
|
|
def update_iteration_metrics(
|
|
self,
|
|
stats: "DatasetStats",
|
|
dataset_tag,
|
|
):
|
|
tags = self._create_tags(dataset_tag)
|
|
|
|
self.iter_initialize_s.set(stats.iter_initialize_s.get(), tags)
|
|
self.iter_total_s.set(stats.iter_total_s.get(), tags)
|
|
self.iter_get_ref_bundles_s.set(stats.iter_get_ref_bundles_s.get(), tags)
|
|
self.iter_get_s.set(stats.iter_get_s.get(), tags)
|
|
self.iter_next_batch_s.set(stats.iter_next_batch_s.get(), tags)
|
|
self.iter_format_batch_s.set(stats.iter_format_batch_s.get(), tags)
|
|
self.iter_collate_batch_s.set(stats.iter_collate_batch_s.get(), tags)
|
|
self.iter_finalize_batch_s.set(stats.iter_finalize_batch_s.get(), tags)
|
|
self.iter_blocks_local.set(stats.iter_blocks_local, tags)
|
|
self.iter_blocks_remote.set(stats.iter_blocks_remote, tags)
|
|
self.iter_unknown_location.set(stats.iter_unknown_location, tags)
|
|
self.iter_prefetched_bytes.set(stats.iter_prefetched_bytes, tags)
|
|
|
|
self.iter_block_fetching_s.set(stats.iter_get_s.get(), tags)
|
|
self.iter_batch_shaping_s.set(stats.iter_next_batch_s.get(), tags)
|
|
self.iter_batch_formatting_s.set(stats.iter_format_batch_s.get(), tags)
|
|
self.iter_batch_collating_s.set(stats.iter_collate_batch_s.get(), tags)
|
|
self.iter_batch_finalizing_s.set(stats.iter_finalize_batch_s.get(), tags)
|
|
|
|
self.time_to_first_batch_s.set(stats.iter_time_to_first_batch_s.get(), tags)
|
|
|
|
self.iter_total_blocked_s.set(stats.iter_total_blocked_s.get(), tags)
|
|
self.iter_blocked_production_wait_s.set(
|
|
stats.iter_blocked_production_wait_s.get(), tags
|
|
)
|
|
self.iter_blocked_data_transfer_s.set(
|
|
stats.iter_blocked_data_transfer_s.get(), tags
|
|
)
|
|
self.iter_blocked_batching_s.set(stats.iter_blocked_batching_s.get(), tags)
|
|
self.iter_blocked_format_s.set(stats.iter_blocked_format_s.get(), tags)
|
|
self.iter_blocked_collate_s.set(stats.iter_blocked_collate_s.get(), tags)
|
|
self.iter_blocked_finalize_s.set(stats.iter_blocked_finalize_s.get(), tags)
|
|
self.iter_batches_total.set(stats.iter_batches_total, tags)
|
|
self.iter_rows_total.set(stats.iter_rows_total, tags)
|
|
self.iter_user_s.set(stats.iter_user_s.get(), tags)
|
|
|
|
def register_dataset(
|
|
self,
|
|
job_id: str,
|
|
dataset_tag: str,
|
|
operator_tags: List[str],
|
|
topology: Topology,
|
|
data_context: DataContextMetadata,
|
|
):
|
|
start_time = time.time()
|
|
self.datasets[dataset_tag] = {
|
|
"job_id": job_id,
|
|
"state": DatasetState.PENDING.name,
|
|
"progress": 0,
|
|
"total": 0,
|
|
"total_rows": 0,
|
|
"start_time": start_time,
|
|
"end_time": None,
|
|
"operators": {
|
|
operator: {
|
|
"state": DatasetState.PENDING.name,
|
|
"progress": 0,
|
|
"total": 0,
|
|
"queued_blocks": 0,
|
|
}
|
|
for operator in operator_tags
|
|
},
|
|
}
|
|
if self._metadata_exporter is not None:
|
|
self.dataset_metadatas[dataset_tag] = DatasetMetadata(
|
|
job_id=job_id,
|
|
topology=topology,
|
|
dataset_id=dataset_tag,
|
|
start_time=start_time,
|
|
data_context=data_context,
|
|
execution_start_time=None,
|
|
execution_end_time=None,
|
|
state=DatasetState.PENDING.name,
|
|
)
|
|
self._metadata_exporter.export_dataset_metadata(
|
|
self.dataset_metadatas[dataset_tag]
|
|
)
|
|
|
|
def update_dataset(self, dataset_tag: str, state: Dict[str, Any]):
|
|
self.datasets[dataset_tag].update(state)
|
|
state = self.datasets[dataset_tag]
|
|
|
|
job_id = self.datasets[dataset_tag].get("job_id", "None")
|
|
start_time = str(int(self.datasets[dataset_tag].get("start_time", 0)))
|
|
|
|
# Update dataset-level metrics
|
|
dataset_tags = {
|
|
"dataset": dataset_tag,
|
|
"job_id": job_id,
|
|
"start_time": start_time,
|
|
}
|
|
self.data_dataset_estimated_total_blocks.set(
|
|
state.get("total", 0), dataset_tags
|
|
)
|
|
self.data_dataset_estimated_total_rows.set(
|
|
state.get("total_rows", 0), dataset_tags
|
|
)
|
|
state_string = state.get("state", DatasetState.UNKNOWN.name)
|
|
state_enum = DatasetState.from_string(state_string)
|
|
self.data_dataset_state.set(state_enum.value, dataset_tags)
|
|
self.update_dataset_metadata_state(dataset_tag, state_string)
|
|
|
|
# Update operator-level metrics
|
|
operator_states: Dict[str, str] = {}
|
|
for operator, op_state in state.get("operators", {}).items():
|
|
operator_tags = {
|
|
"dataset": dataset_tag,
|
|
"operator": operator,
|
|
}
|
|
self.data_operator_estimated_total_blocks.set(
|
|
op_state.get("total", 0), operator_tags
|
|
)
|
|
self.data_operator_estimated_total_rows.set(
|
|
op_state.get("total_rows", 0), operator_tags
|
|
)
|
|
self.data_operator_queued_blocks.set(
|
|
op_state.get("queued_blocks", 0), operator_tags
|
|
)
|
|
|
|
# Get state code directly from enum
|
|
state_string = op_state.get("state", DatasetState.UNKNOWN.name)
|
|
state_enum = DatasetState.from_string(state_string)
|
|
self.data_operator_state.set(state_enum.value, operator_tags)
|
|
operator_states[operator] = state_string
|
|
|
|
self.update_dataset_metadata_operator_states(dataset_tag, operator_states)
|
|
|
|
# Evict the oldest finished datasets to ensure the `max_stats` limit is enforced.
|
|
if state["state"] in {DatasetState.FINISHED.name, DatasetState.FAILED.name}:
|
|
self.finished_datasets_queue.append(dataset_tag)
|
|
while len(self.datasets) > self.max_stats and self.finished_datasets_queue:
|
|
tag_to_evict = self.finished_datasets_queue.popleft()
|
|
self.datasets.pop(tag_to_evict, None)
|
|
self.dataset_metadatas.pop(tag_to_evict, None)
|
|
|
|
def get_datasets(self, job_id: Optional[str] = None):
|
|
if not job_id:
|
|
return self.datasets
|
|
return {k: v for k, v in self.datasets.items() if v["job_id"] == job_id}
|
|
|
|
def update_dataset_metadata_state(self, dataset_id: str, new_state: str):
|
|
if dataset_id not in self.dataset_metadatas:
|
|
return
|
|
update_time = time.time()
|
|
dataset_metadata = self.dataset_metadatas[dataset_id]
|
|
if dataset_metadata.state == new_state:
|
|
return
|
|
updated_dataset_metadata = copy.deepcopy(dataset_metadata)
|
|
updated_dataset_metadata.state = new_state
|
|
if new_state == DatasetState.RUNNING.name:
|
|
updated_dataset_metadata.execution_start_time = update_time
|
|
elif new_state in (DatasetState.FINISHED.name, DatasetState.FAILED.name):
|
|
updated_dataset_metadata.execution_end_time = update_time
|
|
# Update metadata of running operators
|
|
for operator in updated_dataset_metadata.topology.operators:
|
|
if operator.state == DatasetState.RUNNING.name:
|
|
operator.state = new_state
|
|
operator.execution_end_time = update_time
|
|
|
|
self.dataset_metadatas[dataset_id] = updated_dataset_metadata
|
|
if self._metadata_exporter is not None:
|
|
self._metadata_exporter.export_dataset_metadata(
|
|
updated_dataset_metadata,
|
|
include_data_context=False,
|
|
include_op_args=False,
|
|
)
|
|
|
|
def update_dataset_metadata_operator_states(
|
|
self, dataset_id: str, operator_states: Dict[str, str]
|
|
):
|
|
if dataset_id not in self.dataset_metadatas:
|
|
return
|
|
|
|
dataset_metadata = self.dataset_metadatas[dataset_id]
|
|
update_needed = False
|
|
for operator in dataset_metadata.topology.operators:
|
|
if (
|
|
operator.id in operator_states
|
|
and operator.state != operator_states[operator.id]
|
|
):
|
|
update_needed = True
|
|
break
|
|
|
|
if not update_needed:
|
|
return
|
|
|
|
updated_dataset_metadata = copy.deepcopy(dataset_metadata)
|
|
update_time = time.time()
|
|
for operator in updated_dataset_metadata.topology.operators:
|
|
if operator.id in operator_states:
|
|
new_state = operator_states[operator.id]
|
|
if operator.state == new_state:
|
|
continue
|
|
operator.state = new_state
|
|
if new_state == DatasetState.RUNNING.name:
|
|
operator.execution_start_time = update_time
|
|
elif new_state in (
|
|
DatasetState.FINISHED.name,
|
|
DatasetState.FAILED.name,
|
|
):
|
|
operator.execution_end_time = update_time
|
|
# Handle outlier case for InputDataBuffer, which is marked as finished immediately and does not have a RUNNING state.
|
|
# Set the execution time the same as its end time
|
|
if not operator.execution_start_time:
|
|
operator.execution_start_time = update_time
|
|
|
|
self.dataset_metadatas[dataset_id] = updated_dataset_metadata
|
|
if self._metadata_exporter is not None:
|
|
self._metadata_exporter.export_dataset_metadata(
|
|
updated_dataset_metadata,
|
|
include_data_context=False,
|
|
include_op_args=False,
|
|
)
|
|
|
|
def _create_tags(
|
|
self,
|
|
dataset_tag: str,
|
|
operator_tag: Optional[str] = None,
|
|
node_ip_tag: Optional[str] = None,
|
|
):
|
|
tags = {"dataset": dataset_tag}
|
|
if operator_tag is not None:
|
|
tags["operator"] = operator_tag
|
|
if node_ip_tag is not None:
|
|
tags["node_ip"] = node_ip_tag
|
|
return tags
|
|
|
|
|
|
def get_or_create_stats_actor() -> ActorHandle[_StatsActor]:
|
|
"""Each cluster will contain exactly 1 _StatsActor. This function
|
|
returns the current _StatsActor handle, or create a new one if one
|
|
does not exist in the connected cluster. The _StatsActor is pinned on
|
|
on driver process' node.
|
|
"""
|
|
if not ray.is_initialized():
|
|
raise RuntimeError(
|
|
"Ray is not initialized. Driver might be not connected to Ray."
|
|
)
|
|
|
|
# `_global_node` is None under Ray Client (the driver is not a cluster
|
|
# worker), so only log the cluster_id when it is available.
|
|
global_node = ray._private.worker._global_node
|
|
if global_node is not None:
|
|
logger.debug(f"Stats Actor located on cluster_id={global_node.cluster_id}")
|
|
|
|
# so it fate-shares with the driver.
|
|
label_selector = {
|
|
ray._raylet.RAY_NODE_ID_KEY: ray.get_runtime_context().get_node_id()
|
|
}
|
|
|
|
return _StatsActor.options(
|
|
name=STATS_ACTOR_NAME,
|
|
namespace=STATS_ACTOR_NAMESPACE,
|
|
get_if_exists=True,
|
|
lifetime="detached",
|
|
label_selector=label_selector,
|
|
).remote()
|
|
|
|
|
|
class _StatsManager:
|
|
"""A Class containing util functions that manage remote calls to _StatsActor.
|
|
|
|
Ray Data updates metrics through the _StatsManager, and direct remote calls
|
|
to the _StatsActor is discouraged. Some functionalities provided by
|
|
_StatsManager:
|
|
- Format and update iteration metrics
|
|
- Format and update execution metrics
|
|
- Aggregate per node metrics
|
|
- Dataset registration
|
|
"""
|
|
|
|
@staticmethod
|
|
def _aggregate_per_node_metrics(
|
|
op_metrics: List[OpRuntimeMetrics],
|
|
) -> Optional[Mapping[str, Mapping[str, int | float]]]:
|
|
"""
|
|
Aggregate per-node metrics from a list of OpRuntimeMetrics objects.
|
|
|
|
If per-node metrics are disabled in the current DataContext, returns None.
|
|
Otherwise, it sums up all NodeMetrics fields across the provided metrics and
|
|
returns a nested dictionary mapping each node ID to a dict of field values.
|
|
"""
|
|
if not DataContext.get_current().enable_per_node_metrics:
|
|
return None
|
|
|
|
aggregated_by_node = defaultdict(lambda: defaultdict(int))
|
|
for metrics in op_metrics:
|
|
for node_id, node_metrics in metrics._per_node_metrics.items():
|
|
agg_node_metrics = aggregated_by_node[node_id]
|
|
for f in fields(NodeMetrics):
|
|
agg_node_metrics[f.name] += getattr(node_metrics, f.name)
|
|
|
|
return aggregated_by_node
|
|
|
|
@staticmethod
|
|
def update_execution_metrics(
|
|
dataset_tag: str,
|
|
op_metrics: List[OpRuntimeMetrics],
|
|
operator_tags: List[str],
|
|
state: Dict[str, Any],
|
|
):
|
|
per_node_metrics = _StatsManager._aggregate_per_node_metrics(op_metrics)
|
|
op_metrics_dicts = [metric.as_dict() for metric in op_metrics]
|
|
args = (
|
|
dataset_tag,
|
|
op_metrics_dicts,
|
|
operator_tags,
|
|
state,
|
|
per_node_metrics,
|
|
)
|
|
try:
|
|
get_or_create_stats_actor().update_execution_metrics.remote(*args)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Error occurred during update_execution_metrics.remote call to _StatsActor: {e}",
|
|
exc_info=True,
|
|
)
|
|
return
|
|
|
|
@staticmethod
|
|
def update_iteration_metrics(stats: "DatasetStats", dataset_tag: str):
|
|
args = (stats, dataset_tag)
|
|
try:
|
|
get_or_create_stats_actor().update_iteration_metrics.remote(*args)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Error occurred during update_iteration_metrics.remote call to _StatsActor: {e}",
|
|
exc_info=True,
|
|
)
|
|
|
|
@staticmethod
|
|
def register_dataset_to_stats_actor(
|
|
dataset_tag: str,
|
|
operator_tags: List[str],
|
|
topology: Topology,
|
|
data_context: DataContext,
|
|
):
|
|
"""Register a dataset with the stats actor.
|
|
|
|
Args:
|
|
dataset_tag: Tag for the dataset
|
|
operator_tags: List of operator tags
|
|
topology: Optional Topology representing the DAG structure to export
|
|
data_context: The DataContext attached to the dataset
|
|
"""
|
|
# Convert DataContext to DataContextMetadata before serialization to avoid
|
|
# module dependency issues during Ray's cloudpickle serialization.
|
|
data_context = DataContextMetadata.from_data_context(data_context)
|
|
|
|
get_or_create_stats_actor().register_dataset.remote(
|
|
ray.get_runtime_context().get_job_id(),
|
|
dataset_tag,
|
|
operator_tags,
|
|
topology,
|
|
data_context,
|
|
)
|
|
|
|
@staticmethod
|
|
def gen_dataset_id_from_stats_actor() -> str:
|
|
try:
|
|
stats_actor = get_or_create_stats_actor()
|
|
|
|
return ray.get(stats_actor.gen_dataset_id.remote())
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Failed to generate dataset_id, falling back to random uuid_v4: {e}"
|
|
)
|
|
# Getting dataset id from _StatsActor may fail, in this case
|
|
# fall back to uuid4
|
|
return uuid4().hex
|
|
|
|
|
|
class DatasetStats:
|
|
"""Holds the execution times for a given Dataset.
|
|
|
|
This object contains a reference to the parent Dataset's stats as well,
|
|
but not the Dataset object itself, to allow its blocks to be dropped from
|
|
memory."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
metadata: StatsDict,
|
|
parent: Union[Optional["DatasetStats"], List["DatasetStats"]],
|
|
base_name: str = None,
|
|
):
|
|
"""Create dataset stats.
|
|
|
|
Args:
|
|
metadata: Dict of operators used to create this Dataset from the
|
|
previous one. Typically one entry, e.g., {"map": [...]}.
|
|
parent: Reference to parent Dataset's stats, or a list of parents
|
|
if there are multiple.
|
|
base_name: The name of the base operation for a multi-operator operation.
|
|
"""
|
|
|
|
self.metadata: StatsDict = metadata
|
|
if parent is not None and not isinstance(parent, list):
|
|
parent = [parent]
|
|
self.parents: List["DatasetStats"] = parent or []
|
|
self.number: int = (
|
|
0 if not self.parents else max(p.number for p in self.parents) + 1
|
|
)
|
|
self.base_name = base_name
|
|
# TODO(ekl) deprecate and remove the notion of dataset UUID once we move
|
|
# fully to streaming execution.
|
|
self.dataset_uuid: str = UNKNOWN_UUID
|
|
self.time_total_s: float = 0
|
|
|
|
# Streaming executor stats. Timer's KLL-sketch percentile
|
|
# backend has bounded memory, so p50/p90 tracking is always on
|
|
# — no opt-in needed.
|
|
self.streaming_exec_schedule_s: Timer = Timer()
|
|
|
|
# Iteration stats, filled out if the user iterates over the dataset.
|
|
self.iter_wait_s: Timer = Timer()
|
|
self.iter_get_ref_bundles_s: Timer = Timer()
|
|
self.iter_get_s: Timer = Timer()
|
|
self.iter_next_batch_s: Timer = Timer()
|
|
self.iter_format_batch_s: Timer = Timer()
|
|
self.iter_collate_batch_s: Timer = Timer()
|
|
self.iter_finalize_batch_s: Timer = Timer()
|
|
self.iter_time_to_first_batch_s: Timer = Timer()
|
|
self.iter_total_blocked_s: Timer = Timer()
|
|
self.iter_blocked_production_wait_s: Timer = Timer()
|
|
self.iter_blocked_data_transfer_s: Timer = Timer()
|
|
self.iter_blocked_batching_s: Timer = Timer()
|
|
self.iter_blocked_format_s: Timer = Timer()
|
|
self.iter_blocked_collate_s: Timer = Timer()
|
|
self.iter_blocked_finalize_s: Timer = Timer()
|
|
self.iter_user_s: Timer = Timer()
|
|
self.iter_initialize_s: Timer = Timer()
|
|
self.iter_total_s: Timer = Timer()
|
|
self.iter_batches_total: int = 0
|
|
self.iter_rows_total: int = 0
|
|
self.extra_metrics = {}
|
|
|
|
# Block fetch stats during iteration.
|
|
# These are stats about locations of blocks when the iterator is trying to
|
|
# consume them. The iteration performance will be affected depending on
|
|
# whether the block is in the local object store of the node where the
|
|
# iterator is running.
|
|
# This serves as an indicator of block prefetching effectiveness.
|
|
self.iter_blocks_local: int = 0
|
|
self.iter_blocks_remote: int = 0
|
|
self.iter_unknown_location: int = 0
|
|
self.iter_prefetched_bytes: int = 0
|
|
|
|
# Memory usage stats
|
|
self.global_bytes_spilled: int = 0
|
|
self.global_bytes_restored: int = 0
|
|
self.dataset_bytes_spilled: int = 0
|
|
|
|
# Streaming split coordinator stats (dataset level)
|
|
self.streaming_split_coordinator_s: Timer = Timer()
|
|
|
|
def get_blocked_timer(self, stage: IterationStage) -> Timer:
|
|
"""Return the blocked-attribution Timer for the given iteration stage."""
|
|
match stage:
|
|
case IterationStage.PRODUCTION_WAIT:
|
|
return self.iter_blocked_production_wait_s
|
|
case IterationStage.DATA_TRANSFER:
|
|
return self.iter_blocked_data_transfer_s
|
|
case IterationStage.BATCHING:
|
|
return self.iter_blocked_batching_s
|
|
case IterationStage.FORMAT:
|
|
return self.iter_blocked_format_s
|
|
case IterationStage.COLLATE:
|
|
return self.iter_blocked_collate_s
|
|
case IterationStage.FINALIZE:
|
|
return self.iter_blocked_finalize_s
|
|
case _:
|
|
raise ValueError(f"Unknown iteration stage: {stage}")
|
|
|
|
@property
|
|
def stats_actor(self):
|
|
return get_or_create_stats_actor()
|
|
|
|
def child_builder(
|
|
self, name: str, override_start_time: Optional[float] = None
|
|
) -> _DatasetStatsBuilder:
|
|
"""Start recording stats for an op of the given name (e.g., map)."""
|
|
return _DatasetStatsBuilder(name, self, override_start_time)
|
|
|
|
def to_summary(self) -> "DatasetStatsSummary":
|
|
"""Generate a `DatasetStatsSummary` object from the given `DatasetStats`
|
|
object, which can be used to generate a summary string."""
|
|
operators_stats = []
|
|
is_sub_operator = len(self.metadata) > 1
|
|
|
|
iter_stats = IterStatsSummary(
|
|
self.iter_wait_s,
|
|
self.iter_get_ref_bundles_s,
|
|
self.iter_get_s,
|
|
self.iter_next_batch_s,
|
|
self.iter_format_batch_s,
|
|
self.iter_collate_batch_s,
|
|
self.iter_finalize_batch_s,
|
|
self.iter_time_to_first_batch_s,
|
|
self.iter_total_blocked_s,
|
|
self.iter_user_s,
|
|
self.iter_initialize_s,
|
|
self.iter_total_s,
|
|
self.streaming_split_coordinator_s,
|
|
self.iter_blocks_local,
|
|
self.iter_blocks_remote,
|
|
self.iter_unknown_location,
|
|
self.iter_prefetched_bytes,
|
|
self.iter_blocked_production_wait_s,
|
|
self.iter_blocked_data_transfer_s,
|
|
self.iter_blocked_batching_s,
|
|
self.iter_blocked_format_s,
|
|
self.iter_blocked_collate_s,
|
|
self.iter_blocked_finalize_s,
|
|
self.iter_batches_total,
|
|
self.iter_rows_total,
|
|
)
|
|
|
|
stats_summary_parents = []
|
|
if self.parents is not None:
|
|
stats_summary_parents = [p.to_summary() for p in self.parents]
|
|
|
|
# Collect the sum of the final output row counts from all parent nodes
|
|
parent_total_output = 0
|
|
for i, parent_summary in enumerate(stats_summary_parents):
|
|
if parent_summary.operators_stats:
|
|
# Get the last operator stats from the current parent summary
|
|
last_parent_op = parent_summary.operators_stats[-1]
|
|
# Extract output row count (handle dict type with "sum" key)
|
|
op_output = (
|
|
last_parent_op.output_num_rows.sum
|
|
if last_parent_op.output_num_rows
|
|
else 0
|
|
)
|
|
logger.debug(
|
|
f"Parent {i + 1} (operator: {last_parent_op.operator_name}) contributes {op_output} rows to input"
|
|
)
|
|
parent_total_output += op_output
|
|
|
|
# Create temporary operator stats objects from block metadata
|
|
op_stats = [
|
|
OperatorStatsSummary.from_block_metadata(
|
|
name, stats, is_sub_operator=is_sub_operator
|
|
)
|
|
for name, stats in self.metadata.items()
|
|
]
|
|
|
|
for i, op_stat in enumerate(op_stats):
|
|
# For sub-operators: inherit input based on the order in the current list
|
|
if is_sub_operator:
|
|
if i == 0:
|
|
# Input of the first sub-operator is the total output from parent nodes
|
|
op_stat.total_input_num_rows = parent_total_output
|
|
else:
|
|
# Input of subsequent sub-operators is the output of the previous sub-operator
|
|
prev_op = op_stats[i - 1]
|
|
op_stat.total_input_num_rows = (
|
|
prev_op.output_num_rows.sum if prev_op.output_num_rows else 0
|
|
)
|
|
else:
|
|
# Single operator scenario: input rows = total output from all parent nodes
|
|
op_stat.total_input_num_rows = parent_total_output
|
|
operators_stats.append(op_stat)
|
|
# Keep ``streaming_exec_schedule_s`` as the total wall-clock time so
|
|
# ``runtime_metrics()`` can still divide by total_wall_time and
|
|
# produce a meaningful percentage. Per-iteration avg/max are
|
|
# exposed separately. ``StreamingExecutor._generate_stats``
|
|
# always assigns a ``Timer`` (never ``None``), so this call site
|
|
# needs no guard.
|
|
schedule_timer = self.streaming_exec_schedule_s
|
|
streaming_exec_schedule_s = schedule_timer.get()
|
|
streaming_exec_schedule_avg_s = schedule_timer.avg()
|
|
streaming_exec_schedule_max_s = schedule_timer.max()
|
|
streaming_exec_schedule_p50_s = schedule_timer.percentile(0.5)
|
|
streaming_exec_schedule_p90_s = schedule_timer.percentile(0.9)
|
|
return DatasetStatsSummary(
|
|
operators_stats,
|
|
iter_stats,
|
|
stats_summary_parents,
|
|
self.number,
|
|
self.dataset_uuid,
|
|
self.time_total_s,
|
|
self.base_name,
|
|
self.extra_metrics,
|
|
self.global_bytes_spilled,
|
|
self.global_bytes_restored,
|
|
self.dataset_bytes_spilled,
|
|
streaming_exec_schedule_s,
|
|
streaming_exec_schedule_avg_s,
|
|
streaming_exec_schedule_max_s,
|
|
streaming_exec_schedule_p50_s,
|
|
streaming_exec_schedule_p90_s,
|
|
)
|
|
|
|
def runtime_metrics(self) -> str:
|
|
"""Generate a string representing the runtime metrics of a Dataset. This is
|
|
a high level summary of the time spent in Ray Data code broken down by operator.
|
|
It also includes the time spent in the scheduler. Times are shown as the total
|
|
time for each operator and percentages of time are shown as a fraction of the
|
|
total time for the whole dataset."""
|
|
return self.to_summary().runtime_metrics()
|
|
|
|
def set_uuid_recursive(self, dataset_uuid: Optional[str]) -> None:
|
|
"""Recursively set the dataset uuid (if not None) throughout all stats parents."""
|
|
if (
|
|
self.dataset_uuid is None or self.dataset_uuid == UNKNOWN_UUID
|
|
) and dataset_uuid is not None:
|
|
self.dataset_uuid = dataset_uuid
|
|
for parent in self.parents:
|
|
parent.set_uuid_recursive(dataset_uuid)
|
|
|
|
|
|
@DeveloperAPI
|
|
@dataclass
|
|
class DatasetStatsSummary:
|
|
operators_stats: List["OperatorStatsSummary"]
|
|
iter_stats: "IterStatsSummary"
|
|
parents: List["DatasetStatsSummary"]
|
|
number: int
|
|
dataset_uuid: str
|
|
time_total_s: float
|
|
base_name: str
|
|
extra_metrics: Dict[str, Any]
|
|
global_bytes_spilled: int
|
|
global_bytes_restored: int
|
|
dataset_bytes_spilled: int
|
|
streaming_exec_schedule_s: float
|
|
streaming_exec_schedule_avg_s: float
|
|
streaming_exec_schedule_max_s: float
|
|
# KLL-sketch-approximate percentiles (k=200, ~1.65% rank error).
|
|
# 0 when no samples have been added, or when the optional
|
|
# ``datasketches`` dependency is unavailable.
|
|
streaming_exec_schedule_p50_s: float
|
|
streaming_exec_schedule_p90_s: float
|
|
|
|
def to_string(
|
|
self,
|
|
already_printed: Optional[Set[str]] = None,
|
|
include_parent: bool = True,
|
|
add_global_stats: bool = True,
|
|
) -> str:
|
|
"""Return a human-readable summary of this Dataset's stats.
|
|
|
|
Args:
|
|
already_printed: Set of operator IDs that have already had its stats printed
|
|
out.
|
|
include_parent: If true, also include parent stats summary; otherwise, only
|
|
log stats of the latest operator.
|
|
add_global_stats: If true, includes global stats to this summary.
|
|
Returns:
|
|
String with summary statistics for executing the Dataset.
|
|
"""
|
|
if already_printed is None:
|
|
already_printed = set()
|
|
|
|
out = ""
|
|
if self.parents and include_parent:
|
|
for p in self.parents:
|
|
parent_sum = p.to_string(already_printed, add_global_stats=False)
|
|
if parent_sum:
|
|
out += parent_sum
|
|
out += "\n"
|
|
operators_stats_summary = None
|
|
if len(self.operators_stats) == 1:
|
|
operators_stats_summary = self.operators_stats[0]
|
|
operator_name = operators_stats_summary.operator_name
|
|
operator_uuid = self.dataset_uuid + operator_name
|
|
out += "Operator {} {}: ".format(self.number, operator_name)
|
|
if operator_uuid in already_printed:
|
|
out += "[execution cached]\n"
|
|
else:
|
|
already_printed.add(operator_uuid)
|
|
out += str(operators_stats_summary)
|
|
elif len(self.operators_stats) > 1:
|
|
rounded_total = round(self.time_total_s, 2)
|
|
if rounded_total <= 0:
|
|
# Handle -0.0 case.
|
|
rounded_total = 0
|
|
out += "Operator {} {}: executed in {}s\n".format(
|
|
self.number, self.base_name, rounded_total
|
|
)
|
|
for n, operators_stats_summary in enumerate(self.operators_stats):
|
|
operator_name = operators_stats_summary.operator_name
|
|
operator_uuid = self.dataset_uuid + operator_name
|
|
out += "\n"
|
|
out += "\tSuboperator {} {}: ".format(n, operator_name)
|
|
if operator_uuid in already_printed:
|
|
out += "\t[execution cached]\n"
|
|
else:
|
|
already_printed.add(operator_uuid)
|
|
out += str(operators_stats_summary)
|
|
verbose_stats_logs = DataContext.get_current().verbose_stats_logs
|
|
if verbose_stats_logs and self.extra_metrics:
|
|
indent = (
|
|
"\t"
|
|
if operators_stats_summary and operators_stats_summary.is_sub_operator
|
|
else ""
|
|
)
|
|
out += indent
|
|
out += "* Extra metrics: " + str(self.extra_metrics) + "\n"
|
|
out += str(self.iter_stats)
|
|
|
|
if len(self.operators_stats) > 0 and add_global_stats:
|
|
mb_spilled = round(self.global_bytes_spilled / 1e6)
|
|
mb_restored = round(self.global_bytes_restored / 1e6)
|
|
if mb_spilled or mb_restored:
|
|
out += "\nCluster memory:\n"
|
|
out += "* Spilled to disk: {}MB\n".format(mb_spilled)
|
|
out += "* Restored from disk: {}MB\n".format(mb_restored)
|
|
|
|
dataset_mb_spilled = round(self.dataset_bytes_spilled / 1e6)
|
|
if dataset_mb_spilled:
|
|
out += "\nDataset memory:\n"
|
|
out += "* Spilled to disk: {}MB\n".format(dataset_mb_spilled)
|
|
|
|
if self.num_rows_per_s:
|
|
out += "\n"
|
|
out += "Dataset throughput:\n"
|
|
out += f"\t* Ray Data throughput: {self.num_rows_per_s} rows/s\n"
|
|
if verbose_stats_logs and add_global_stats:
|
|
out += "\n" + self.runtime_metrics()
|
|
|
|
return out
|
|
|
|
@property
|
|
def num_rows_per_s(self) -> float:
|
|
"""Calculates the throughput in rows per second for the entire dataset."""
|
|
# The observed dataset throughput is computed by dividing the total number
|
|
# of rows produced by the total wall time of the dataset (i.e. from start to
|
|
# finish how long did the dataset take to be processed). With the recursive
|
|
# nature of the DatasetStatsSummary, we use get_total_wall_time to determine
|
|
# the total wall time (this finds the difference between the earliest start
|
|
# and latest end for any block in any operator).
|
|
output_num_rows = (
|
|
self.operators_stats[-1].output_num_rows if self.operators_stats else None
|
|
)
|
|
total_num_out_rows = output_num_rows.sum if output_num_rows else 0
|
|
wall_time = self.get_total_wall_time()
|
|
if not total_num_out_rows or not wall_time:
|
|
return 0.0
|
|
return total_num_out_rows / wall_time
|
|
|
|
@staticmethod
|
|
def _collect_dataset_stats_summaries(
|
|
curr: "DatasetStatsSummary",
|
|
) -> List["DatasetStatsSummary"]:
|
|
summs = []
|
|
# TODO: Do operators ever have multiple parents? Do we need to deduplicate?
|
|
for p in curr.parents:
|
|
if p and p.parents:
|
|
summs.extend(DatasetStatsSummary._collect_dataset_stats_summaries(p))
|
|
return summs + [curr]
|
|
|
|
@staticmethod
|
|
def _find_start_and_end(summ: "DatasetStatsSummary") -> Tuple[float, float]:
|
|
start_times = [
|
|
ops.earliest_start_time
|
|
for ops in summ.operators_stats
|
|
if ops.earliest_start_time is not None
|
|
]
|
|
end_times = [
|
|
ops.latest_end_time
|
|
for ops in summ.operators_stats
|
|
if ops.latest_end_time is not None
|
|
]
|
|
earliest_start = min(start_times) if start_times else 0
|
|
latest_end = max(end_times) if end_times else 0
|
|
return earliest_start, latest_end
|
|
|
|
def runtime_metrics(self) -> str:
|
|
total_wall_time = self.get_total_wall_time()
|
|
|
|
def fmt_line(name: str, time: float) -> str:
|
|
fraction = time / total_wall_time if total_wall_time > 0 else 0
|
|
return f"* {name}: {fmt(time)} ({fraction * 100:.3f}%)\n"
|
|
|
|
summaries = DatasetStatsSummary._collect_dataset_stats_summaries(self)
|
|
out = "Runtime Metrics:\n"
|
|
for summ in summaries:
|
|
if len(summ.operators_stats) > 0:
|
|
earliest_start, latest_end = DatasetStatsSummary._find_start_and_end(
|
|
summ
|
|
)
|
|
op_total_time = latest_end - earliest_start
|
|
out += fmt_line(summ.base_name, op_total_time)
|
|
out += fmt_line("Scheduling", self.streaming_exec_schedule_s)
|
|
out += fmt_line("Total", total_wall_time)
|
|
return out
|
|
|
|
def __repr__(self, level=0) -> str:
|
|
indent = leveled_indent(level)
|
|
operators_stats = "\n".join(
|
|
[ss.__repr__(level + 2) for ss in self.operators_stats]
|
|
)
|
|
parent_stats = "\n".join([ps.__repr__(level + 2) for ps in self.parents])
|
|
extra_metrics = "\n".join(
|
|
f"{leveled_indent(level + 2)}{k}: {v},"
|
|
for k, v in self.extra_metrics.items()
|
|
)
|
|
|
|
# Handle formatting case for empty outputs.
|
|
operators_stats = (
|
|
f"\n{operators_stats},\n{indent} " if operators_stats else ""
|
|
)
|
|
parent_stats = f"\n{parent_stats},\n{indent} " if parent_stats else ""
|
|
extra_metrics = f"\n{extra_metrics}\n{indent} " if extra_metrics else ""
|
|
return (
|
|
f"{indent}DatasetStatsSummary(\n"
|
|
f"{indent} dataset_uuid={self.dataset_uuid},\n"
|
|
f"{indent} base_name={self.base_name},\n"
|
|
f"{indent} number={self.number},\n"
|
|
f"{indent} extra_metrics={{{extra_metrics}}},\n"
|
|
f"{indent} operators_stats=[{operators_stats}],\n"
|
|
f"{indent} iter_stats={self.iter_stats.__repr__(level + 1)},\n"
|
|
f"{indent} global_bytes_spilled={self.global_bytes_spilled / 1e6}MB,\n"
|
|
f"{indent} global_bytes_restored={self.global_bytes_restored / 1e6}MB,\n"
|
|
f"{indent} dataset_bytes_spilled={self.dataset_bytes_spilled / 1e6}MB,\n"
|
|
f"{indent} parents=[{parent_stats}],\n"
|
|
f"{indent})"
|
|
)
|
|
|
|
def get_total_wall_time(self) -> float:
|
|
"""Calculate the total wall time for the dataset, this is done by finding
|
|
the earliest start time and latest end time for any block in any operator.
|
|
The wall time is the difference of these two times.
|
|
"""
|
|
start_ends = [
|
|
DatasetStatsSummary._find_start_and_end(summ)
|
|
for summ in DatasetStatsSummary._collect_dataset_stats_summaries(self)
|
|
if len(summ.operators_stats) > 0
|
|
]
|
|
if len(start_ends) == 0:
|
|
return 0
|
|
else:
|
|
earliest_start = min(start_end[0] for start_end in start_ends)
|
|
latest_end = max(start_end[1] for start_end in start_ends)
|
|
return latest_end - earliest_start
|
|
|
|
def get_total_time_all_blocks(self) -> float:
|
|
"""Calculate the sum of the wall times across all blocks of all operators."""
|
|
summaries = DatasetStatsSummary._collect_dataset_stats_summaries(self)
|
|
return sum(
|
|
(
|
|
sum(
|
|
ops.wall_time.sum if ops.wall_time else 0
|
|
for ops in summ.operators_stats
|
|
)
|
|
)
|
|
for summ in summaries
|
|
)
|
|
|
|
def get_total_cpu_time(self) -> float:
|
|
parent_sum = sum(p.get_total_cpu_time() for p in self.parents)
|
|
return parent_sum + sum(
|
|
ss.cpu_time.sum if ss.cpu_time else 0 for ss in self.operators_stats
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class OperatorStatsSummary:
|
|
operator_name: str
|
|
# Whether the operator associated with this OperatorStatsSummary object
|
|
# is a suboperator
|
|
is_sub_operator: bool
|
|
# This is the total walltime of the entire operator, typically obtained from
|
|
# `DatasetStats.time_total_s`. An important distinction is that this is the
|
|
# overall runtime of the operator, pulled from the stats actor, whereas the
|
|
# computed walltimes in `self.wall_time` are calculated on a operator level.
|
|
time_total_s: float
|
|
earliest_start_time: Optional[float]
|
|
latest_end_time: Optional[float]
|
|
# String summarizing high-level statistics from executing the operator
|
|
block_execution_summary_str: str
|
|
wall_time: Optional[StatsSummary] = None
|
|
cpu_time: Optional[StatsSummary] = None
|
|
udf_time: Optional[StatsSummary] = None
|
|
total_input_num_rows: Optional[int] = None
|
|
output_num_rows: Optional[StatsSummary] = None
|
|
output_size_bytes: Optional[StatsSummary] = None
|
|
node_count: Optional[StatsSummary] = None
|
|
task_rows: Optional[StatsSummary] = None
|
|
scheduling_overhead: Optional[List["BucketedSchedulingOverhead"]] = None
|
|
|
|
@property
|
|
def num_rows_per_s(self) -> float:
|
|
# The observed Ray Data operator throughput is computed by dividing the
|
|
# total number of rows produced by the wall time of the operator,
|
|
# time_total_s.
|
|
if not self.output_num_rows or not self.time_total_s:
|
|
return 0.0
|
|
return self.output_num_rows.sum / self.time_total_s
|
|
|
|
@property
|
|
def num_rows_per_task_s(self) -> float:
|
|
"""Calculates the estimated single-task throughput in rows per second."""
|
|
# The estimated single task operator throughput is computed by dividing the
|
|
# total number of rows produced by the sum of the wall times across all
|
|
# blocks of the operator. This assumes that on a single task the work done
|
|
# would be equivalent, with no concurrency.
|
|
if not self.output_num_rows or not self.wall_time or not self.wall_time.sum:
|
|
return 0.0
|
|
return self.output_num_rows.sum / self.wall_time.sum
|
|
|
|
@classmethod
|
|
def from_block_metadata(
|
|
cls,
|
|
operator_name: str,
|
|
block_stats: List[BlockStats],
|
|
is_sub_operator: bool,
|
|
) -> "OperatorStatsSummary":
|
|
"""Calculate the stats for a operator from a given list of blocks,
|
|
and generates a `OperatorStatsSummary` object with the results.
|
|
|
|
Args:
|
|
operator_name: Name of operator associated with `blocks`
|
|
block_stats: List of `BlockStats` to calculate stats of
|
|
is_sub_operator: Whether this set of blocks belongs to a sub operator.
|
|
Returns:
|
|
A `OperatorStatsSummary` object initialized with the calculated statistics
|
|
"""
|
|
# Single pass over block_stats to collect all metrics.
|
|
wall_time_acc: _StatsAccumulator = _StatsAccumulator()
|
|
cpu_time_acc: _StatsAccumulator = _StatsAccumulator()
|
|
udf_time_acc: _StatsAccumulator = _StatsAccumulator()
|
|
output_rows_acc: _StatsAccumulator = _StatsAccumulator()
|
|
output_sizes_acc: _StatsAccumulator = _StatsAccumulator()
|
|
rows_per_task: DefaultDict[int, int] = collections.defaultdict(int)
|
|
tasks_per_node: DefaultDict[str, Set[int]] = collections.defaultdict(set)
|
|
num_exec = 0
|
|
earliest_start_time, latest_end_time = float("inf"), float("-inf")
|
|
|
|
for block_meta in block_stats:
|
|
if block_meta.num_rows is not None:
|
|
output_rows_acc.add(block_meta.num_rows)
|
|
if block_meta.size_bytes is not None:
|
|
output_sizes_acc.add(block_meta.size_bytes)
|
|
|
|
es = block_meta.exec_stats
|
|
if es is not None:
|
|
num_exec += 1
|
|
if es.wall_time_s is not None:
|
|
wall_time_acc.add(es.wall_time_s)
|
|
if es.cpu_time_s is not None:
|
|
cpu_time_acc.add(es.cpu_time_s)
|
|
if es.udf_time_s is not None:
|
|
udf_time_acc.add(es.udf_time_s)
|
|
tasks_per_node[es.node_id].add(es.task_idx)
|
|
if es.start_time_s is not None:
|
|
earliest_start_time = min(earliest_start_time, es.start_time_s)
|
|
if es.end_time_s is not None:
|
|
latest_end_time = max(latest_end_time, es.end_time_s)
|
|
if block_meta.num_rows is not None:
|
|
rows_per_task[es.task_idx] += block_meta.num_rows
|
|
|
|
# Compute timing totals.
|
|
if num_exec and earliest_start_time != float("inf"):
|
|
time_total_s = latest_end_time - earliest_start_time
|
|
# Handle -0.0 case.
|
|
rounded_total = round(time_total_s, 2)
|
|
if rounded_total <= 0:
|
|
rounded_total = 0
|
|
else:
|
|
time_total_s = 0
|
|
rounded_total = 0
|
|
earliest_start_time, latest_end_time = None, None
|
|
|
|
# Build execution summary string.
|
|
if is_sub_operator:
|
|
exec_summary_str = f"{num_exec} blocks produced\n"
|
|
elif num_exec:
|
|
exec_summary_str = f"{num_exec} blocks produced in {rounded_total}s\n"
|
|
else:
|
|
exec_summary_str = "\n"
|
|
|
|
# Task-level row stats.
|
|
task_rows_stats = None
|
|
if rows_per_task:
|
|
task_rows_acc = _StatsAccumulator()
|
|
for count in rows_per_task.values():
|
|
task_rows_acc.add(count)
|
|
task_rows_stats = task_rows_acc.get()
|
|
exec_summary_str = (
|
|
f"{task_rows_acc.count} tasks executed, {exec_summary_str}"
|
|
)
|
|
|
|
# Execution stats.
|
|
wall_time_stats = wall_time_acc.get()
|
|
cpu_stats = cpu_time_acc.get()
|
|
udf_stats = udf_time_acc.get()
|
|
|
|
# Output stats.
|
|
output_num_rows_stats = output_rows_acc.get()
|
|
output_size_bytes_stats = output_sizes_acc.get()
|
|
|
|
# Node distribution stats.
|
|
node_counts_stats = None
|
|
if tasks_per_node:
|
|
node_counts_acc = _StatsAccumulator()
|
|
for tasks in tasks_per_node.values():
|
|
node_counts_acc.add(len(tasks))
|
|
node_counts_stats = node_counts_acc.get()
|
|
|
|
# Assign a value in to_summary and initialize it as None.
|
|
total_input_num_rows = None
|
|
|
|
return OperatorStatsSummary(
|
|
operator_name=operator_name,
|
|
is_sub_operator=is_sub_operator,
|
|
time_total_s=time_total_s,
|
|
earliest_start_time=earliest_start_time,
|
|
latest_end_time=latest_end_time,
|
|
block_execution_summary_str=exec_summary_str,
|
|
wall_time=wall_time_stats,
|
|
cpu_time=cpu_stats,
|
|
udf_time=udf_stats,
|
|
total_input_num_rows=total_input_num_rows,
|
|
output_num_rows=output_num_rows_stats,
|
|
output_size_bytes=output_size_bytes_stats,
|
|
node_count=node_counts_stats,
|
|
task_rows=task_rows_stats,
|
|
)
|
|
|
|
def __str__(self) -> str:
|
|
"""For a given (pre-calculated) `OperatorStatsSummary` object (e.g. generated from
|
|
`OperatorStatsSummary.from_block_metadata()`), returns a human-friendly string
|
|
that summarizes operator execution statistics.
|
|
|
|
Returns:
|
|
String with summary statistics for executing the given operator.
|
|
"""
|
|
indent = "\t" if self.is_sub_operator else ""
|
|
out = self.block_execution_summary_str
|
|
|
|
if self.wall_time:
|
|
out += indent
|
|
out += "* Remote wall time: {} min, {} max, {} mean, {} total\n".format(
|
|
fmt(self.wall_time.min),
|
|
fmt(self.wall_time.max),
|
|
fmt(self.wall_time.mean),
|
|
fmt(self.wall_time.sum),
|
|
)
|
|
|
|
if self.cpu_time:
|
|
out += indent
|
|
out += "* Remote cpu time: {} min, {} max, {} mean, {} total\n".format(
|
|
fmt(self.cpu_time.min),
|
|
fmt(self.cpu_time.max),
|
|
fmt(self.cpu_time.mean),
|
|
fmt(self.cpu_time.sum),
|
|
)
|
|
|
|
if self.udf_time:
|
|
out += indent
|
|
out += "* UDF time: {} min, {} max, {} mean, {} total\n".format(
|
|
fmt(self.udf_time.min),
|
|
fmt(self.udf_time.max),
|
|
fmt(self.udf_time.mean),
|
|
fmt(self.udf_time.sum),
|
|
)
|
|
|
|
if self.output_num_rows:
|
|
out += indent
|
|
out += (
|
|
"* Output num rows per block: {} min, {} max, {} mean, {} total\n"
|
|
).format(
|
|
self.output_num_rows.min,
|
|
self.output_num_rows.max,
|
|
int(self.output_num_rows.mean),
|
|
self.output_num_rows.sum,
|
|
)
|
|
|
|
if self.output_size_bytes:
|
|
out += indent
|
|
out += (
|
|
"* Output size bytes per block: {} min, {} max, {} mean, {} total\n"
|
|
).format(
|
|
self.output_size_bytes.min,
|
|
self.output_size_bytes.max,
|
|
int(self.output_size_bytes.mean),
|
|
self.output_size_bytes.sum,
|
|
)
|
|
|
|
if self.task_rows:
|
|
out += indent
|
|
out += (
|
|
"* Output rows per task: {} min, {} max, {} mean, {} tasks used\n"
|
|
).format(
|
|
self.task_rows.min,
|
|
self.task_rows.max,
|
|
int(self.task_rows.mean),
|
|
self.task_rows.count,
|
|
)
|
|
|
|
if self.node_count:
|
|
out += indent
|
|
out += "* Tasks per node: {} min, {} max, {} mean; {} nodes used\n".format(
|
|
self.node_count.min,
|
|
self.node_count.max,
|
|
int(self.node_count.mean),
|
|
self.node_count.count,
|
|
)
|
|
if self.num_rows_per_s and self.num_rows_per_task_s:
|
|
total_num_in_rows = (
|
|
self.total_input_num_rows if self.total_input_num_rows else 0
|
|
)
|
|
total_num_out_rows = self.output_num_rows.sum
|
|
out += indent
|
|
out += "* Operator throughput:\n"
|
|
out += indent + f"\t* Total input num rows: {total_num_in_rows} rows\n"
|
|
out += indent + f"\t* Total output num rows: {total_num_out_rows} rows\n"
|
|
out += indent + f"\t* Ray Data throughput: {self.num_rows_per_s} rows/s\n"
|
|
out += (
|
|
indent + "\t* Estimated single task throughput:"
|
|
f" {self.num_rows_per_task_s} "
|
|
"rows/s\n"
|
|
)
|
|
return out
|
|
|
|
def __repr__(self, level: int = 0) -> str:
|
|
"""For a given (pre-calculated) `OperatorStatsSummary` object (e.g. generated from
|
|
`OperatorStatsSummary.from_block_metadata()`), returns a human-friendly string
|
|
that summarizes operator execution statistics.
|
|
|
|
Args:
|
|
level: The indentation level to use when formatting nested summaries.
|
|
|
|
Returns:
|
|
String with summary statistics for executing the given operator.
|
|
"""
|
|
indent = leveled_indent(level)
|
|
indent += leveled_indent(1) if self.is_sub_operator else ""
|
|
|
|
def _fmt_dict(
|
|
s: Optional[StatsSummary],
|
|
include_sum: bool = True,
|
|
include_count: bool = False,
|
|
) -> Optional[dict]:
|
|
if s is None:
|
|
return None
|
|
return {
|
|
k: fmt(v)
|
|
for k, v in s.to_dict(
|
|
include_sum=include_sum, include_count=include_count
|
|
).items()
|
|
}
|
|
|
|
out = (
|
|
f"{indent}OperatorStatsSummary(\n"
|
|
f"{indent} operator_name='{self.operator_name}',\n"
|
|
f"{indent} is_suboperator={self.is_sub_operator},\n"
|
|
f"{indent} time_total_s={fmt(self.time_total_s)},\n"
|
|
# block_execution_summary_str already ends with \n
|
|
f"{indent} block_execution_summary_str={self.block_execution_summary_str}"
|
|
f"{indent} wall_time={_fmt_dict(self.wall_time)},\n"
|
|
f"{indent} cpu_time={_fmt_dict(self.cpu_time)},\n"
|
|
f"{indent} output_num_rows={_fmt_dict(self.output_num_rows)},\n"
|
|
f"{indent} output_size_bytes={_fmt_dict(self.output_size_bytes)},\n"
|
|
f"{indent} node_count={_fmt_dict(self.node_count, include_sum=False, include_count=True)},\n"
|
|
f"{indent})"
|
|
)
|
|
return out
|
|
|
|
|
|
@dataclass
|
|
class IterStatsSummary:
|
|
# Time spent in actor based prefetching, in seconds.
|
|
wait_time: Timer
|
|
# Time spent getting RefBundles from the dataset iterator, in seconds
|
|
get_ref_bundles_time: Timer
|
|
# Time spent in `ray.get()`, in seconds
|
|
get_time: Timer
|
|
# Time spent in batch building, in seconds
|
|
next_time: Timer
|
|
# Time spent in `_format_batch_()`, in seconds
|
|
format_time: Timer
|
|
# Time spent in collate fn, in seconds
|
|
collate_time: Timer
|
|
# Time spent in finalize_fn, in seconds
|
|
finalize_batch_time: Timer
|
|
# Time user thread is blocked waiting for first batch
|
|
time_to_first_batch: Timer
|
|
# Total time user thread is blocked by iter_batches
|
|
block_time: Timer
|
|
# Time spent in user code, in seconds
|
|
user_time: Timer
|
|
initialize_time: Timer
|
|
# Total time taken by Dataset iterator, in seconds
|
|
total_time: Timer
|
|
# Time spent in streaming split coordinator
|
|
streaming_split_coord_time: Timer
|
|
# Num of blocks that are in local object store
|
|
iter_blocks_local: int
|
|
# Num of blocks that are in remote node and have to fetch locally
|
|
iter_blocks_remote: int
|
|
# Num of blocks with unknown locations
|
|
iter_unknown_location: int
|
|
# Current bytes of prefetched blocks in the iterator
|
|
iter_prefetched_bytes: int
|
|
# Per-stage training-thread blocked attribution timers.
|
|
blocked_production_wait_time: Timer
|
|
blocked_data_transfer_time: Timer
|
|
blocked_batching_time: Timer
|
|
blocked_format_time: Timer
|
|
blocked_collate_time: Timer
|
|
blocked_finalize_time: Timer
|
|
# Cumulative batch and row counters.
|
|
batches_total: int
|
|
rows_total: int
|
|
|
|
def __str__(self) -> str:
|
|
return self.to_string()
|
|
|
|
def to_string(self) -> str:
|
|
out = ""
|
|
if (
|
|
self.block_time.get()
|
|
or self.time_to_first_batch.get()
|
|
or self.total_time.get()
|
|
or self.get_ref_bundles_time.get()
|
|
or self.get_time.get()
|
|
or self.next_time.get()
|
|
or self.format_time.get()
|
|
or self.collate_time.get()
|
|
or self.finalize_batch_time.get()
|
|
):
|
|
out += "\nDataset iterator time breakdown:\n"
|
|
if self.total_time.get():
|
|
out += "* Total time overall: {}\n".format(fmt(self.total_time.get()))
|
|
if self.initialize_time.get():
|
|
out += (
|
|
" * Total time in Ray Data iterator initialization code: "
|
|
"{}\n".format(fmt(self.initialize_time.get()))
|
|
)
|
|
if self.block_time.get():
|
|
out += (
|
|
" * Total time user thread is blocked by Ray Data iter_batches: "
|
|
"{}\n".format(fmt(self.block_time.get()))
|
|
)
|
|
if self.time_to_first_batch.get():
|
|
out += (
|
|
" * Total time spent waiting for the first batch after starting iteration: "
|
|
"{}\n".format(fmt(self.time_to_first_batch.get()))
|
|
)
|
|
if self.user_time.get():
|
|
out += " * Total execution time for user thread: {}\n".format(
|
|
fmt(self.user_time.get())
|
|
)
|
|
out += (
|
|
"* Batch iteration time breakdown (summed across prefetch threads):\n"
|
|
)
|
|
if self.get_ref_bundles_time.get():
|
|
out += " * In get RefBundles: {} min, {} max, {} avg, {} total\n".format(
|
|
fmt(self.get_ref_bundles_time.min()),
|
|
fmt(self.get_ref_bundles_time.max()),
|
|
fmt(self.get_ref_bundles_time.avg()),
|
|
fmt(self.get_ref_bundles_time.get()),
|
|
)
|
|
if self.get_time.get():
|
|
out += " * In ray.get(): {} min, {} max, {} avg, {} total\n".format(
|
|
fmt(self.get_time.min()),
|
|
fmt(self.get_time.max()),
|
|
fmt(self.get_time.avg()),
|
|
fmt(self.get_time.get()),
|
|
)
|
|
if self.next_time.get():
|
|
batch_creation_str = (
|
|
" * In batch creation: {} min, {} max, {} avg, {} total\n"
|
|
)
|
|
out += batch_creation_str.format(
|
|
fmt(self.next_time.min()),
|
|
fmt(self.next_time.max()),
|
|
fmt(self.next_time.avg()),
|
|
fmt(self.next_time.get()),
|
|
)
|
|
if self.format_time.get():
|
|
format_str = (
|
|
" * In batch formatting: {} min, {} max, {} avg, {} total\n"
|
|
)
|
|
out += format_str.format(
|
|
fmt(self.format_time.min()),
|
|
fmt(self.format_time.max()),
|
|
fmt(self.format_time.avg()),
|
|
fmt(self.format_time.get()),
|
|
)
|
|
if self.collate_time.get():
|
|
out += " * In collate_fn: {} min, {} max, {} avg, {} total\n".format(
|
|
fmt(self.collate_time.min()),
|
|
fmt(self.collate_time.max()),
|
|
fmt(self.collate_time.avg()),
|
|
fmt(self.collate_time.get()),
|
|
)
|
|
if self.finalize_batch_time.get():
|
|
format_str = (
|
|
" * In host->device transfer: {} min, {} max, {} avg, {} total\n"
|
|
)
|
|
out += format_str.format(
|
|
fmt(self.finalize_batch_time.min()),
|
|
fmt(self.finalize_batch_time.max()),
|
|
fmt(self.finalize_batch_time.avg()),
|
|
fmt(self.finalize_batch_time.get()),
|
|
)
|
|
if DataContext.get_current().enable_get_object_locations_for_metrics:
|
|
out += "Block locations:\n"
|
|
out += " * Num blocks local: {}\n".format(self.iter_blocks_local)
|
|
out += " * Num blocks remote: {}\n".format(self.iter_blocks_remote)
|
|
out += " * Num blocks unknown location: {}\n".format(
|
|
self.iter_unknown_location
|
|
)
|
|
if self.iter_prefetched_bytes:
|
|
out += " * Prefetched bytes: {}\n".format(self.iter_prefetched_bytes)
|
|
if self.streaming_split_coord_time.get() != 0:
|
|
out += "Streaming split coordinator overhead time: "
|
|
out += f"{fmt(self.streaming_split_coord_time.get())}\n"
|
|
|
|
# Per-stage training-thread blocked attribution.
|
|
stage_totals = [
|
|
("production wait", self.blocked_production_wait_time),
|
|
("data transfer (ray.get)", self.blocked_data_transfer_time),
|
|
("batching", self.blocked_batching_time),
|
|
("format", self.blocked_format_time),
|
|
("collate", self.blocked_collate_time),
|
|
("finalize (host->device)", self.blocked_finalize_time),
|
|
]
|
|
active_stages = [(name, t) for name, t in stage_totals if t.get() > 0]
|
|
if active_stages:
|
|
out += "\nPer-stage training-thread blocked time breakdown:\n"
|
|
for stage_name, timer in active_stages:
|
|
out += " * {}: {}\n".format(stage_name, fmt(timer.get()))
|
|
if self.batches_total:
|
|
out += "Total batches consumed: {}\n".format(self.batches_total)
|
|
if self.rows_total:
|
|
out += "Total rows consumed: {}\n".format(self.rows_total)
|
|
|
|
return out
|
|
|
|
def __repr__(self, level=0) -> str:
|
|
indent = leveled_indent(level)
|
|
return (
|
|
f"IterStatsSummary(\n"
|
|
f"{indent} wait_time={fmt(self.wait_time.get()) or None},\n"
|
|
f"{indent} get_ref_bundles_time={fmt(self.get_ref_bundles_time.get()) or None},\n"
|
|
f"{indent} get_time={fmt(self.get_time.get()) or None},\n"
|
|
f"{indent} iter_blocks_local={self.iter_blocks_local or None},\n"
|
|
f"{indent} iter_blocks_remote={self.iter_blocks_remote or None},\n"
|
|
f"{indent} iter_unknown_location={self.iter_unknown_location or None},\n"
|
|
f"{indent} iter_prefetched_bytes={self.iter_prefetched_bytes or None},\n"
|
|
f"{indent} next_time={fmt(self.next_time.get()) or None},\n"
|
|
f"{indent} format_time={fmt(self.format_time.get()) or None},\n"
|
|
f"{indent} user_time={fmt(self.user_time.get()) or None},\n"
|
|
f"{indent} total_time={fmt(self.total_time.get()) or None},\n"
|
|
f"{indent})"
|
|
)
|