2901 lines
111 KiB
Python
2901 lines
111 KiB
Python
import gc
|
||
import logging
|
||
import pickle
|
||
import platform
|
||
import re
|
||
import threading
|
||
import time
|
||
from collections import Counter, defaultdict
|
||
from contextlib import contextmanager
|
||
from dataclasses import dataclass, fields
|
||
from typing import Dict, List, Optional
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import numpy as np
|
||
import pyarrow as pa
|
||
import pytest
|
||
|
||
import ray
|
||
from ray._common.test_utils import (
|
||
run_string_as_driver,
|
||
wait_for_condition,
|
||
)
|
||
from ray.data._internal.block_batching.iter_batches import BatchIterator
|
||
from ray.data._internal.execution.backpressure_policy import (
|
||
ENABLED_BACKPRESSURE_POLICIES_CONFIG_KEY,
|
||
)
|
||
from ray.data._internal.execution.backpressure_policy.backpressure_policy import (
|
||
BackpressurePolicy,
|
||
)
|
||
from ray.data._internal.execution.dataset_state import DatasetState
|
||
from ray.data._internal.execution.interfaces.common import RuntimeMetricsHistogram
|
||
from ray.data._internal.execution.interfaces.physical_operator import PhysicalOperator
|
||
from ray.data._internal.execution.interfaces.task_context import TaskContext
|
||
from ray.data._internal.execution.operators.map_operator import _map_task
|
||
from ray.data._internal.execution.operators.map_transformer import (
|
||
BlockMapTransformFn,
|
||
CustomOpStatsReporter,
|
||
MapTransformer,
|
||
)
|
||
from ray.data._internal.execution.streaming_executor import StreamingExecutor
|
||
from ray.data._internal.stats import (
|
||
DatasetStats,
|
||
DatasetStatsSummary,
|
||
IterationStage,
|
||
NodeMetrics,
|
||
OperatorStatsSummary,
|
||
StatsSummary,
|
||
Timer,
|
||
TimeSpan,
|
||
_maybe_time,
|
||
_StatsActor,
|
||
get_or_create_stats_actor,
|
||
)
|
||
from ray.data._internal.util import MemoryProfiler
|
||
from ray.data.block import BlockExecStats, BlockStats, CustomOpStats
|
||
from ray.data.context import DataContext
|
||
from ray.data.tests.util import column_udf
|
||
from ray.tests.conftest import * # noqa
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _ReadTaskStats(CustomOpStats):
|
||
num_rows: int
|
||
num_columns: int
|
||
|
||
|
||
def get_operator(
|
||
stats_summary: DatasetStatsSummary,
|
||
*,
|
||
index: Optional[int] = None,
|
||
name_pattern: Optional[str] = None,
|
||
) -> OperatorStatsSummary:
|
||
"""Find and return an operator from a DatasetStatsSummary.
|
||
|
||
Args:
|
||
stats_summary: DatasetStatsSummary object.
|
||
index: 0-based index to select operator by position.
|
||
name_pattern: Regex pattern to match operator name.
|
||
|
||
Returns:
|
||
OperatorStatsSummary for the found operator.
|
||
|
||
Raises:
|
||
AssertionError: if operator not found or if neither index nor name_pattern is specified.
|
||
|
||
Examples:
|
||
stats_summary = ds.get_stats_summary()
|
||
op = get_operator(stats_summary, index=0)
|
||
op = get_operator(stats_summary, name_pattern="ReadRange->Map")
|
||
"""
|
||
if index is not None and name_pattern is not None:
|
||
raise AssertionError("Specify either index or name_pattern, not both")
|
||
|
||
if index is not None:
|
||
if not (0 <= index < len(stats_summary.operators_stats)):
|
||
available = len(stats_summary.operators_stats)
|
||
raise AssertionError(
|
||
f"Operator index {index} out of range. "
|
||
f"Found {available} operators (indices 0-{available - 1})."
|
||
)
|
||
return stats_summary.operators_stats[index]
|
||
|
||
if name_pattern is not None:
|
||
for op in stats_summary.operators_stats:
|
||
if re.search(name_pattern, op.operator_name):
|
||
return op
|
||
available_names = [op.operator_name for op in stats_summary.operators_stats]
|
||
raise AssertionError(
|
||
f"No operator found matching pattern '{name_pattern}'. "
|
||
f"Available operators: {available_names}"
|
||
)
|
||
|
||
# Require explicit selection
|
||
raise AssertionError(
|
||
"Must specify either index or name_pattern to select an operator"
|
||
)
|
||
|
||
|
||
def assert_operator_count(
|
||
stats_summary: DatasetStatsSummary,
|
||
expected_count: int,
|
||
) -> None:
|
||
"""Assert that stats_summary has exactly expected_count operators.
|
||
|
||
Args:
|
||
stats_summary: DatasetStatsSummary object.
|
||
expected_count: Expected number of operators.
|
||
|
||
Raises:
|
||
AssertionError: if count doesn't match.
|
||
|
||
Examples:
|
||
stats_summary = ds.get_stats_summary()
|
||
assert_operator_count(stats_summary, expected_count=2)
|
||
"""
|
||
actual = len(stats_summary.operators_stats)
|
||
assert (
|
||
actual == expected_count
|
||
), f"Expected {expected_count} operators, found {actual}"
|
||
|
||
|
||
def assert_basic_operator_metrics(
|
||
op: OperatorStatsSummary,
|
||
) -> None:
|
||
"""Assert that basic operator metrics are present and valid."""
|
||
assert op.wall_time is not None, "wall_time should not be None"
|
||
assert op.wall_time.sum > 0, "wall_time sum should be positive"
|
||
|
||
assert op.output_num_rows is not None, "output_num_rows should not be None"
|
||
|
||
assert op.output_size_bytes is not None, "output_size_bytes should not be None"
|
||
|
||
assert op.block_execution_summary_str is not None
|
||
assert len(op.block_execution_summary_str) > 0
|
||
|
||
|
||
def find_stats_summary_in_parents(
|
||
stats_summary: DatasetStatsSummary,
|
||
name_pattern: str,
|
||
) -> DatasetStatsSummary:
|
||
"""Find and return a DatasetStatsSummary node from the parent chain."""
|
||
current = stats_summary
|
||
while current:
|
||
if current.base_name and re.search(name_pattern, current.base_name):
|
||
return current
|
||
current = current.parents[0] if current.parents else None
|
||
|
||
raise AssertionError(
|
||
f"No stats summary found matching pattern '{name_pattern}' in parents chain"
|
||
)
|
||
|
||
|
||
@pytest.mark.skipif(
|
||
platform.system() != "Linux", reason="MemoryProfiler only supported on Linux"
|
||
)
|
||
def test_block_exec_stats_max_uss_bytes_with_polling(ray_start_regular_shared):
|
||
array_nbytes = 1024**3 # 1 GiB
|
||
poll_interval_s = 0.01
|
||
with MemoryProfiler(poll_interval_s=poll_interval_s) as profiler:
|
||
array = np.random.randint(0, 256, size=(array_nbytes,), dtype=np.uint8)
|
||
time.sleep(poll_interval_s * 2)
|
||
|
||
del array
|
||
gc.collect()
|
||
|
||
assert profiler.estimate_max_uss() > array_nbytes
|
||
|
||
|
||
@pytest.mark.skipif(
|
||
platform.system() != "Linux", reason="MemoryProfiler only supported on Linux"
|
||
)
|
||
def test_block_exec_stats_max_uss_bytes_without_polling(ray_start_regular_shared):
|
||
array_nbytes = 1024**3 # 1 GiB
|
||
with MemoryProfiler(poll_interval_s=None) as profiler:
|
||
_ = np.random.randint(0, 256, size=(array_nbytes,), dtype=np.uint8)
|
||
|
||
assert profiler.estimate_max_uss() > array_nbytes
|
||
|
||
|
||
def test_map_transformer_custom_op_stats():
|
||
expected = _ReadTaskStats(num_rows=4, num_columns=1)
|
||
|
||
def set_stats(blocks, ctx, report_custom_op_stats):
|
||
report_custom_op_stats(expected)
|
||
yield from blocks
|
||
|
||
transformer = MapTransformer(
|
||
[
|
||
BlockMapTransformFn(
|
||
set_stats, disable_block_shaping=True, reports_custom_op_stats=True
|
||
)
|
||
]
|
||
)
|
||
|
||
reporter = CustomOpStatsReporter()
|
||
# Nothing reported until a task runs.
|
||
assert reporter.get_stats() == []
|
||
|
||
ctx = TaskContext(task_idx=0, op_name="test")
|
||
block = pa.table({"id": list(range(expected.num_rows))})
|
||
# apply_transform takes the report callback, not the reporter object.
|
||
list(transformer.apply_transform([block], ctx, reporter.report))
|
||
assert reporter.get_stats() == [expected]
|
||
|
||
|
||
def _drive_map_task_metadata(transformer, ctx, block):
|
||
"""Run ``_map_task`` to completion and return the per-block metadata.
|
||
|
||
``_map_task`` yields each block, then (after a ``send``) the pickled
|
||
``BlockMetadataWithSchema`` for that block.
|
||
"""
|
||
gen = _map_task(transformer, DataContext.get_current(), ctx, block)
|
||
metas = []
|
||
try:
|
||
next(gen) # first block
|
||
while True:
|
||
metas.append(pickle.loads(gen.send(None))) # that block's metadata
|
||
next(gen) # next block; StopIteration when exhausted
|
||
except StopIteration:
|
||
pass
|
||
return metas
|
||
|
||
|
||
def test_map_task_carries_custom_op_stats_to_block_metadata(ray_start_regular_shared):
|
||
"""End-to-end wiring: a reporting transform's stats reach the per-block
|
||
TaskExecWorkerStats that ``_map_task`` emits back to the driver.
|
||
|
||
Guards the ``_map_task`` -> ``TaskExecWorkerStats.custom_op_stats`` plumbing
|
||
so a future edit there can't silently drop the field.
|
||
"""
|
||
expected = _ReadTaskStats(num_rows=2, num_columns=1)
|
||
|
||
def set_stats(blocks, ctx, report_custom_op_stats):
|
||
report_custom_op_stats(expected)
|
||
yield from blocks
|
||
|
||
transformer = MapTransformer(
|
||
[
|
||
BlockMapTransformFn(
|
||
set_stats, disable_block_shaping=True, reports_custom_op_stats=True
|
||
)
|
||
]
|
||
)
|
||
ctx = TaskContext(task_idx=0, op_name="test")
|
||
metas = _drive_map_task_metadata(transformer, ctx, pa.table({"id": [0, 1]}))
|
||
|
||
# custom_op_stats is a List[CustomOpStats] per block; flatten across blocks.
|
||
reported_stats = [
|
||
stats
|
||
for m in metas
|
||
if m.metadata.task_exec_stats is not None
|
||
for stats in m.metadata.task_exec_stats.custom_op_stats
|
||
]
|
||
assert expected in reported_stats, reported_stats
|
||
|
||
|
||
def test_custom_op_stats_survives_operator_fusion(ray_start_regular_shared):
|
||
"""A reporting transform's stats survive operator fusion.
|
||
|
||
Because ``_map_task`` owns the reporter (rather than the transformer), a
|
||
reporting upstream transform fused with a downstream transform still carries
|
||
its stats back: both run under the fused operator's single reporter. This is
|
||
a regression guard — when stats lived on the transformer, fusion built a new
|
||
transformer and the closure-captured original was orphaned, silently
|
||
dropping the stats.
|
||
"""
|
||
expected = _ReadTaskStats(num_rows=2, num_columns=1)
|
||
|
||
def report_stats(blocks, ctx, report_custom_op_stats):
|
||
report_custom_op_stats(expected)
|
||
yield from blocks
|
||
|
||
def passthrough(blocks, ctx):
|
||
yield from blocks
|
||
|
||
upstream = MapTransformer(
|
||
[
|
||
BlockMapTransformFn(
|
||
report_stats, disable_block_shaping=True, reports_custom_op_stats=True
|
||
)
|
||
]
|
||
)
|
||
downstream = MapTransformer(
|
||
[BlockMapTransformFn(passthrough, disable_block_shaping=True)]
|
||
)
|
||
fused = upstream.fuse(downstream)
|
||
|
||
ctx = TaskContext(task_idx=0, op_name="test")
|
||
metas = _drive_map_task_metadata(fused, ctx, pa.table({"id": [0, 1]}))
|
||
|
||
# custom_op_stats is a List[CustomOpStats] per block; flatten across blocks.
|
||
reported_stats = [
|
||
stats
|
||
for m in metas
|
||
if m.metadata.task_exec_stats is not None
|
||
for stats in m.metadata.task_exec_stats.custom_op_stats
|
||
]
|
||
assert expected in reported_stats, reported_stats
|
||
|
||
|
||
def gen_expected_metrics(
|
||
is_map: bool,
|
||
spilled: bool = False,
|
||
task_backpressure: bool = False,
|
||
task_output_backpressure: bool = False,
|
||
extra_metrics: Optional[List[str]] = None,
|
||
task_locality_hit: bool = False,
|
||
):
|
||
if is_map:
|
||
metrics = [
|
||
"'average_num_outputs_per_task': N",
|
||
"'average_num_inputs_per_task': N",
|
||
"'num_output_blocks_per_task_s': N",
|
||
"'average_total_task_completion_time_s': N",
|
||
"'average_task_scheduling_time_s': N",
|
||
"'average_task_output_backpressure_time_s': Z",
|
||
"'average_task_completion_time_excl_backpressure_s': N",
|
||
"'average_task_block_gen_and_ser_time_s': N",
|
||
"'average_bytes_per_output': N",
|
||
"'obj_store_mem_internal_inqueue': Z",
|
||
"'obj_store_mem_internal_outqueue': Z",
|
||
"'obj_store_mem_pending_task_inputs': Z",
|
||
"'obj_store_mem_pending_task_outputs': Z",
|
||
"'average_bytes_inputs_per_task': N",
|
||
"'average_rows_inputs_per_task': N",
|
||
"'average_bytes_outputs_per_task': N",
|
||
"'average_rows_outputs_per_task': N",
|
||
"'op_task_duration_stats': {'num_samples': N, 'mean': N, 'variance': N, 'min': N, 'max': N, 'pN': P, 'pN': P, 'pN': P, 'pN': P, 'pN': P, 'pN': P}",
|
||
"'max_uss_bytes': H",
|
||
"'average_max_uss_per_task': H",
|
||
"'num_inputs_received': N",
|
||
"'num_row_inputs_received': N",
|
||
"'bytes_inputs_received': N",
|
||
"'num_task_inputs_processed': N",
|
||
"'bytes_task_inputs_processed': N",
|
||
"'bytes_inputs_of_submitted_tasks': N",
|
||
"'rows_inputs_of_submitted_tasks': N",
|
||
"'num_task_outputs_generated': N",
|
||
"'bytes_task_outputs_generated': N",
|
||
"'rows_task_outputs_generated': N",
|
||
"'row_outputs_taken': N",
|
||
"'block_outputs_taken': N",
|
||
"'num_outputs_taken': N",
|
||
"'bytes_outputs_taken': N",
|
||
"'num_outputs_of_finished_tasks': N",
|
||
"'bytes_outputs_of_finished_tasks': N",
|
||
"'rows_outputs_of_finished_tasks': N",
|
||
"'num_external_inqueue_blocks': Z",
|
||
"'num_external_inqueue_bytes': Z",
|
||
"'num_external_outqueue_blocks': Z",
|
||
"'num_external_outqueue_bytes': Z",
|
||
"'num_tasks_submitted': N",
|
||
"'num_tasks_running': Z",
|
||
"'num_tasks_have_outputs': N",
|
||
"'num_tasks_finished': N",
|
||
"'num_tasks_failed': Z",
|
||
f"'task_scheduling_time_task_locality_hit_s': {'N' if task_locality_hit else 'Z'}",
|
||
f"'task_scheduling_time_task_locality_miss_s': {'Z' if task_locality_hit else 'N'}",
|
||
f"'bytes_inputs_of_task_locality_hit_tasks': {'N' if task_locality_hit else 'Z'}",
|
||
f"'bytes_inputs_of_task_locality_miss_tasks': {'Z' if task_locality_hit else 'N'}",
|
||
f"'task_completion_time_task_locality_hit_s': {'N' if task_locality_hit else 'Z'}",
|
||
f"'task_completion_time_task_locality_miss_s': {'Z' if task_locality_hit else 'N'}",
|
||
f"'num_tasks_task_locality_hit': {'N' if task_locality_hit else 'Z'}",
|
||
f"'num_tasks_task_locality_miss': {'Z' if task_locality_hit else 'N'}",
|
||
"'block_generation_time': N",
|
||
"'block_serialization_time_s': N",
|
||
(
|
||
"'task_submission_backpressure_time': "
|
||
f"{'N' if task_backpressure else 'Z'}"
|
||
),
|
||
(
|
||
"'task_output_backpressure_time': "
|
||
f"{'N' if task_output_backpressure else 'Z'}"
|
||
),
|
||
"'task_completion_time_s': N",
|
||
"'task_worker_completion_time_s': N",
|
||
"'task_scheduling_time_s': N",
|
||
"'task_output_backpressure_time_s': Z",
|
||
"'task_completion_time': (samples: N, avg: N)",
|
||
"'block_completion_time': (samples: N, avg: N)",
|
||
"'task_block_gen_and_ser_time_s': N",
|
||
"'block_size_bytes': (samples: N, avg: N)",
|
||
"'block_size_rows': (samples: N, avg: N)",
|
||
"'num_alive_actors': Z",
|
||
"'num_restarting_actors': Z",
|
||
"'num_pending_actors': Z",
|
||
"'num_active_actors': Z",
|
||
"'num_idle_actors': Z",
|
||
"'pool_utilization': Z",
|
||
"'num_tasks_in_flight': Z",
|
||
"'obj_store_mem_internal_inqueue_blocks': Z",
|
||
"'obj_store_mem_internal_outqueue_blocks': Z",
|
||
"'obj_store_mem_freed': N",
|
||
f"""'obj_store_mem_spilled': {"N" if spilled else "Z"}""",
|
||
"'obj_store_mem_used': A",
|
||
"'cpu_usage': Z",
|
||
"'gpu_usage': Z",
|
||
]
|
||
else:
|
||
metrics = [
|
||
"'average_num_outputs_per_task': None",
|
||
"'average_num_inputs_per_task': None",
|
||
"'num_output_blocks_per_task_s': None",
|
||
"'average_total_task_completion_time_s': None",
|
||
"'average_task_scheduling_time_s': None",
|
||
"'average_task_output_backpressure_time_s': None",
|
||
"'average_task_completion_time_excl_backpressure_s': None",
|
||
"'average_task_block_gen_and_ser_time_s': None",
|
||
"'average_bytes_per_output': None",
|
||
"'obj_store_mem_internal_inqueue': Z",
|
||
"'obj_store_mem_internal_outqueue': Z",
|
||
"'obj_store_mem_pending_task_inputs': Z",
|
||
"'obj_store_mem_pending_task_outputs': None",
|
||
"'average_bytes_inputs_per_task': None",
|
||
"'average_rows_inputs_per_task': None",
|
||
"'average_bytes_outputs_per_task': None",
|
||
"'average_rows_outputs_per_task': None",
|
||
"'op_task_duration_stats': {'num_samples': Z, 'mean': Z, 'variance': Z, 'min': None, 'max': None, 'pN': P, 'pN': P, 'pN': P, 'pN': P, 'pN': P, 'pN': P}",
|
||
"'max_uss_bytes': H",
|
||
"'average_max_uss_per_task': H",
|
||
"'num_inputs_received': N",
|
||
"'num_row_inputs_received': N",
|
||
"'bytes_inputs_received': N",
|
||
"'num_task_inputs_processed': Z",
|
||
"'bytes_task_inputs_processed': Z",
|
||
"'bytes_inputs_of_submitted_tasks': Z",
|
||
"'rows_inputs_of_submitted_tasks': Z",
|
||
"'num_task_outputs_generated': Z",
|
||
"'bytes_task_outputs_generated': Z",
|
||
"'rows_task_outputs_generated': Z",
|
||
"'row_outputs_taken': N",
|
||
"'block_outputs_taken': N",
|
||
"'num_outputs_taken': N",
|
||
"'bytes_outputs_taken': N",
|
||
"'num_outputs_of_finished_tasks': Z",
|
||
"'bytes_outputs_of_finished_tasks': Z",
|
||
"'rows_outputs_of_finished_tasks': Z",
|
||
"'num_external_inqueue_blocks': Z",
|
||
"'num_external_inqueue_bytes': Z",
|
||
"'num_external_outqueue_blocks': Z",
|
||
"'num_external_outqueue_bytes': Z",
|
||
"'num_tasks_submitted': Z",
|
||
"'num_tasks_running': Z",
|
||
"'num_tasks_have_outputs': Z",
|
||
"'num_tasks_finished': Z",
|
||
"'num_tasks_failed': Z",
|
||
"'task_scheduling_time_task_locality_hit_s': Z",
|
||
"'task_scheduling_time_task_locality_miss_s': Z",
|
||
"'bytes_inputs_of_task_locality_hit_tasks': Z",
|
||
"'bytes_inputs_of_task_locality_miss_tasks': Z",
|
||
"'task_completion_time_task_locality_hit_s': Z",
|
||
"'task_completion_time_task_locality_miss_s': Z",
|
||
"'num_tasks_task_locality_hit': Z",
|
||
"'num_tasks_task_locality_miss': Z",
|
||
"'block_generation_time': Z",
|
||
"'block_serialization_time_s': Z",
|
||
(
|
||
"'task_submission_backpressure_time': "
|
||
f"{'N' if task_backpressure else 'Z'}"
|
||
),
|
||
(
|
||
"'task_output_backpressure_time': "
|
||
f"{'N' if task_output_backpressure else 'Z'}"
|
||
),
|
||
"'task_completion_time_s': Z",
|
||
"'task_worker_completion_time_s': Z",
|
||
"'task_scheduling_time_s': Z",
|
||
"'task_output_backpressure_time_s': Z",
|
||
"'task_completion_time': (samples: Z, avg: Z)",
|
||
"'block_completion_time': (samples: Z, avg: Z)",
|
||
"'task_block_gen_and_ser_time_s': Z",
|
||
"'block_size_bytes': (samples: Z, avg: Z)",
|
||
"'block_size_rows': (samples: Z, avg: Z)",
|
||
"'num_alive_actors': Z",
|
||
"'num_restarting_actors': Z",
|
||
"'num_pending_actors': Z",
|
||
"'num_active_actors': Z",
|
||
"'num_idle_actors': Z",
|
||
"'pool_utilization': Z",
|
||
"'num_tasks_in_flight': Z",
|
||
"'obj_store_mem_internal_inqueue_blocks': Z",
|
||
"'obj_store_mem_internal_outqueue_blocks': Z",
|
||
"'obj_store_mem_freed': Z",
|
||
"'obj_store_mem_spilled': Z",
|
||
"'obj_store_mem_used': A",
|
||
"'cpu_usage': Z",
|
||
"'gpu_usage': Z",
|
||
]
|
||
if extra_metrics:
|
||
metrics.extend(extra_metrics)
|
||
return "{" + ", ".join(metrics) + "}"
|
||
|
||
|
||
def gen_extra_metrics_str(metrics: str, verbose: bool):
|
||
return f"* Extra metrics: {metrics}" + "\n" if verbose else ""
|
||
|
||
|
||
def gen_runtime_metrics_str(op_names: List[str], verbose: bool) -> str:
|
||
if not verbose:
|
||
return ""
|
||
out = "\nRuntime Metrics:\n"
|
||
for op in op_names + ["Scheduling", "Total"]:
|
||
out += f"* {op}: T (N%)\n"
|
||
return out
|
||
|
||
|
||
STANDARD_EXTRA_METRICS = gen_expected_metrics(
|
||
is_map=True,
|
||
spilled=False,
|
||
extra_metrics=[
|
||
"'ray_remote_args': {'num_cpus': N, 'scheduling_strategy': 'SPREAD'}"
|
||
],
|
||
)
|
||
|
||
STANDARD_EXTRA_METRICS_TASK_BACKPRESSURE = gen_expected_metrics(
|
||
is_map=True,
|
||
spilled=False,
|
||
task_backpressure=True,
|
||
extra_metrics=[
|
||
"'ray_remote_args': {'num_cpus': N, 'scheduling_strategy': 'SPREAD'}"
|
||
],
|
||
)
|
||
|
||
STANDARD_EXTRA_METRICS_TASK_BACKPRESSURE_LOCALITY_HIT = gen_expected_metrics(
|
||
is_map=True,
|
||
spilled=False,
|
||
task_backpressure=True,
|
||
extra_metrics=[
|
||
"'ray_remote_args': {'num_cpus': N, 'scheduling_strategy': 'SPREAD'}"
|
||
],
|
||
task_locality_hit=True,
|
||
)
|
||
|
||
MEM_SPILLED_EXTRA_METRICS = gen_expected_metrics(
|
||
is_map=True,
|
||
spilled=True,
|
||
extra_metrics=[
|
||
"'ray_remote_args': {'num_cpus': N, 'scheduling_strategy': 'SPREAD'}"
|
||
],
|
||
)
|
||
|
||
MEM_SPILLED_EXTRA_METRICS_TASK_BACKPRESSURE = gen_expected_metrics(
|
||
is_map=True,
|
||
spilled=True,
|
||
task_backpressure=True,
|
||
extra_metrics=[
|
||
"'ray_remote_args': {'num_cpus': N, 'scheduling_strategy': 'SPREAD'}"
|
||
],
|
||
)
|
||
|
||
|
||
CLUSTER_MEMORY_STATS = """
|
||
Cluster memory:
|
||
* Spilled to disk: M
|
||
* Restored from disk: M
|
||
"""
|
||
|
||
DATASET_MEMORY_STATS = """
|
||
Dataset memory:
|
||
* Spilled to disk: M
|
||
"""
|
||
|
||
EXECUTION_STRING = "N tasks executed, N blocks produced in T"
|
||
|
||
|
||
def canonicalize(
|
||
stats: str,
|
||
filter_global_stats: bool = True,
|
||
) -> str:
|
||
# Dataset UUID expression.
|
||
canonicalized_stats = re.sub(r"([a-f\d]{32})", "U", stats)
|
||
# Time expressions.
|
||
canonicalized_stats = re.sub(r"[0-9\.]+(ms|us|s)", "T", canonicalized_stats)
|
||
# Memory expressions.
|
||
canonicalized_stats = re.sub(r"[0-9\.]+(B|MB|GB)", "M", canonicalized_stats)
|
||
# Histogram expressions.
|
||
canonicalized_stats = re.sub(
|
||
r"\(samples: 0, avg: 0.00\)", "(samples: Z, avg: Z)", canonicalized_stats
|
||
)
|
||
canonicalized_stats = re.sub(
|
||
r"\(samples: \d+, avg: \d+\.\d+\)", "(samples: N, avg: N)", canonicalized_stats
|
||
)
|
||
# For obj_store_mem_used, the value can be zero or positive, depending on the run.
|
||
# Replace with A to avoid test flakiness.
|
||
canonicalized_stats = re.sub(
|
||
r"(obj_store_mem_used: |'obj_store_mem_used': )\d+(\.\d+)?",
|
||
# Replaces the number with 'A' while keeping the key prefix intact.
|
||
r"\g<1>A",
|
||
canonicalized_stats,
|
||
)
|
||
# Handle floats in (0, 1)
|
||
canonicalized_stats = re.sub(r" (0\.0*[1-9][0-9]*)", " N", canonicalized_stats)
|
||
# Replace input rows value (0 or non-0) with 'N' while keeping key prefix
|
||
canonicalized_stats = re.sub(
|
||
r"(Total input num rows: )\d+(\.\d+)?", r"\g<1>N", canonicalized_stats
|
||
)
|
||
# Replace output rows value (0 or non-0) with 'N' while keeping key prefix
|
||
canonicalized_stats = re.sub(
|
||
r"(Total output num rows: )\d+(\.\d+)?", r"\g<1>N", canonicalized_stats
|
||
)
|
||
# Handle zero values specially so we can check for missing values.
|
||
canonicalized_stats = re.sub(r" [0]+(\.[0])?", " Z", canonicalized_stats)
|
||
# Scientific notation for small or large numbers
|
||
canonicalized_stats = re.sub(r"\d+(\.\d+)?[eE][-+]?\d+", "N", canonicalized_stats)
|
||
# Other numerics.
|
||
canonicalized_stats = re.sub(r"[0-9]+(\.[0-9]+)?", "N", canonicalized_stats)
|
||
# Replace tabs with spaces.
|
||
canonicalized_stats = re.sub("\t", " ", canonicalized_stats)
|
||
|
||
canonicalized_stats = re.sub(
|
||
r"(average_max_uss_per_task:|'average_max_uss_per_task':) (?:N|Z|None)\b",
|
||
r"\g<1> H",
|
||
canonicalized_stats,
|
||
)
|
||
# Percentile values in DistributionTracker dicts can be None (when datasketches
|
||
# is not installed) or a number (canonicalized to N). Normalize to P.
|
||
canonicalized_stats = re.sub(
|
||
r"('pN': )(?:N|None)\b",
|
||
r"\g<1>P",
|
||
canonicalized_stats,
|
||
)
|
||
# max_uss_bytes DistributionTracker may have 0 or N samples depending on
|
||
# platform (USS measurement only available on Linux). Normalize entire dict.
|
||
canonicalized_stats = re.sub(
|
||
r"(max_uss_bytes['\s:]+)\{[^}]+\}",
|
||
r"\g<1>H",
|
||
canonicalized_stats,
|
||
)
|
||
|
||
if filter_global_stats:
|
||
canonicalized_stats = canonicalized_stats.replace(CLUSTER_MEMORY_STATS, "")
|
||
canonicalized_stats = canonicalized_stats.replace(DATASET_MEMORY_STATS, "")
|
||
return canonicalized_stats
|
||
|
||
|
||
def dummy_map_batches(x):
|
||
"""Dummy function used in calls to map_batches below."""
|
||
return x
|
||
|
||
|
||
def dummy_map_batches_sleep(n):
|
||
"""Function used to create a function that sleeps for n seconds
|
||
to be used in map_batches below."""
|
||
|
||
def f(x):
|
||
time.sleep(n)
|
||
return x
|
||
|
||
return f
|
||
|
||
|
||
@contextmanager
|
||
def patch_update_stats_actor():
|
||
with patch(
|
||
"ray.data._internal.stats._StatsManager.update_execution_metrics"
|
||
) as update_fn:
|
||
yield update_fn
|
||
|
||
|
||
@contextmanager
|
||
def patch_update_stats_actor_iter():
|
||
with patch(
|
||
"ray.data._internal.stats._StatsManager.update_iteration_metrics"
|
||
) as update_fn:
|
||
yield update_fn
|
||
|
||
|
||
def test_streaming_split_stats(ray_start_regular_shared, restore_data_context):
|
||
context = DataContext.get_current()
|
||
context.verbose_stats_logs = True
|
||
ds = ray.data.range(1000, override_num_blocks=10)
|
||
it = ds.map_batches(dummy_map_batches).streaming_split(1)[0]
|
||
list(it.iter_batches())
|
||
stats = it.stats()
|
||
extra_metrics_2 = gen_expected_metrics(
|
||
is_map=False,
|
||
extra_metrics=["'num_output_N': N", "'output_splitter_overhead_time': N"],
|
||
)
|
||
# The task_output_backpressure_time* metrics are wall-clock timers for output
|
||
# backpressure on the running MapBatches operator. Whether (and for how long)
|
||
# it blocks is a timing race against the single, slower streaming-split
|
||
# consumer, so the value is genuinely nondeterministic across runs (sometimes
|
||
# 0, usually positive). We therefore deliberately do NOT assert these three
|
||
# values: both the expected and the produced stats collapse them to a
|
||
# sentinel. Everything else -- including task_submission_backpressure_time --
|
||
# stays strictly checked. Only the running operator's (first) occurrence is
|
||
# collapsed; the idle split operator's timers remain strictly asserted as 0.
|
||
not_asserted = "<varies>"
|
||
backpressure_keys = (
|
||
"average_task_output_backpressure_time_s",
|
||
"task_output_backpressure_time",
|
||
"task_output_backpressure_time_s",
|
||
)
|
||
extra_metrics_1 = STANDARD_EXTRA_METRICS_TASK_BACKPRESSURE
|
||
for key in backpressure_keys:
|
||
extra_metrics_1 = extra_metrics_1.replace(
|
||
f"'{key}': Z", f"'{key}': {not_asserted}"
|
||
)
|
||
produced = canonicalize(stats)
|
||
for key in backpressure_keys:
|
||
# count=1 collapses only the first (running MapBatches operator)
|
||
# occurrence; the \b stops the "N" token from matching the "N" in an idle
|
||
# operator's "None".
|
||
produced = re.sub(
|
||
rf"('{key}': )(?:N|Z)\b", rf"\g<1>{not_asserted}", produced, count=1
|
||
)
|
||
# The per-stage training-thread blocked breakdown is timing-dependent
|
||
# (depends on whether prefetch hid the stall); strip it before comparing.
|
||
produced = re.sub(
|
||
r"\nPer-stage training-thread blocked time breakdown:\n"
|
||
r"(?: \* [^\n]+\n)+",
|
||
"",
|
||
produced,
|
||
)
|
||
assert (
|
||
produced
|
||
== f"""Operator N ReadRange->MapBatches(dummy_map_batches): {EXECUTION_STRING}
|
||
* Remote wall time: T min, T max, T mean, T total
|
||
* Remote cpu time: T min, T max, T mean, T total
|
||
* UDF time: T min, T max, T mean, T total
|
||
* Output num rows per block: N min, N max, N mean, N total
|
||
* Output size bytes per block: N min, N max, N mean, N total
|
||
* Output rows per task: N min, N max, N mean, N tasks used
|
||
* Tasks per node: N min, N max, N mean; N nodes used
|
||
* Operator throughput:
|
||
* Total input num rows: N rows
|
||
* Total output num rows: N rows
|
||
* Ray Data throughput: N rows/s
|
||
* Estimated single task throughput: N rows/s
|
||
* Extra metrics: {extra_metrics_1}
|
||
|
||
Operator N split(N, equal=False): \n"""
|
||
# Workaround to preserve trailing whitespace in the above line without
|
||
# causing linter failures.
|
||
f"""* Extra metrics: {extra_metrics_2}\n"""
|
||
"""
|
||
Dataset iterator time breakdown:
|
||
* Total time overall: T
|
||
* Total time in Ray Data iterator initialization code: T
|
||
* Total time user thread is blocked by Ray Data iter_batches: T
|
||
* Total time spent waiting for the first batch after starting iteration: T
|
||
* Total execution time for user thread: T
|
||
* Batch iteration time breakdown (summed across prefetch threads):
|
||
* In get RefBundles: T min, T max, T avg, T total
|
||
* In ray.get(): T min, T max, T avg, T total
|
||
* In batch creation: T min, T max, T avg, T total
|
||
* In batch formatting: T min, T max, T avg, T total
|
||
Streaming split coordinator overhead time: T
|
||
Total batches consumed: N
|
||
Total rows consumed: N
|
||
"""
|
||
f"{gen_runtime_metrics_str(['ReadRange->MapBatches(dummy_map_batches)', 'split(N, equal=False)'], True)}" # noqa: E501
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize("verbose_stats_logs", [True, False])
|
||
def test_dataset_stats_basic(
|
||
ray_start_regular_shared,
|
||
enable_auto_log_stats,
|
||
verbose_stats_logs,
|
||
restore_data_context,
|
||
):
|
||
context = DataContext.get_current()
|
||
context.verbose_stats_logs = verbose_stats_logs
|
||
logger = logging.getLogger("ray.data._internal.execution.streaming_executor")
|
||
|
||
with patch.object(logger, "info") as mock_logger:
|
||
ds = ray.data.range(1000, override_num_blocks=10)
|
||
ds = ds.map_batches(dummy_map_batches).materialize()
|
||
|
||
if enable_auto_log_stats:
|
||
logger_args, logger_kwargs = mock_logger.call_args_list[-2]
|
||
|
||
assert canonicalize(logger_args[0]) == (
|
||
f"Operator N ReadRange->MapBatches(dummy_map_batches): "
|
||
f"{EXECUTION_STRING}\n"
|
||
f"* Remote wall time: T min, T max, T mean, T total\n"
|
||
f"* Remote cpu time: T min, T max, T mean, T total\n"
|
||
f"* UDF time: T min, T max, T mean, T total\n"
|
||
f"* Output num rows per block: N min, N max, N mean, N total\n"
|
||
f"* Output size bytes per block: N min, N max, N mean, N total\n"
|
||
f"* Output rows per task: N min, N max, N mean, N tasks used\n"
|
||
f"* Tasks per node: N min, N max, N mean; N nodes used\n"
|
||
f"* Operator throughput:\n"
|
||
f" * Total input num rows: N rows\n"
|
||
f" * Total output num rows: N rows\n"
|
||
f" * Ray Data throughput: N rows/s\n"
|
||
f" * Estimated single task throughput: N rows/s\n"
|
||
f"{gen_extra_metrics_str(STANDARD_EXTRA_METRICS_TASK_BACKPRESSURE, verbose_stats_logs)}" # noqa: E501
|
||
f"\n"
|
||
f"Dataset throughput:\n"
|
||
f" * Ray Data throughput: N rows/s\n"
|
||
f"{gen_runtime_metrics_str(['ReadRange->MapBatches(dummy_map_batches)'], verbose_stats_logs)}" # noqa: E501
|
||
)
|
||
|
||
ds = ds.map(dummy_map_batches).materialize()
|
||
if enable_auto_log_stats:
|
||
logger_args, logger_kwargs = mock_logger.call_args_list[-2]
|
||
|
||
assert canonicalize(logger_args[0]) == (
|
||
f"Operator N Map(dummy_map_batches): {EXECUTION_STRING}\n"
|
||
f"* Remote wall time: T min, T max, T mean, T total\n"
|
||
f"* Remote cpu time: T min, T max, T mean, T total\n"
|
||
f"* UDF time: T min, T max, T mean, T total\n"
|
||
f"* Output num rows per block: N min, N max, N mean, N total\n"
|
||
f"* Output size bytes per block: N min, N max, N mean, N total\n"
|
||
f"* Output rows per task: N min, N max, N mean, N tasks used\n"
|
||
f"* Tasks per node: N min, N max, N mean; N nodes used\n"
|
||
f"* Operator throughput:\n"
|
||
f" * Total input num rows: N rows\n"
|
||
f" * Total output num rows: N rows\n"
|
||
f" * Ray Data throughput: N rows/s\n"
|
||
f" * Estimated single task throughput: N rows/s\n"
|
||
f"{gen_extra_metrics_str(STANDARD_EXTRA_METRICS_TASK_BACKPRESSURE_LOCALITY_HIT, verbose_stats_logs)}" # noqa: E501
|
||
f"\n"
|
||
f"Dataset throughput:\n"
|
||
f" * Ray Data throughput: N rows/s\n"
|
||
f"{gen_runtime_metrics_str(['ReadRange->MapBatches(dummy_map_batches)', 'Map(dummy_map_batches)'], verbose_stats_logs)}" # noqa: E501
|
||
)
|
||
|
||
for batch in ds.iter_batches():
|
||
pass
|
||
mds = ds.materialize()
|
||
|
||
# Use structured assertions instead of canonicalize string comparison
|
||
stats_summary = mds.get_stats_summary()
|
||
|
||
# Find each pipeline stage via the parent chain
|
||
map_summary = find_stats_summary_in_parents(stats_summary, r"^Map\(")
|
||
read_map_summary = find_stats_summary_in_parents(stats_summary, "ReadRange")
|
||
|
||
# Verify both operators have valid metrics
|
||
read_map_op = get_operator(read_map_summary, name_pattern="ReadRange->MapBatches")
|
||
assert_basic_operator_metrics(read_map_op)
|
||
assert read_map_op.output_num_rows.sum == 1000
|
||
|
||
map_op = get_operator(map_summary, name_pattern=r"^Map\(")
|
||
assert_basic_operator_metrics(map_op)
|
||
assert map_op.output_num_rows.sum == 1000
|
||
|
||
# Verify iteration stats are present
|
||
assert stats_summary.iter_stats is not None
|
||
assert stats_summary.iter_stats.total_time.get() > 0
|
||
|
||
|
||
def test_block_location_nums(ray_start_regular_shared, restore_data_context):
|
||
context = DataContext.get_current()
|
||
context.enable_get_object_locations_for_metrics = True
|
||
ds = ray.data.range(1000, override_num_blocks=10)
|
||
ds = ds.map_batches(dummy_map_batches).materialize()
|
||
|
||
for batch in ds.iter_batches():
|
||
pass
|
||
|
||
stats_summary = ds.materialize().get_stats_summary()
|
||
|
||
# Verify operator exists and has valid metrics
|
||
assert_operator_count(stats_summary, 1)
|
||
op = stats_summary.operators_stats[0]
|
||
assert re.search("ReadRange->MapBatches", op.operator_name)
|
||
assert_basic_operator_metrics(op)
|
||
assert op.output_num_rows.sum == 1000
|
||
|
||
# Verify iteration stats are present
|
||
assert stats_summary.iter_stats is not None
|
||
assert stats_summary.iter_stats.total_time.get() > 0
|
||
|
||
# Verify block location stats - local and remote should be 0, unknown > 0
|
||
assert stats_summary.iter_stats.iter_blocks_local == 0
|
||
assert stats_summary.iter_stats.iter_blocks_remote == 0
|
||
assert stats_summary.iter_stats.iter_unknown_location > 0
|
||
|
||
|
||
def test_dataset__repr__(ray_start_regular_shared, restore_data_context):
|
||
context = DataContext.get_current()
|
||
context.enable_get_object_locations_for_metrics = True
|
||
n = 100
|
||
ds = ray.data.range(n)
|
||
assert len(ds.take_all()) == n
|
||
ds = ds.materialize()
|
||
|
||
expected_stats = (
|
||
"DatasetStatsSummary(\n"
|
||
" dataset_uuid=N,\n"
|
||
" base_name=ReadRange,\n"
|
||
" number=N,\n"
|
||
" extra_metrics={\n"
|
||
" average_num_outputs_per_task: N,\n"
|
||
" average_num_inputs_per_task: N,\n"
|
||
" num_output_blocks_per_task_s: N,\n"
|
||
" average_total_task_completion_time_s: N,\n"
|
||
" average_task_scheduling_time_s: N,\n"
|
||
" average_task_output_backpressure_time_s: Z,\n"
|
||
" average_task_completion_time_excl_backpressure_s: N,\n"
|
||
" average_task_block_gen_and_ser_time_s: N,\n"
|
||
" average_bytes_per_output: N,\n"
|
||
" obj_store_mem_internal_inqueue: Z,\n"
|
||
" obj_store_mem_internal_outqueue: Z,\n"
|
||
" obj_store_mem_pending_task_inputs: Z,\n"
|
||
" obj_store_mem_pending_task_outputs: Z,\n"
|
||
" average_bytes_inputs_per_task: N,\n"
|
||
" average_rows_inputs_per_task: N,\n"
|
||
" average_bytes_outputs_per_task: N,\n"
|
||
" average_rows_outputs_per_task: N,\n"
|
||
" op_task_duration_stats: {'num_samples': N, 'mean': N, 'variance': N, 'min': N, 'max': N, 'pN': P, 'pN': P, 'pN': P, 'pN': P, 'pN': P, 'pN': P},\n"
|
||
" max_uss_bytes: H,\n"
|
||
" average_max_uss_per_task: H,\n"
|
||
" num_inputs_received: N,\n"
|
||
" num_row_inputs_received: N,\n"
|
||
" bytes_inputs_received: N,\n"
|
||
" num_task_inputs_processed: N,\n"
|
||
" bytes_task_inputs_processed: N,\n"
|
||
" bytes_inputs_of_submitted_tasks: N,\n"
|
||
" rows_inputs_of_submitted_tasks: N,\n"
|
||
" num_task_outputs_generated: N,\n"
|
||
" bytes_task_outputs_generated: N,\n"
|
||
" rows_task_outputs_generated: N,\n"
|
||
" row_outputs_taken: N,\n"
|
||
" block_outputs_taken: N,\n"
|
||
" num_outputs_taken: N,\n"
|
||
" bytes_outputs_taken: N,\n"
|
||
" num_outputs_of_finished_tasks: N,\n"
|
||
" bytes_outputs_of_finished_tasks: N,\n"
|
||
" rows_outputs_of_finished_tasks: N,\n"
|
||
" num_external_inqueue_blocks: Z,\n"
|
||
" num_external_inqueue_bytes: Z,\n"
|
||
" num_external_outqueue_blocks: Z,\n"
|
||
" num_external_outqueue_bytes: Z,\n"
|
||
" num_tasks_submitted: N,\n"
|
||
" num_tasks_running: Z,\n"
|
||
" num_tasks_have_outputs: N,\n"
|
||
" num_tasks_finished: N,\n"
|
||
" num_tasks_failed: Z,\n"
|
||
" task_scheduling_time_task_locality_hit_s: Z,\n"
|
||
" task_scheduling_time_task_locality_miss_s: N,\n"
|
||
" bytes_inputs_of_task_locality_hit_tasks: Z,\n"
|
||
" bytes_inputs_of_task_locality_miss_tasks: N,\n"
|
||
" task_completion_time_task_locality_hit_s: Z,\n"
|
||
" task_completion_time_task_locality_miss_s: N,\n"
|
||
" num_tasks_task_locality_hit: Z,\n"
|
||
" num_tasks_task_locality_miss: N,\n"
|
||
" block_generation_time: N,\n"
|
||
" block_serialization_time_s: N,\n"
|
||
" task_submission_backpressure_time: N,\n"
|
||
" task_output_backpressure_time: Z,\n"
|
||
" task_completion_time_s: N,\n"
|
||
" task_worker_completion_time_s: N,\n"
|
||
" task_scheduling_time_s: N,\n"
|
||
" task_output_backpressure_time_s: Z,\n"
|
||
" task_completion_time: (samples: N, avg: N),\n"
|
||
" block_completion_time: (samples: N, avg: N),\n"
|
||
" task_block_gen_and_ser_time_s: N,\n"
|
||
" block_size_bytes: (samples: N, avg: N),\n"
|
||
" block_size_rows: (samples: N, avg: N),\n"
|
||
" num_alive_actors: Z,\n"
|
||
" num_restarting_actors: Z,\n"
|
||
" num_pending_actors: Z,\n"
|
||
" num_active_actors: Z,\n"
|
||
" num_idle_actors: Z,\n"
|
||
" pool_utilization: Z,\n"
|
||
" num_tasks_in_flight: Z,\n"
|
||
" obj_store_mem_internal_inqueue_blocks: Z,\n"
|
||
" obj_store_mem_internal_outqueue_blocks: Z,\n"
|
||
" obj_store_mem_freed: N,\n"
|
||
" obj_store_mem_spilled: Z,\n"
|
||
" obj_store_mem_used: A,\n"
|
||
" cpu_usage: Z,\n"
|
||
" gpu_usage: Z,\n"
|
||
" ray_remote_args: {'num_cpus': N, 'scheduling_strategy': 'SPREAD'},\n"
|
||
" },\n"
|
||
" operators_stats=[\n"
|
||
" OperatorStatsSummary(\n"
|
||
" operator_name='ReadRange',\n"
|
||
" is_suboperator=False,\n"
|
||
" time_total_s=T,\n"
|
||
f" block_execution_summary_str={EXECUTION_STRING}\n"
|
||
" wall_time={'min': 'T', 'max': 'T', 'mean': 'T', 'sum': 'T'},\n"
|
||
" cpu_time={'min': 'T', 'max': 'T', 'mean': 'T', 'sum': 'T'},\n"
|
||
" output_num_rows={'min': 'T', 'max': 'T', 'mean': 'T', 'sum': 'T'},\n"
|
||
" output_size_bytes={'min': 'T', 'max': 'T', 'mean': 'T', 'sum': 'T'},\n" # noqa: E501
|
||
" node_count={'min': 'T', 'max': 'T', 'mean': 'T', 'count': 'T'},\n"
|
||
" ),\n"
|
||
" ],\n"
|
||
" iter_stats=IterStatsSummary(\n"
|
||
" wait_time=T,\n"
|
||
" get_ref_bundles_time=T,\n"
|
||
" get_time=T,\n"
|
||
" iter_blocks_local=None,\n"
|
||
" iter_blocks_remote=None,\n"
|
||
" iter_unknown_location=None,\n"
|
||
" iter_prefetched_bytes=None,\n"
|
||
" next_time=T,\n"
|
||
" format_time=T,\n"
|
||
" user_time=T,\n"
|
||
" total_time=T,\n"
|
||
" ),\n"
|
||
" global_bytes_spilled=M,\n"
|
||
" global_bytes_restored=M,\n"
|
||
" dataset_bytes_spilled=M,\n"
|
||
" parents=[\n"
|
||
" DatasetStatsSummary(\n"
|
||
" dataset_uuid=N,\n"
|
||
" base_name=None,\n"
|
||
" number=N,\n"
|
||
" extra_metrics={},\n"
|
||
" operators_stats=[],\n"
|
||
" iter_stats=IterStatsSummary(\n"
|
||
" wait_time=T,\n"
|
||
" get_ref_bundles_time=T,\n"
|
||
" get_time=T,\n"
|
||
" iter_blocks_local=None,\n"
|
||
" iter_blocks_remote=None,\n"
|
||
" iter_unknown_location=None,\n"
|
||
" iter_prefetched_bytes=None,\n"
|
||
" next_time=T,\n"
|
||
" format_time=T,\n"
|
||
" user_time=T,\n"
|
||
" total_time=T,\n"
|
||
" ),\n"
|
||
" global_bytes_spilled=M,\n"
|
||
" global_bytes_restored=M,\n"
|
||
" dataset_bytes_spilled=M,\n"
|
||
" parents=[],\n"
|
||
" ),\n"
|
||
" ],\n"
|
||
")"
|
||
)
|
||
|
||
def check_stats():
|
||
stats = canonicalize(repr(ds._raw_stats().to_summary()))
|
||
assert stats == expected_stats, stats
|
||
return True
|
||
|
||
# TODO(hchen): The reason why `wait_for_condition` is needed here is because
|
||
# `to_summary` depends on an external actor (_StatsActor) that records stats
|
||
# asynchronously. This makes the behavior non-deterministic.
|
||
# See the TODO in `to_summary`.
|
||
# We should make it deterministic and refine this test.
|
||
wait_for_condition(
|
||
check_stats,
|
||
timeout=10,
|
||
retry_interval_ms=1000,
|
||
)
|
||
|
||
ds2 = ds.map_batches(lambda x: x).materialize()
|
||
assert len(ds2.take_all()) == n
|
||
expected_stats2 = (
|
||
"DatasetStatsSummary(\n"
|
||
" dataset_uuid=N,\n"
|
||
" base_name=MapBatches(<lambda>),\n"
|
||
" number=N,\n"
|
||
" extra_metrics={\n"
|
||
" average_num_outputs_per_task: N,\n"
|
||
" average_num_inputs_per_task: N,\n"
|
||
" num_output_blocks_per_task_s: N,\n"
|
||
" average_total_task_completion_time_s: N,\n"
|
||
" average_task_scheduling_time_s: N,\n"
|
||
" average_task_output_backpressure_time_s: Z,\n"
|
||
" average_task_completion_time_excl_backpressure_s: N,\n"
|
||
" average_task_block_gen_and_ser_time_s: N,\n"
|
||
" average_bytes_per_output: N,\n"
|
||
" obj_store_mem_internal_inqueue: Z,\n"
|
||
" obj_store_mem_internal_outqueue: Z,\n"
|
||
" obj_store_mem_pending_task_inputs: Z,\n"
|
||
" obj_store_mem_pending_task_outputs: Z,\n"
|
||
" average_bytes_inputs_per_task: N,\n"
|
||
" average_rows_inputs_per_task: N,\n"
|
||
" average_bytes_outputs_per_task: N,\n"
|
||
" average_rows_outputs_per_task: N,\n"
|
||
" op_task_duration_stats: {'num_samples': N, 'mean': N, 'variance': N, 'min': N, 'max': N, 'pN': P, 'pN': P, 'pN': P, 'pN': P, 'pN': P, 'pN': P},\n"
|
||
" max_uss_bytes: H,\n"
|
||
" average_max_uss_per_task: H,\n"
|
||
" num_inputs_received: N,\n"
|
||
" num_row_inputs_received: N,\n"
|
||
" bytes_inputs_received: N,\n"
|
||
" num_task_inputs_processed: N,\n"
|
||
" bytes_task_inputs_processed: N,\n"
|
||
" bytes_inputs_of_submitted_tasks: N,\n"
|
||
" rows_inputs_of_submitted_tasks: N,\n"
|
||
" num_task_outputs_generated: N,\n"
|
||
" bytes_task_outputs_generated: N,\n"
|
||
" rows_task_outputs_generated: N,\n"
|
||
" row_outputs_taken: N,\n"
|
||
" block_outputs_taken: N,\n"
|
||
" num_outputs_taken: N,\n"
|
||
" bytes_outputs_taken: N,\n"
|
||
" num_outputs_of_finished_tasks: N,\n"
|
||
" bytes_outputs_of_finished_tasks: N,\n"
|
||
" rows_outputs_of_finished_tasks: N,\n"
|
||
" num_external_inqueue_blocks: Z,\n"
|
||
" num_external_inqueue_bytes: Z,\n"
|
||
" num_external_outqueue_blocks: Z,\n"
|
||
" num_external_outqueue_bytes: Z,\n"
|
||
" num_tasks_submitted: N,\n"
|
||
" num_tasks_running: Z,\n"
|
||
" num_tasks_have_outputs: N,\n"
|
||
" num_tasks_finished: N,\n"
|
||
" num_tasks_failed: Z,\n"
|
||
" task_scheduling_time_task_locality_hit_s: N,\n"
|
||
" task_scheduling_time_task_locality_miss_s: Z,\n"
|
||
" bytes_inputs_of_task_locality_hit_tasks: N,\n"
|
||
" bytes_inputs_of_task_locality_miss_tasks: Z,\n"
|
||
" task_completion_time_task_locality_hit_s: N,\n"
|
||
" task_completion_time_task_locality_miss_s: Z,\n"
|
||
" num_tasks_task_locality_hit: N,\n"
|
||
" num_tasks_task_locality_miss: Z,\n"
|
||
" block_generation_time: N,\n"
|
||
" block_serialization_time_s: N,\n"
|
||
" task_submission_backpressure_time: N,\n"
|
||
" task_output_backpressure_time: Z,\n"
|
||
" task_completion_time_s: N,\n"
|
||
" task_worker_completion_time_s: N,\n"
|
||
" task_scheduling_time_s: N,\n"
|
||
" task_output_backpressure_time_s: Z,\n"
|
||
" task_completion_time: (samples: N, avg: N),\n"
|
||
" block_completion_time: (samples: N, avg: N),\n"
|
||
" task_block_gen_and_ser_time_s: N,\n"
|
||
" block_size_bytes: (samples: N, avg: N),\n"
|
||
" block_size_rows: (samples: N, avg: N),\n"
|
||
" num_alive_actors: Z,\n"
|
||
" num_restarting_actors: Z,\n"
|
||
" num_pending_actors: Z,\n"
|
||
" num_active_actors: Z,\n"
|
||
" num_idle_actors: Z,\n"
|
||
" pool_utilization: Z,\n"
|
||
" num_tasks_in_flight: Z,\n"
|
||
" obj_store_mem_internal_inqueue_blocks: Z,\n"
|
||
" obj_store_mem_internal_outqueue_blocks: Z,\n"
|
||
" obj_store_mem_freed: N,\n"
|
||
" obj_store_mem_spilled: Z,\n"
|
||
" obj_store_mem_used: A,\n"
|
||
" cpu_usage: Z,\n"
|
||
" gpu_usage: Z,\n"
|
||
" ray_remote_args: {'num_cpus': N, 'scheduling_strategy': 'SPREAD'},\n"
|
||
" },\n"
|
||
" operators_stats=[\n"
|
||
" OperatorStatsSummary(\n"
|
||
" operator_name='MapBatches(<lambda>)',\n"
|
||
" is_suboperator=False,\n"
|
||
" time_total_s=T,\n"
|
||
f" block_execution_summary_str={EXECUTION_STRING}\n"
|
||
" wall_time={'min': 'T', 'max': 'T', 'mean': 'T', 'sum': 'T'},\n"
|
||
" cpu_time={'min': 'T', 'max': 'T', 'mean': 'T', 'sum': 'T'},\n"
|
||
" output_num_rows={'min': 'T', 'max': 'T', 'mean': 'T', 'sum': 'T'},\n"
|
||
" output_size_bytes={'min': 'T', 'max': 'T', 'mean': 'T', 'sum': 'T'},\n" # noqa: E501
|
||
" node_count={'min': 'T', 'max': 'T', 'mean': 'T', 'count': 'T'},\n"
|
||
" ),\n"
|
||
" ],\n"
|
||
" iter_stats=IterStatsSummary(\n"
|
||
" wait_time=T,\n"
|
||
" get_ref_bundles_time=T,\n"
|
||
" get_time=T,\n"
|
||
" iter_blocks_local=None,\n"
|
||
" iter_blocks_remote=None,\n"
|
||
" iter_unknown_location=N,\n"
|
||
" iter_prefetched_bytes=None,\n"
|
||
" next_time=T,\n"
|
||
" format_time=T,\n"
|
||
" user_time=T,\n"
|
||
" total_time=T,\n"
|
||
" ),\n"
|
||
" global_bytes_spilled=M,\n"
|
||
" global_bytes_restored=M,\n"
|
||
" dataset_bytes_spilled=M,\n"
|
||
" parents=[\n"
|
||
" DatasetStatsSummary(\n"
|
||
" dataset_uuid=N,\n"
|
||
" base_name=ReadRange,\n"
|
||
" number=N,\n"
|
||
" extra_metrics={\n"
|
||
" average_num_outputs_per_task: N,\n"
|
||
" average_num_inputs_per_task: N,\n"
|
||
" num_output_blocks_per_task_s: N,\n"
|
||
" average_total_task_completion_time_s: N,\n"
|
||
" average_task_scheduling_time_s: N,\n"
|
||
" average_task_output_backpressure_time_s: Z,\n"
|
||
" average_task_completion_time_excl_backpressure_s: N,\n"
|
||
" average_task_block_gen_and_ser_time_s: N,\n"
|
||
" average_bytes_per_output: N,\n"
|
||
" obj_store_mem_internal_inqueue: Z,\n"
|
||
" obj_store_mem_internal_outqueue: Z,\n"
|
||
" obj_store_mem_pending_task_inputs: Z,\n"
|
||
" obj_store_mem_pending_task_outputs: Z,\n"
|
||
" average_bytes_inputs_per_task: N,\n"
|
||
" average_rows_inputs_per_task: N,\n"
|
||
" average_bytes_outputs_per_task: N,\n"
|
||
" average_rows_outputs_per_task: N,\n"
|
||
" op_task_duration_stats: {'num_samples': N, 'mean': N, 'variance': N, 'min': N, 'max': N, 'pN': P, 'pN': P, 'pN': P, 'pN': P, 'pN': P, 'pN': P},\n"
|
||
" max_uss_bytes: H,\n"
|
||
" average_max_uss_per_task: H,\n"
|
||
" num_inputs_received: N,\n"
|
||
" num_row_inputs_received: N,\n"
|
||
" bytes_inputs_received: N,\n"
|
||
" num_task_inputs_processed: N,\n"
|
||
" bytes_task_inputs_processed: N,\n"
|
||
" bytes_inputs_of_submitted_tasks: N,\n"
|
||
" rows_inputs_of_submitted_tasks: N,\n"
|
||
" num_task_outputs_generated: N,\n"
|
||
" bytes_task_outputs_generated: N,\n"
|
||
" rows_task_outputs_generated: N,\n"
|
||
" row_outputs_taken: N,\n"
|
||
" block_outputs_taken: N,\n"
|
||
" num_outputs_taken: N,\n"
|
||
" bytes_outputs_taken: N,\n"
|
||
" num_outputs_of_finished_tasks: N,\n"
|
||
" bytes_outputs_of_finished_tasks: N,\n"
|
||
" rows_outputs_of_finished_tasks: N,\n"
|
||
" num_external_inqueue_blocks: Z,\n"
|
||
" num_external_inqueue_bytes: Z,\n"
|
||
" num_external_outqueue_blocks: Z,\n"
|
||
" num_external_outqueue_bytes: Z,\n"
|
||
" num_tasks_submitted: N,\n"
|
||
" num_tasks_running: Z,\n"
|
||
" num_tasks_have_outputs: N,\n"
|
||
" num_tasks_finished: N,\n"
|
||
" num_tasks_failed: Z,\n"
|
||
" task_scheduling_time_task_locality_hit_s: Z,\n"
|
||
" task_scheduling_time_task_locality_miss_s: N,\n"
|
||
" bytes_inputs_of_task_locality_hit_tasks: Z,\n"
|
||
" bytes_inputs_of_task_locality_miss_tasks: N,\n"
|
||
" task_completion_time_task_locality_hit_s: Z,\n"
|
||
" task_completion_time_task_locality_miss_s: N,\n"
|
||
" num_tasks_task_locality_hit: Z,\n"
|
||
" num_tasks_task_locality_miss: N,\n"
|
||
" block_generation_time: N,\n"
|
||
" block_serialization_time_s: N,\n"
|
||
" task_submission_backpressure_time: N,\n"
|
||
" task_output_backpressure_time: Z,\n"
|
||
" task_completion_time_s: N,\n"
|
||
" task_worker_completion_time_s: N,\n"
|
||
" task_scheduling_time_s: N,\n"
|
||
" task_output_backpressure_time_s: Z,\n"
|
||
" task_completion_time: (samples: N, avg: N),\n"
|
||
" block_completion_time: (samples: N, avg: N),\n"
|
||
" task_block_gen_and_ser_time_s: N,\n"
|
||
" block_size_bytes: (samples: N, avg: N),\n"
|
||
" block_size_rows: (samples: N, avg: N),\n"
|
||
" num_alive_actors: Z,\n"
|
||
" num_restarting_actors: Z,\n"
|
||
" num_pending_actors: Z,\n"
|
||
" num_active_actors: Z,\n"
|
||
" num_idle_actors: Z,\n"
|
||
" pool_utilization: Z,\n"
|
||
" num_tasks_in_flight: Z,\n"
|
||
" obj_store_mem_internal_inqueue_blocks: Z,\n"
|
||
" obj_store_mem_internal_outqueue_blocks: Z,\n"
|
||
" obj_store_mem_freed: N,\n"
|
||
" obj_store_mem_spilled: Z,\n"
|
||
" obj_store_mem_used: A,\n"
|
||
" cpu_usage: Z,\n"
|
||
" gpu_usage: Z,\n"
|
||
" ray_remote_args: {'num_cpus': N, 'scheduling_strategy': 'SPREAD'},\n" # noqa: E501
|
||
" },\n"
|
||
" operators_stats=[\n"
|
||
" OperatorStatsSummary(\n"
|
||
" operator_name='ReadRange',\n"
|
||
" is_suboperator=False,\n"
|
||
" time_total_s=T,\n"
|
||
f" block_execution_summary_str={EXECUTION_STRING}\n"
|
||
" wall_time={'min': 'T', 'max': 'T', 'mean': 'T', 'sum': 'T'},\n"
|
||
" cpu_time={'min': 'T', 'max': 'T', 'mean': 'T', 'sum': 'T'},\n"
|
||
" output_num_rows={'min': 'T', 'max': 'T', 'mean': 'T', 'sum': 'T'},\n" # noqa: E501
|
||
" output_size_bytes={'min': 'T', 'max': 'T', 'mean': 'T', 'sum': 'T'},\n" # noqa: E501
|
||
" node_count={'min': 'T', 'max': 'T', 'mean': 'T', 'count': 'T'},\n" # noqa: E501
|
||
" ),\n"
|
||
" ],\n"
|
||
" iter_stats=IterStatsSummary(\n"
|
||
" wait_time=T,\n"
|
||
" get_ref_bundles_time=T,\n"
|
||
" get_time=T,\n"
|
||
" iter_blocks_local=None,\n"
|
||
" iter_blocks_remote=None,\n"
|
||
" iter_unknown_location=None,\n"
|
||
" iter_prefetched_bytes=None,\n"
|
||
" next_time=T,\n"
|
||
" format_time=T,\n"
|
||
" user_time=T,\n"
|
||
" total_time=T,\n"
|
||
" ),\n"
|
||
" global_bytes_spilled=M,\n"
|
||
" global_bytes_restored=M,\n"
|
||
" dataset_bytes_spilled=M,\n"
|
||
" parents=[\n"
|
||
" DatasetStatsSummary(\n"
|
||
" dataset_uuid=N,\n"
|
||
" base_name=None,\n"
|
||
" number=N,\n"
|
||
" extra_metrics={},\n"
|
||
" operators_stats=[],\n"
|
||
" iter_stats=IterStatsSummary(\n"
|
||
" wait_time=T,\n"
|
||
" get_ref_bundles_time=T,\n"
|
||
" get_time=T,\n"
|
||
" iter_blocks_local=None,\n"
|
||
" iter_blocks_remote=None,\n"
|
||
" iter_unknown_location=None,\n"
|
||
" iter_prefetched_bytes=None,\n"
|
||
" next_time=T,\n"
|
||
" format_time=T,\n"
|
||
" user_time=T,\n"
|
||
" total_time=T,\n"
|
||
" ),\n"
|
||
" global_bytes_spilled=M,\n"
|
||
" global_bytes_restored=M,\n"
|
||
" dataset_bytes_spilled=M,\n"
|
||
" parents=[],\n"
|
||
" ),\n"
|
||
" ],\n"
|
||
" ),\n"
|
||
" ],\n"
|
||
")"
|
||
)
|
||
|
||
def check_stats2():
|
||
stats = canonicalize(repr(ds2._raw_stats().to_summary()))
|
||
assert stats == expected_stats2
|
||
return True
|
||
|
||
wait_for_condition(
|
||
check_stats2,
|
||
timeout=10,
|
||
retry_interval_ms=1000,
|
||
)
|
||
|
||
|
||
def test_dataset_stats_shuffle(ray_start_regular_shared):
|
||
ds = ray.data.range(1000, override_num_blocks=10)
|
||
ds = ds.random_shuffle().repartition(1, shuffle=True)
|
||
mds = ds.materialize()
|
||
|
||
stats_summary = mds.get_stats_summary()
|
||
|
||
repartition_summary = find_stats_summary_in_parents(stats_summary, "Repartition")
|
||
random_shuffle_summary = find_stats_summary_in_parents(
|
||
stats_summary, "RandomShuffle"
|
||
)
|
||
|
||
assert_operator_count(repartition_summary, expected_count=2)
|
||
get_operator(repartition_summary, name_pattern="RepartitionMap")
|
||
get_operator(repartition_summary, name_pattern="RepartitionReduce")
|
||
|
||
assert_operator_count(random_shuffle_summary, expected_count=2)
|
||
get_operator(random_shuffle_summary, name_pattern="RandomShuffleMap")
|
||
get_operator(random_shuffle_summary, name_pattern="RandomShuffleReduce")
|
||
|
||
# Both top-level operators should produce 1000 rows across their suboperators.
|
||
for operator_summary in (repartition_summary, random_shuffle_summary):
|
||
for op in operator_summary.operators_stats:
|
||
assert_basic_operator_metrics(op)
|
||
assert op.output_num_rows.sum == 1000
|
||
|
||
|
||
def test_dataset_stats_repartition(ray_start_regular_shared):
|
||
ds = ray.data.range(1000, override_num_blocks=10)
|
||
ds = ds.repartition(1, shuffle=False)
|
||
mds = ds.materialize()
|
||
|
||
stats_summary = mds.get_stats_summary()
|
||
op = get_operator(stats_summary, name_pattern="Repartition")
|
||
assert "Repartition" in op.operator_name
|
||
|
||
|
||
def test_dataset_stats_union(ray_start_regular_shared):
|
||
ds = ray.data.range(1000, override_num_blocks=10)
|
||
ds = ds.union(ds)
|
||
mds = ds.materialize()
|
||
|
||
stats_summary = mds.get_stats_summary()
|
||
op = get_operator(stats_summary, name_pattern="Union")
|
||
assert "Union" in op.operator_name
|
||
|
||
|
||
def test_dataset_stats_zip(ray_start_regular_shared):
|
||
ds = ray.data.range(1000, override_num_blocks=10)
|
||
ds = ds.zip(ds)
|
||
mds = ds.materialize()
|
||
|
||
stats_summary = mds.get_stats_summary()
|
||
op = get_operator(stats_summary, name_pattern="Zip")
|
||
assert "Zip" in op.operator_name
|
||
|
||
|
||
def test_dataset_stats_sort(ray_start_regular_shared):
|
||
ds = ray.data.range(1000, override_num_blocks=10)
|
||
ds = ds.sort("id")
|
||
mds = ds.materialize()
|
||
|
||
stats_summary = mds.get_stats_summary()
|
||
assert_operator_count(stats_summary, expected_count=2)
|
||
|
||
get_operator(stats_summary, name_pattern="SortMap")
|
||
get_operator(stats_summary, name_pattern="SortReduce")
|
||
|
||
|
||
def test_dataset_stats_from_items(ray_start_regular_shared):
|
||
ds = ray.data.from_items(range(10))
|
||
mds = ds.materialize()
|
||
|
||
stats_summary = mds.get_stats_summary()
|
||
op = get_operator(stats_summary, name_pattern="FromItems")
|
||
assert "FromItems" in op.operator_name
|
||
|
||
|
||
def test_dataset_stats_range(ray_start_regular_shared, tmp_path):
|
||
ds = ray.data.range(1000, override_num_blocks=10).map(lambda x: x)
|
||
mds = ds.materialize()
|
||
|
||
stats_summary = mds.get_stats_summary()
|
||
op = get_operator(stats_summary, name_pattern="ReadRange->Map")
|
||
|
||
# Check key metrics explicitly - tests are now clear about what they verify
|
||
assert op.output_num_rows.sum == 1000
|
||
assert op.wall_time.max > 0
|
||
assert op.wall_time.sum > 0
|
||
|
||
|
||
def test_dataset_split_stats(ray_start_regular_shared, tmp_path, restore_data_context):
|
||
DataContext.get_current().execution_options.preserve_order = True
|
||
|
||
ds = ray.data.range(100, override_num_blocks=10).map(
|
||
column_udf("id", lambda x: x + 1)
|
||
)
|
||
|
||
# Check ReadRange and Map operators before split
|
||
stats_summary = ds.materialize().get_stats_summary()
|
||
op = get_operator(stats_summary, name_pattern="ReadRange.*Map")
|
||
assert "ReadRange" in op.operator_name
|
||
assert "Map" in op.operator_name
|
||
assert op.wall_time.sum > 0
|
||
assert op.output_num_rows.sum == 100
|
||
|
||
# Check split was executed
|
||
dses = ds.split_at_indices([49])
|
||
assert len(dses) == 2
|
||
assert dses[0].count() == 49
|
||
assert dses[1].count() == 51
|
||
|
||
# Check Map operator after mapping on split datasets
|
||
dses = [ds.map(column_udf("id", lambda x: x + 1)) for ds in dses]
|
||
for ds_ in dses:
|
||
mds = ds_.materialize()
|
||
stats_summary = mds.get_stats_summary()
|
||
map_op = get_operator(stats_summary, name_pattern=r"Map\(")
|
||
assert "Map" in map_op.operator_name
|
||
assert map_op.wall_time.sum > 0
|
||
assert map_op.output_num_rows.sum > 0
|
||
|
||
|
||
def test_calculate_blocks_stats(ray_start_regular_shared, op_two_block):
|
||
block_params, block_meta_list = op_two_block
|
||
stats = DatasetStats(
|
||
metadata={"Read": block_meta_list},
|
||
parent=None,
|
||
)
|
||
calculated_stats = stats.to_summary().operators_stats[0]
|
||
|
||
assert calculated_stats.output_num_rows == StatsSummary(
|
||
min=min(block_params["num_rows"]),
|
||
max=max(block_params["num_rows"]),
|
||
mean=np.mean(block_params["num_rows"]),
|
||
sum=sum(block_params["num_rows"]),
|
||
count=len(block_params["num_rows"]),
|
||
)
|
||
assert calculated_stats.output_size_bytes == StatsSummary(
|
||
min=min(block_params["size_bytes"]),
|
||
max=max(block_params["size_bytes"]),
|
||
mean=np.mean(block_params["size_bytes"]),
|
||
sum=sum(block_params["size_bytes"]),
|
||
count=len(block_params["size_bytes"]),
|
||
)
|
||
assert calculated_stats.wall_time == StatsSummary(
|
||
min=min(block_params["wall_time"]),
|
||
max=max(block_params["wall_time"]),
|
||
mean=np.mean(block_params["wall_time"]),
|
||
sum=sum(block_params["wall_time"]),
|
||
count=len(block_params["wall_time"]),
|
||
)
|
||
assert calculated_stats.cpu_time == StatsSummary(
|
||
min=min(block_params["cpu_time"]),
|
||
max=max(block_params["cpu_time"]),
|
||
mean=np.mean(block_params["cpu_time"]),
|
||
sum=sum(block_params["cpu_time"]),
|
||
count=len(block_params["cpu_time"]),
|
||
)
|
||
|
||
node_counts = Counter(block_params["node_id"])
|
||
assert calculated_stats.node_count == StatsSummary(
|
||
min=min(node_counts.values()),
|
||
max=max(node_counts.values()),
|
||
mean=np.mean(list(node_counts.values())),
|
||
sum=sum(node_counts.values()),
|
||
count=len(node_counts),
|
||
)
|
||
|
||
|
||
def test_summarize_blocks(ray_start_regular_shared, op_two_block):
|
||
block_params, block_meta_list = op_two_block
|
||
stats = DatasetStats(
|
||
metadata={"Read": block_meta_list},
|
||
parent=None,
|
||
)
|
||
stats.dataset_uuid = "test-uuid"
|
||
|
||
calculated_stats = stats.to_summary()
|
||
op = calculated_stats.operators_stats[0]
|
||
|
||
# Verify operator name
|
||
assert "Read" in op.operator_name
|
||
|
||
# Verify all metrics are present and match expected values
|
||
assert op.output_num_rows == StatsSummary(
|
||
min=min(block_params["num_rows"]),
|
||
max=max(block_params["num_rows"]),
|
||
mean=np.mean(block_params["num_rows"]),
|
||
sum=sum(block_params["num_rows"]),
|
||
count=len(block_params["num_rows"]),
|
||
)
|
||
assert op.output_size_bytes == StatsSummary(
|
||
min=min(block_params["size_bytes"]),
|
||
max=max(block_params["size_bytes"]),
|
||
mean=np.mean(block_params["size_bytes"]),
|
||
sum=sum(block_params["size_bytes"]),
|
||
count=len(block_params["size_bytes"]),
|
||
)
|
||
assert op.wall_time == StatsSummary(
|
||
min=min(block_params["wall_time"]),
|
||
max=max(block_params["wall_time"]),
|
||
mean=np.mean(block_params["wall_time"]),
|
||
sum=sum(block_params["wall_time"]),
|
||
count=len(block_params["wall_time"]),
|
||
)
|
||
assert op.cpu_time == StatsSummary(
|
||
min=min(block_params["cpu_time"]),
|
||
max=max(block_params["cpu_time"]),
|
||
mean=np.mean(block_params["cpu_time"]),
|
||
sum=sum(block_params["cpu_time"]),
|
||
count=len(block_params["cpu_time"]),
|
||
)
|
||
|
||
node_counts = Counter(block_params["node_id"])
|
||
assert op.node_count == StatsSummary(
|
||
min=min(node_counts.values()),
|
||
max=max(node_counts.values()),
|
||
mean=np.mean(list(node_counts.values())),
|
||
sum=sum(node_counts.values()),
|
||
count=len(node_counts),
|
||
)
|
||
|
||
# Verify to_string() produces output containing operator name
|
||
stats_str = calculated_stats.to_string()
|
||
assert "Read" in stats_str
|
||
assert "tasks executed" in stats_str
|
||
|
||
|
||
def test_get_total_stats(ray_start_regular_shared, op_two_block):
|
||
"""Tests a set of similar getter methods which pull aggregated
|
||
statistics values after calculating operator-level stats:
|
||
`DatasetStats.get_total_wall_time()`,
|
||
`DatasetStats.get_total_cpu_time()`."""
|
||
block_params, block_meta_list = op_two_block
|
||
stats = DatasetStats(
|
||
metadata={"Read": block_meta_list},
|
||
parent=None,
|
||
)
|
||
|
||
dataset_stats_summary = stats.to_summary()
|
||
op_stats = dataset_stats_summary.operators_stats[0]
|
||
|
||
# simple case with only one block / summary, result should match difference between
|
||
# the start and end time
|
||
assert (
|
||
dataset_stats_summary.get_total_wall_time()
|
||
== op_stats.latest_end_time - op_stats.earliest_start_time
|
||
)
|
||
|
||
# total time across all blocks is sum of wall times of blocks
|
||
assert dataset_stats_summary.get_total_time_all_blocks() == sum(
|
||
block_params["wall_time"]
|
||
)
|
||
|
||
cpu_time_stats = op_stats.cpu_time
|
||
assert dataset_stats_summary.get_total_cpu_time() == cpu_time_stats.sum
|
||
|
||
|
||
def test_streaming_stats_full(ray_start_regular_shared, restore_data_context):
|
||
ds = ray.data.range(5, override_num_blocks=5).map(column_udf("id", lambda x: x + 1))
|
||
ds.take_all()
|
||
|
||
stats_summary = ds.get_stats_summary()
|
||
op = get_operator(stats_summary, name_pattern="ReadRange->Map")
|
||
|
||
# Verify operator has expected metrics
|
||
assert "ReadRange" in op.operator_name
|
||
assert "Map" in op.operator_name
|
||
assert op.wall_time is not None
|
||
assert op.cpu_time is not None
|
||
assert op.udf_time is not None
|
||
assert op.output_num_rows is not None
|
||
assert op.output_size_bytes is not None
|
||
assert op.node_count is not None
|
||
assert op.task_rows is not None
|
||
assert stats_summary.num_rows_per_s > 0
|
||
|
||
# Verify dataset iterator time breakdown exists
|
||
assert stats_summary.iter_stats is not None
|
||
|
||
|
||
def test_write_ds_stats(ray_start_regular_shared, tmp_path):
|
||
# Test 1: Basic write_parquet - stats stored in _write_ds
|
||
ds1 = ray.data.range(100, override_num_blocks=100)
|
||
ds1.write_parquet(str(tmp_path))
|
||
|
||
write_stats = ds1._write_ds.get_stats_summary()
|
||
op = get_operator(write_stats, name_pattern="ReadRange->Write")
|
||
assert "ReadRange" in op.operator_name
|
||
assert "Write" in op.operator_name
|
||
assert op.wall_time.sum > 0
|
||
assert op.output_num_rows.sum > 0
|
||
assert write_stats.num_rows_per_s > 0
|
||
|
||
# Test 2: Materialize then write_parquet
|
||
ds2 = (
|
||
ray.data.range(100, override_num_blocks=100)
|
||
.map_batches(lambda x: x)
|
||
.materialize()
|
||
)
|
||
|
||
# Capture stats before write_parquet
|
||
materialized_stats = ds2.get_stats_summary()
|
||
map_op = get_operator(materialized_stats, name_pattern="Map")
|
||
assert map_op.wall_time.sum > 0
|
||
|
||
# After write_parquet, ds.get_stats_summary() returns _write_ds stats
|
||
# This tests the _write_ds delegation branch
|
||
ds2.write_parquet(str(tmp_path))
|
||
combined_stats = ds2.get_stats_summary()
|
||
write_op = get_operator(combined_stats, name_pattern="Write")
|
||
assert write_op.wall_time.sum > 0
|
||
|
||
|
||
def test_time_backpressure(ray_start_regular_shared, restore_data_context):
|
||
class TimedBackpressurePolicy(BackpressurePolicy):
|
||
COUNT = 0
|
||
|
||
def can_add_input(self, op: "PhysicalOperator") -> bool:
|
||
if TimedBackpressurePolicy.COUNT > 1:
|
||
time.sleep(0.01)
|
||
return True
|
||
else:
|
||
TimedBackpressurePolicy.COUNT += 1
|
||
return False
|
||
|
||
context = DataContext.get_current()
|
||
context.verbose_stats_logs = True
|
||
context.set_config(
|
||
ENABLED_BACKPRESSURE_POLICIES_CONFIG_KEY, [TimedBackpressurePolicy]
|
||
)
|
||
|
||
def f(x):
|
||
time.sleep(0.01)
|
||
return x
|
||
|
||
ds = ray.data.range(10000).map_batches(f).materialize()
|
||
assert ds._raw_stats().extra_metrics["task_submission_backpressure_time"] > 0
|
||
|
||
|
||
def test_runtime_metrics(ray_start_regular_shared):
|
||
from math import isclose
|
||
|
||
def time_to_seconds(time_str):
|
||
if time_str.endswith("us"):
|
||
# Convert microseconds to seconds
|
||
return float(time_str[:-2]) / (1000 * 1000)
|
||
elif time_str.endswith("ms"):
|
||
# Convert milliseconds to seconds
|
||
return float(time_str[:-2]) / 1000
|
||
elif time_str.endswith("s"):
|
||
# Already in seconds, just remove the 's' and convert to float
|
||
return float(time_str[:-1])
|
||
|
||
f = dummy_map_batches_sleep(0.01)
|
||
ds = ray.data.range(100).map(f).materialize().map(f).materialize()
|
||
metrics_str = ds._raw_stats().runtime_metrics()
|
||
|
||
# Dictionary to store the metrics for testing
|
||
metrics_dict = {}
|
||
|
||
# Regular expression to match the pattern of each metric line
|
||
pattern = re.compile(r"\* (.+?): ([\d\.]+(?:ms|s)) \(([\d\.]+)%\)")
|
||
|
||
# Split the input string into lines and iterate over them
|
||
for line in metrics_str.split("\n"):
|
||
match = pattern.match(line)
|
||
if match:
|
||
# Extracting the operator name, time, and percentage
|
||
operator_name, time_str, percent_str = match.groups()
|
||
# Converting percentage to float and keeping time as string
|
||
metrics_dict[operator_name] = (
|
||
time_to_seconds(time_str),
|
||
float(percent_str),
|
||
)
|
||
|
||
total_time, total_percent = metrics_dict.pop("Total")
|
||
assert total_percent == 100
|
||
|
||
# Tolerance for floating-point rounding errors (100ms)
|
||
# Individual operator times may appear slightly larger than total time
|
||
# due to rounding (e.g., 2.265s rounds to 2.27s for operator but 2.26s for total)
|
||
TOLERANCE = 0.02
|
||
|
||
for name, (time_s, percent) in metrics_dict.items():
|
||
# Special-case Scheduling: it's cumulative time across scheduling loops,
|
||
# so it can exceed the wall-clock Total span under concurrency.
|
||
if name == "Scheduling":
|
||
continue
|
||
if time_s > total_time + TOLERANCE:
|
||
print("runtime_metrics raw:\n", metrics_str)
|
||
print("runtime_metrics parsed:", metrics_dict)
|
||
print(
|
||
f"runtime_metrics mismatch for '{name}': {time_s}s > {total_time}s (tolerance: {TOLERANCE}s)"
|
||
)
|
||
|
||
assert time_s <= total_time + TOLERANCE
|
||
# Check percentage, this is done with some expected loss of precision
|
||
# due to rounding in the intital output.
|
||
assert isclose(percent, time_s / total_time * 100, rel_tol=0.01)
|
||
|
||
|
||
def test_per_node_metrics_basic(ray_start_regular_shared, restore_data_context):
|
||
"""Basic test to ensure per-node metrics are populated."""
|
||
ctx = DataContext.get_current()
|
||
ctx.enable_per_node_metrics = True
|
||
|
||
def _sum_net_metrics(per_node_metrics: Dict[str, NodeMetrics]) -> Dict[str, float]:
|
||
sum_metrics = defaultdict(float)
|
||
for metrics in per_node_metrics.values():
|
||
for metric, value in metrics.items():
|
||
sum_metrics[metric] += value
|
||
return sum_metrics
|
||
|
||
with patch("ray.data._internal.stats.get_or_create_stats_actor") as mock_get_actor:
|
||
mock_actor_handle = MagicMock()
|
||
mock_get_actor.return_value = mock_actor_handle
|
||
|
||
ds = ray.data.range(20).map_batches(lambda batch: batch).materialize()
|
||
metrics = ds._raw_stats().extra_metrics
|
||
|
||
calls = mock_actor_handle.update_execution_metrics.remote.call_args_list
|
||
assert len(calls) > 0
|
||
|
||
last_args, _ = calls[-1]
|
||
per_node_metrics = last_args[-1]
|
||
|
||
assert isinstance(per_node_metrics, dict)
|
||
assert len(per_node_metrics) >= 1
|
||
|
||
for nm in per_node_metrics.values():
|
||
for f in fields(NodeMetrics):
|
||
assert f.name in nm
|
||
|
||
# basic checks to make sure metrics are populated
|
||
assert any(nm["num_tasks_finished"] > 0 for nm in per_node_metrics.values())
|
||
assert any(
|
||
nm["bytes_outputs_of_finished_tasks"] > 0
|
||
for nm in per_node_metrics.values()
|
||
)
|
||
assert any(
|
||
nm["blocks_outputs_of_finished_tasks"] > 0
|
||
for nm in per_node_metrics.values()
|
||
)
|
||
|
||
net_metrics = _sum_net_metrics(per_node_metrics)
|
||
assert net_metrics["num_tasks_finished"] == metrics["num_tasks_finished"]
|
||
assert (
|
||
net_metrics["bytes_outputs_of_finished_tasks"]
|
||
== metrics["bytes_outputs_of_finished_tasks"]
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize("enable_metrics", [True, False])
|
||
def test_per_node_metrics_toggle(
|
||
ray_start_regular_shared, restore_data_context, enable_metrics
|
||
):
|
||
ctx = DataContext.get_current()
|
||
ctx.enable_per_node_metrics = enable_metrics
|
||
|
||
with patch("ray.data._internal.stats.get_or_create_stats_actor") as mock_get_actor:
|
||
mock_actor_handle = MagicMock()
|
||
mock_get_actor.return_value = mock_actor_handle
|
||
|
||
ray.data.range(10000).map(lambda x: x).materialize()
|
||
|
||
calls = mock_actor_handle.update_execution_metrics.remote.call_args_list
|
||
assert len(calls) > 0
|
||
|
||
last_args, _ = calls[-1]
|
||
per_node_metrics = last_args[-1]
|
||
|
||
if enable_metrics:
|
||
assert per_node_metrics is not None
|
||
else:
|
||
assert per_node_metrics is None
|
||
|
||
|
||
def test_dataset_throughput_calculation(ray_start_regular_shared):
|
||
"""Test throughput calculations using mock block stats."""
|
||
|
||
def create_block_stats(start_time, end_time, num_rows):
|
||
wall_time_s = end_time - start_time
|
||
exec_stats = BlockExecStats(
|
||
start_time_s=start_time,
|
||
end_time_s=end_time,
|
||
wall_time_s=wall_time_s,
|
||
cpu_time_s=wall_time_s,
|
||
)
|
||
return BlockStats(num_rows=num_rows, size_bytes=None, exec_stats=exec_stats)
|
||
|
||
blocks_stats = [
|
||
create_block_stats(0.0, 2.0, 100),
|
||
create_block_stats(0.5, 2.5, 100),
|
||
create_block_stats(1.0, 3.0, 100),
|
||
]
|
||
|
||
stats = DatasetStats(metadata={"Map": blocks_stats}, parent=None)
|
||
summary = stats.to_summary()
|
||
|
||
# Throughput: total rows / total execution duration
|
||
# Total rows = 300
|
||
# Duration = max end_time - min start_time = 3.0s
|
||
# 300 rows / 3s = 100 rows/s
|
||
assert summary.num_rows_per_s == 100
|
||
|
||
|
||
def test_operator_throughput_calculation(ray_start_regular_shared):
|
||
"""Test operator throughput calculations using mock BlockStats."""
|
||
|
||
def create_block_stats(start_time, end_time, num_rows, task_idx):
|
||
wall_time_s = end_time - start_time
|
||
exec_stats = BlockExecStats(
|
||
start_time_s=start_time,
|
||
end_time_s=end_time,
|
||
wall_time_s=wall_time_s,
|
||
cpu_time_s=wall_time_s,
|
||
task_idx=task_idx,
|
||
)
|
||
return BlockStats(num_rows=num_rows, size_bytes=None, exec_stats=exec_stats)
|
||
|
||
blocks_stats = [
|
||
create_block_stats(0.0, 2.0, 100, 1),
|
||
create_block_stats(0.0, 2.0, 100, 2),
|
||
]
|
||
|
||
summary = OperatorStatsSummary.from_block_metadata(
|
||
operator_name="MockOperator",
|
||
block_stats=blocks_stats,
|
||
is_sub_operator=False,
|
||
)
|
||
|
||
# Total rows = 200
|
||
# Total operator wall time (from earliest start to latest end) = 2.0s
|
||
# Sum of individual task wall times = 2.0s + 2.0s = 4.0s
|
||
# Overall throughput: Total rows / Total operator wall time
|
||
assert summary.num_rows_per_s == 200 / (2.0 - 0.0)
|
||
|
||
# Estimated single task throughput: Total rows / Sum of individual task wall times`
|
||
assert summary.num_rows_per_task_s == 200 / (2.0 + 2.0)
|
||
|
||
|
||
# NOTE: All tests above share a Ray cluster, while the tests below do not. These
|
||
# tests should only be carefully reordered to retain this invariant!
|
||
|
||
|
||
def test_individual_operator_num_rows(shutdown_only):
|
||
# The input num rows of an individual operator should be the same as the output num rows of its parent operator.
|
||
ray.shutdown()
|
||
ray.init(num_cpus=2)
|
||
|
||
data = [{"id": i, "value": i * 1.5, "category": i % 5} for i in range(500)]
|
||
ds = (
|
||
ray.data.from_items(data)
|
||
.map(lambda x: {**x, "value_squared": x["value"] ** 2})
|
||
.filter(lambda x: x["value_squared"] > 300)
|
||
)
|
||
|
||
stats_output = ds.materialize().stats()
|
||
re_op0_output = re.compile(r"Operator 0.*?Total output num rows: (\d+)", re.DOTALL)
|
||
re_op1_input = re.compile(r"Operator 1.*?Total input num rows: (\d+)", re.DOTALL)
|
||
|
||
op0_output = int(re_op0_output.search(stats_output).group(1))
|
||
op1_input = int(re_op1_input.search(stats_output).group(1))
|
||
|
||
assert op0_output == 500
|
||
assert op0_output == op1_input
|
||
|
||
|
||
@pytest.mark.parametrize("verbose_stats_logs", [True, False])
|
||
def test_spilled_stats(shutdown_only, verbose_stats_logs, restore_data_context):
|
||
context = DataContext.get_current()
|
||
context.verbose_stats_logs = verbose_stats_logs
|
||
context.enable_get_object_locations_for_metrics = True
|
||
# The object store is about 100MB.
|
||
ray.init(object_store_memory=100e6)
|
||
# The size of dataset is 1000*80*80*4*8B, about 200MB.
|
||
ds = ray.data.range(1000 * 80 * 80 * 4).map_batches(lambda x: x).materialize()
|
||
|
||
# Use structured assertions instead of canonicalize string comparison
|
||
stats_summary = ds.get_stats_summary()
|
||
assert_operator_count(stats_summary, 1)
|
||
op = stats_summary.operators_stats[0]
|
||
assert re.search("ReadRange->MapBatches", op.operator_name)
|
||
assert_basic_operator_metrics(op)
|
||
expected_rows = 1000 * 80 * 80 * 4
|
||
assert op.output_num_rows.sum == expected_rows
|
||
|
||
# Verify global memory stats (spilled/restored)
|
||
assert stats_summary.global_bytes_spilled > 0
|
||
assert stats_summary.global_bytes_restored > 0
|
||
|
||
# Around 100MB should be spilled (200MB - 100MB)
|
||
assert ds._raw_stats().global_bytes_spilled > 100e6
|
||
|
||
ds = (
|
||
ray.data.range(1000 * 80 * 80 * 4)
|
||
.map_batches(lambda x: x)
|
||
.materialize()
|
||
.map_batches(lambda x: x)
|
||
.materialize()
|
||
)
|
||
# two map_batches operators, twice the spillage
|
||
assert ds._raw_stats().dataset_bytes_spilled > 200e6
|
||
|
||
# The size of dataset is around 50MB, there should be no spillage
|
||
ds = (
|
||
ray.data.range(250 * 80 * 80 * 4, override_num_blocks=1)
|
||
.map_batches(lambda x: x)
|
||
.materialize()
|
||
)
|
||
|
||
assert ds._raw_stats().dataset_bytes_spilled == 0
|
||
|
||
|
||
def test_stats_actor_metrics():
|
||
ray.init(object_store_memory=100e6)
|
||
with patch_update_stats_actor() as update_fn:
|
||
ds = ray.data.range(1000 * 80 * 80 * 4).map_batches(lambda x: x).materialize()
|
||
|
||
# last emitted metrics from map operator
|
||
final_metric = update_fn.call_args_list[-1].args[1][-1]
|
||
|
||
assert final_metric.obj_store_mem_spilled == ds._raw_stats().dataset_bytes_spilled
|
||
assert (
|
||
final_metric.obj_store_mem_freed
|
||
== ds._raw_stats().extra_metrics["obj_store_mem_freed"]
|
||
)
|
||
assert (
|
||
final_metric.bytes_task_outputs_generated == 1000 * 80 * 80 * 4 * 8
|
||
) # 8B per int
|
||
assert final_metric.rows_task_outputs_generated == 1000 * 80 * 80 * 4
|
||
# There should be nothing in object store at the end of execution.
|
||
|
||
args = update_fn.call_args_list[-1].args
|
||
assert args[0] == f"dataset_{ds._uuid}_0"
|
||
assert args[2][0] == "Input_0"
|
||
assert args[2][1] == "ReadRange->MapBatches(<lambda>)_1"
|
||
|
||
def sleep_three(x):
|
||
import time
|
||
|
||
time.sleep(3)
|
||
return x
|
||
|
||
with patch_update_stats_actor() as update_fn:
|
||
ds = ray.data.range(3).map_batches(sleep_three, batch_size=1).materialize()
|
||
|
||
final_metric = update_fn.call_args_list[-1].args[1][-1]
|
||
assert final_metric.block_generation_time >= 9
|
||
|
||
|
||
def test_stats_actor_iter_metrics():
|
||
ds = ray.data.range(1e6).map_batches(lambda x: x)
|
||
with patch_update_stats_actor_iter() as update_fn:
|
||
ds.take_all()
|
||
|
||
ds_stats = ds._raw_stats()
|
||
final_stats = update_fn.call_args_list[-1].args[0]
|
||
|
||
assert final_stats == ds_stats
|
||
assert f"dataset_{ds._uuid}_0" == update_fn.call_args_list[-1].args[1]
|
||
|
||
|
||
def test_update_iteration_metrics_exports_new_iter_metrics():
|
||
stats = DatasetStats(metadata={}, parent=None)
|
||
stats.iter_total_s.add(11.0)
|
||
stats.iter_blocked_production_wait_s.add(1.0)
|
||
stats.iter_blocked_data_transfer_s.add(1.5)
|
||
stats.iter_blocked_batching_s.add(2.0)
|
||
stats.iter_blocked_format_s.add(3.0)
|
||
stats.iter_blocked_collate_s.add(4.0)
|
||
stats.iter_blocked_finalize_s.add(5.0)
|
||
stats.iter_batches_total = 7
|
||
stats.iter_rows_total = 8
|
||
|
||
actor = _StatsActor.__ray_metadata__.modified_class.__new__(
|
||
_StatsActor.__ray_metadata__.modified_class
|
||
)
|
||
recorded = {}
|
||
|
||
class FakeGauge:
|
||
def __init__(self, name):
|
||
self.name = name
|
||
|
||
def set(self, value, tags):
|
||
recorded[self.name] = (value, tags)
|
||
|
||
for attr in [
|
||
"iter_initialize_s",
|
||
"iter_total_s",
|
||
"iter_get_ref_bundles_s",
|
||
"iter_get_s",
|
||
"iter_next_batch_s",
|
||
"iter_format_batch_s",
|
||
"iter_collate_batch_s",
|
||
"iter_finalize_batch_s",
|
||
"iter_blocks_local",
|
||
"iter_blocks_remote",
|
||
"iter_unknown_location",
|
||
"iter_prefetched_bytes",
|
||
"iter_block_fetching_s",
|
||
"iter_batch_shaping_s",
|
||
"iter_batch_formatting_s",
|
||
"iter_batch_collating_s",
|
||
"iter_batch_finalizing_s",
|
||
"time_to_first_batch_s",
|
||
"iter_total_blocked_s",
|
||
"iter_blocked_production_wait_s",
|
||
"iter_blocked_data_transfer_s",
|
||
"iter_blocked_batching_s",
|
||
"iter_blocked_format_s",
|
||
"iter_blocked_collate_s",
|
||
"iter_blocked_finalize_s",
|
||
"iter_batches_total",
|
||
"iter_rows_total",
|
||
"iter_user_s",
|
||
]:
|
||
setattr(actor, attr, FakeGauge(attr))
|
||
|
||
actor.update_iteration_metrics(stats, "train_dataset_split_3")
|
||
|
||
expected_tags = {"dataset": "train_dataset_split_3"}
|
||
assert recorded["iter_total_s"] == (11.0, expected_tags)
|
||
assert recorded["iter_blocked_production_wait_s"] == (1.0, expected_tags)
|
||
assert recorded["iter_blocked_data_transfer_s"] == (1.5, expected_tags)
|
||
assert recorded["iter_blocked_batching_s"] == (2.0, expected_tags)
|
||
assert recorded["iter_blocked_format_s"] == (3.0, expected_tags)
|
||
assert recorded["iter_blocked_collate_s"] == (4.0, expected_tags)
|
||
assert recorded["iter_blocked_finalize_s"] == (5.0, expected_tags)
|
||
assert recorded["iter_batches_total"] == (7, expected_tags)
|
||
assert recorded["iter_rows_total"] == (8, expected_tags)
|
||
|
||
|
||
def test_iter_stats_summary_has_new_fields():
|
||
"""IterStatsSummary includes per-stage blocked timers and counters."""
|
||
stats = DatasetStats(metadata={}, parent=None)
|
||
summary = stats.to_summary()
|
||
iter_summary = summary.iter_stats
|
||
|
||
expected_fields = {
|
||
"blocked_production_wait_time",
|
||
"blocked_data_transfer_time",
|
||
"blocked_batching_time",
|
||
"blocked_format_time",
|
||
"blocked_collate_time",
|
||
"blocked_finalize_time",
|
||
"batches_total",
|
||
"rows_total",
|
||
}
|
||
actual_fields = {f.name for f in fields(iter_summary)}
|
||
assert expected_fields.issubset(
|
||
actual_fields
|
||
), f"missing fields: {expected_fields - actual_fields}"
|
||
|
||
|
||
def test_iter_stats_summary_reflects_accumulated_values():
|
||
"""IterStatsSummary carries the accumulated timer values."""
|
||
stats = DatasetStats(metadata={}, parent=None)
|
||
stats.iter_blocked_production_wait_s.add(0.5)
|
||
stats.iter_blocked_batching_s.add(0.2)
|
||
stats.iter_batches_total = 10
|
||
stats.iter_rows_total = 320
|
||
|
||
summary = stats.to_summary().iter_stats
|
||
assert summary.blocked_production_wait_time.get() == pytest.approx(0.5)
|
||
assert summary.blocked_data_transfer_time.get() == pytest.approx(0.0)
|
||
assert summary.blocked_batching_time.get() == pytest.approx(0.2)
|
||
assert summary.batches_total == 10
|
||
assert summary.rows_total == 320
|
||
|
||
|
||
def test_iter_stats_to_string_shows_stage_breakdown():
|
||
"""to_string() renders per-stage breakdown when values are non-zero."""
|
||
stats = DatasetStats(metadata={}, parent=None)
|
||
stats.iter_blocked_production_wait_s.add(1.5)
|
||
stats.iter_blocked_format_s.add(0.8)
|
||
stats.iter_batches_total = 5
|
||
stats.iter_rows_total = 160
|
||
stats.iter_total_blocked_s.add(2.3)
|
||
|
||
text = str(stats.to_summary().iter_stats)
|
||
assert "production wait" in text
|
||
assert "format" in text
|
||
assert "Total batches consumed: 5" in text
|
||
assert "Total rows consumed: 160" in text
|
||
assert "Per-stage training-thread blocked time breakdown" in text
|
||
|
||
|
||
def test_iter_stats_to_string_omits_zero_stages():
|
||
"""to_string() omits stages with zero values from the breakdown."""
|
||
stats = DatasetStats(metadata={}, parent=None)
|
||
stats.iter_blocked_production_wait_s.add(0.5)
|
||
stats.iter_total_blocked_s.add(0.5)
|
||
|
||
text = str(stats.to_summary().iter_stats)
|
||
assert "production wait" in text
|
||
# Zero stages should not appear
|
||
assert "batching" not in text
|
||
assert "collate" not in text
|
||
|
||
|
||
def test_iter_stats_to_string_no_breakdown_when_all_zero():
|
||
"""When all blocked_* stages are zero, no breakdown section appears."""
|
||
stats = DatasetStats(metadata={}, parent=None)
|
||
text = str(stats.to_summary().iter_stats)
|
||
assert "Per-stage training-thread blocked time breakdown" not in text
|
||
assert "Total batches consumed" not in text
|
||
assert "Total rows consumed" not in text
|
||
|
||
|
||
def test_dataset_name_and_id():
|
||
# Test deprecated APIs: _set_name and _name
|
||
ds = ray.data.range(1)
|
||
ds._set_name("test_ds")
|
||
assert ds._name == "test_ds"
|
||
|
||
ds = ray.data.range(100, override_num_blocks=20).map_batches(lambda x: x)
|
||
ds.set_name("test_ds")
|
||
assert ds.name == "test_ds"
|
||
assert "test_ds" in repr(ds)
|
||
|
||
def _run_dataset(ds, expected_name, expected_run_index):
|
||
with patch_update_stats_actor() as update_fn:
|
||
for _ in ds.iter_batches():
|
||
pass
|
||
|
||
assert (
|
||
update_fn.call_args_list[-1].args[0]
|
||
== f"{expected_name}_{ds._uuid}_{expected_run_index}"
|
||
)
|
||
|
||
_run_dataset(ds, "test_ds", 0)
|
||
|
||
# Run the dataset again, the execution index should be incremented
|
||
_run_dataset(ds, "test_ds", 1)
|
||
|
||
# Names persist after an execution
|
||
ds = ds.random_shuffle()
|
||
assert ds.name == "test_ds"
|
||
_run_dataset(ds, "test_ds", 0)
|
||
|
||
ds.set_name("test_ds_two")
|
||
ds = ds.map_batches(lambda x: x)
|
||
assert ds.name == "test_ds_two"
|
||
_run_dataset(ds, "test_ds_two", 0)
|
||
|
||
ds.set_name(None)
|
||
ds = ds.map_batches(lambda x: x)
|
||
assert ds.name is None
|
||
_run_dataset(ds, "dataset", 0)
|
||
|
||
ds = ray.data.range(100, override_num_blocks=20)
|
||
ds.set_name("very_loooooooong_name")
|
||
assert "very_loooooooong_name" in repr(ds)
|
||
|
||
|
||
def test_dataset_id_train_ingest():
|
||
"""Test that the dataset ID is properly set for training ingestion jobs."""
|
||
num_epochs = 3
|
||
driver_script = f"""
|
||
import ray
|
||
|
||
ds = ray.data.range(100, override_num_blocks=20).map_batches(lambda x: x)
|
||
ds.set_name("train")
|
||
ds._set_uuid("1234")
|
||
|
||
split = ds.streaming_split(1)[0]
|
||
|
||
for epoch in range({num_epochs}):
|
||
for _ in split.iter_batches():
|
||
pass
|
||
"""
|
||
# Need to run the code as s sub process, because the executor
|
||
# runs on the SplitCoordinator actor.
|
||
out = run_string_as_driver(driver_script)
|
||
for i in range(num_epochs):
|
||
dataset_id = f"train_1234_{i}"
|
||
assert f"Starting execution of Dataset {dataset_id}" in out
|
||
|
||
|
||
def test_executor_logs_metrics_on_operator_completion(caplog, propagate_logs):
|
||
"""Test that operator completion metrics are logged exactly once per operator."""
|
||
EXPECTED_COMPLETION_MESSAGE = (
|
||
"Operator TaskPoolMapOperator[ReadRange] completed. Operator Metrics:"
|
||
)
|
||
|
||
with caplog.at_level(logging.DEBUG):
|
||
ray.data.range(1).take_all()
|
||
|
||
log_messages = [record.message for record in caplog.records]
|
||
actual_count = sum(EXPECTED_COMPLETION_MESSAGE in msg for msg in log_messages)
|
||
assert actual_count == 1, (
|
||
f"Expected operator completion message to appear exactly once, "
|
||
f"but found {actual_count} occurrences"
|
||
)
|
||
|
||
|
||
def test_stats_actor_datasets(ray_start_cluster):
|
||
ds = ray.data.range(100, override_num_blocks=20).map_batches(lambda x: x)
|
||
ds.set_name("test_stats_actor_datasets")
|
||
ds.materialize()
|
||
stats_actor = get_or_create_stats_actor()
|
||
|
||
datasets = ray.get(stats_actor.get_datasets.remote())
|
||
dataset_name = list(filter(lambda x: x.startswith(ds.name), datasets))
|
||
assert len(dataset_name) == 1
|
||
dataset = datasets[dataset_name[0]]
|
||
|
||
assert dataset["state"] == "FINISHED"
|
||
assert dataset["progress"] == 20
|
||
assert dataset["total"] == 20
|
||
assert dataset["end_time"] is not None
|
||
|
||
operators = dataset["operators"]
|
||
assert len(operators) == 2
|
||
assert "Input_0" in operators
|
||
assert "ReadRange->MapBatches(<lambda>)_1" in operators
|
||
for value in operators.values():
|
||
assert value["name"] in ["Input", "ReadRange->MapBatches(<lambda>)"]
|
||
assert value["progress"] == 20
|
||
assert value["total"] == 20
|
||
assert value["state"] == "FINISHED"
|
||
|
||
|
||
def test_stats_actor_datasets_eviction(ray_start_cluster):
|
||
"""
|
||
Tests that finished datasets are evicted from the _StatsActor when
|
||
the number of datasets exceeds the configured `max_stats` limit.
|
||
"""
|
||
# Set a low max_stats limit to easily trigger eviction.
|
||
max_stats = 2
|
||
# Create a dedicated _StatsActor for this test to avoid interfering
|
||
# with the global actor.
|
||
stats_actor = _StatsActor.remote(max_stats=max_stats)
|
||
|
||
# Patch the function that retrieves the stats actor to return our
|
||
# test-specific actor instance.
|
||
with patch(
|
||
"ray.data._internal.stats.get_or_create_stats_actor",
|
||
return_value=stats_actor,
|
||
):
|
||
|
||
def check_ds_finished(ds_name):
|
||
"""Helper to check if a dataset is marked as FINISHED in the actor."""
|
||
datasets = ray.get(stats_actor.get_datasets.remote())
|
||
ds_tag = next((tag for tag in datasets if tag.startswith(ds_name)), None)
|
||
if not ds_tag:
|
||
return False
|
||
return datasets[ds_tag]["state"] == DatasetState.FINISHED.name
|
||
|
||
# --- DS1 ---
|
||
# Create and materialize the first dataset.
|
||
ds1 = ray.data.range(1, override_num_blocks=1)
|
||
ds1.set_name("ds1")
|
||
ds1.materialize()
|
||
# Wait until the actor has been updated with the FINISHED state.
|
||
wait_for_condition(lambda: check_ds_finished("ds1"))
|
||
|
||
# --- DS2 ---
|
||
# Create and materialize the second dataset.
|
||
# This brings the total number of datasets to the `max_stats` limit.
|
||
ds2 = ray.data.range(1, override_num_blocks=1)
|
||
ds2.set_name("ds2")
|
||
ds2.materialize()
|
||
wait_for_condition(lambda: check_ds_finished("ds2"))
|
||
|
||
# --- Verify state before eviction ---
|
||
# At this point, both ds1 and ds2 should be in the actor.
|
||
datasets = ray.get(stats_actor.get_datasets.remote())
|
||
names_in_actor = {k.split("_")[0] for k in datasets.keys()}
|
||
assert names_in_actor == {"ds1", "ds2"}
|
||
|
||
# --- DS3 ---
|
||
# Create and materialize the third dataset. This should trigger the
|
||
# eviction of the oldest finished dataset (ds1).
|
||
ds3 = ray.data.range(1, override_num_blocks=1)
|
||
ds3.set_name("ds3")
|
||
ds3.materialize()
|
||
|
||
def check_eviction():
|
||
"""
|
||
Helper to check that the actor state reflects the eviction.
|
||
The actor should now contain ds2 and ds3, but not ds1.
|
||
"""
|
||
datasets = ray.get(stats_actor.get_datasets.remote())
|
||
# The eviction happens asynchronously, so we might briefly see 3 datasets.
|
||
# We wait until the count is back to 2.
|
||
if len(datasets) == max_stats + 1:
|
||
return False
|
||
names = {k.split("_")[0] for k in datasets.keys()}
|
||
assert names == {"ds2", "ds3"}
|
||
return True
|
||
|
||
# Wait until the eviction has occurred and the actor state is correct.
|
||
wait_for_condition(check_eviction)
|
||
|
||
|
||
# Setting internal=10000 (super high number) value so they are only called
|
||
# once (on cold start), and on shutdown.
|
||
@patch.object(StreamingExecutor, "UPDATE_METRICS_INTERVAL_S", new=10000)
|
||
@patch.object(BatchIterator, "UPDATE_METRICS_INTERVAL_S", new=10000)
|
||
@patch("ray.data._internal.stats.get_or_create_stats_actor")
|
||
def test_stats_manager(mock_get_or_create, shutdown_only):
|
||
|
||
# Configure what get_or_create_stats_actor() returns
|
||
mock_actor = MagicMock()
|
||
mock_get_or_create.return_value = mock_actor
|
||
|
||
ray.init()
|
||
num_threads = 10
|
||
|
||
datasets = [None] * num_threads
|
||
|
||
def update_stats_manager(i):
|
||
datasets[i] = ray.data.range(10).map_batches(lambda x: x)
|
||
for _ in datasets[i].iter_batches(batch_size=1):
|
||
pass
|
||
|
||
threads = [
|
||
threading.Thread(target=update_stats_manager, args=(i,), daemon=True)
|
||
for i in range(num_threads)
|
||
]
|
||
for thread in threads:
|
||
thread.start()
|
||
for thread in threads:
|
||
thread.join()
|
||
|
||
# Count calls to register_dataset.remote()
|
||
register_dataset_calls = mock_actor.register_dataset.remote.call_count
|
||
# Count calls to update_iteration_metrics.remote()
|
||
iteration_calls = mock_actor.update_iteration_metrics.remote.call_count
|
||
# Count calls to update_execution_metrics.remote()
|
||
execution_calls = mock_actor.update_execution_metrics.remote.call_count
|
||
|
||
# Each thread handles 1 dataset.
|
||
assert register_dataset_calls == num_threads
|
||
|
||
# Since interval is set to high value, the number of execution
|
||
# calls will update on the first update (cold start), and on shutdown,
|
||
# which is 2 for each thread.
|
||
assert execution_calls == 2 * num_threads
|
||
# iteration_calls has 3 per thread: cold start + shutdown + the
|
||
# finally-block flush in DataIterator._iter_batches (added so an
|
||
# early ``break`` still records iter_total_s and flushes metrics).
|
||
assert iteration_calls == 3 * num_threads
|
||
|
||
|
||
def test_stats_manager_stale_actor_handle(ray_start_cluster):
|
||
"""
|
||
This test asserts that StatsManager is able to handle appropriately
|
||
cases of StatsActor being killed upon driver disconnecting from running
|
||
Ray cluster
|
||
|
||
See https://github.com/ray-project/ray/issues/54841 for more details
|
||
"""
|
||
|
||
class F:
|
||
def __call__(self, x):
|
||
return x
|
||
|
||
# First driver run
|
||
ray.init(ignore_reinit_error=True)
|
||
|
||
ray.data.range(1000).map_batches(
|
||
F,
|
||
concurrency=(1, 4),
|
||
num_cpus=1,
|
||
).take_all()
|
||
|
||
ray.shutdown()
|
||
|
||
# Second driver run
|
||
ray.init(ignore_reinit_error=True)
|
||
|
||
ray.data.range(1000).map_batches(
|
||
F,
|
||
concurrency=(1, 4),
|
||
num_cpus=1,
|
||
).take_all()
|
||
|
||
ray.shutdown()
|
||
|
||
|
||
def test_runtime_metrics_histogram_observe():
|
||
"""Test that RuntimeMetricsHistogram correctly places values in buckets."""
|
||
# Create a simple histogram with 3 boundaries: [1.0, 5.0, 10.0]
|
||
boundaries = [1.0, 5.0, 10.0]
|
||
histogram = RuntimeMetricsHistogram(boundaries)
|
||
|
||
# Test values in different buckets
|
||
histogram.observe(0.5) # Should go to bucket 0 (< 1.0)
|
||
histogram.observe(3.0) # Should go to bucket 1 (1.0 <= x < 5.0)
|
||
histogram.observe(7.0) # Should go to bucket 2 (5.0 <= x < 10.0)
|
||
histogram.observe(15.0) # Should go to bucket 3 (>= 10.0)
|
||
|
||
# Test multiple observations
|
||
histogram.observe(2.0, num_observations=3) # Should add 3 to bucket 1
|
||
|
||
# Verify bucket counts
|
||
expected_counts = [1, 4, 1, 1] # [bucket0, bucket1, bucket2, bucket3]
|
||
assert histogram._bucket_counts == expected_counts
|
||
|
||
# Verify the average value
|
||
assert f"{histogram}" == "(samples: 7, avg: 5.00)"
|
||
|
||
|
||
def test_runtime_metrics_histogram_export_to():
|
||
"""Test that export_to correctly applies observations to Ray Histogram."""
|
||
from ray.util.metrics import Histogram
|
||
|
||
# Create a simple histogram with 2 boundaries
|
||
boundaries = [1.0, 3.0]
|
||
histogram = RuntimeMetricsHistogram(boundaries)
|
||
|
||
# Add some observations
|
||
histogram.observe(0.5) # bucket 0
|
||
histogram.observe(2.0) # bucket 1
|
||
histogram.observe(5.0) # bucket 2
|
||
|
||
# Create a mock Ray Histogram
|
||
mock_metric = MagicMock(spec=Histogram)
|
||
mock_metric.last_applied_bucket_counts_for_tags = {}
|
||
|
||
# Apply to metric
|
||
tags = {"node_id": "test_node"}
|
||
histogram.export_to(mock_metric, tags)
|
||
|
||
# Verify that observe was called 3 times (once for each observation)
|
||
assert mock_metric.observe.call_count == 3
|
||
|
||
# Verify the bucket values used for observations are reasonable
|
||
# (should be midpoints of the bucket ranges)
|
||
calls = mock_metric.observe.call_args_list
|
||
observed_values = [call[0][0] for call in calls] # First argument of each call
|
||
|
||
# Check that we have values in the expected ranges
|
||
# Bucket 0: 0 to 1.0, midpoint should be around 0.5
|
||
# Bucket 1: 1.0 to 3.0, midpoint should be around 2.0
|
||
# Bucket 2: 3.0 to 13.0 (3.0 + 10), midpoint should be around 8.0
|
||
assert any(0 <= val <= 1.0 for val in observed_values)
|
||
assert any(1.0 <= val <= 3.0 for val in observed_values)
|
||
assert any(3.0 <= val for val in observed_values)
|
||
|
||
# Verify that the last_applied_bucket_counts_for_tags was updated
|
||
tags_key = '{"node_id": "test_node"}'
|
||
assert tags_key in mock_metric.last_applied_bucket_counts_for_tags
|
||
assert mock_metric.last_applied_bucket_counts_for_tags[tags_key] == [1, 1, 1]
|
||
|
||
# Add some more observations
|
||
histogram.observe(0.8) # bucket 0
|
||
histogram.observe(1.2) # bucket 1
|
||
histogram.export_to(mock_metric, tags)
|
||
|
||
# Verify that observe was called 2 more times (once for each observation)
|
||
assert mock_metric.observe.call_count == 5
|
||
|
||
# Verify the bucket values used for observations are reasonable
|
||
# (should be midpoints of the bucket ranges)
|
||
calls = mock_metric.observe.call_args_list
|
||
observed_values = [call[0][0] for call in calls[2:]] # First argument of each call
|
||
|
||
# Check that we have values in the expected ranges
|
||
# Bucket 0: 0 to 1.0, midpoint should be around 0.5
|
||
# Bucket 1: 1.0 to 3.0, midpoint should be around 2.0
|
||
assert any(0 <= val <= 1.0 for val in observed_values)
|
||
assert any(1.0 <= val <= 3.0 for val in observed_values)
|
||
|
||
assert mock_metric.last_applied_bucket_counts_for_tags[tags_key] == [2, 2, 1]
|
||
|
||
|
||
def test_data_context_with_custom_classes_serialization(ray_start_cluster):
|
||
"""
|
||
Test that DataContext containing custom exception classes can be properly
|
||
serialized to StatsActor across different jobs.
|
||
|
||
This test reproduces the issue where StatsActor fails to deserialize
|
||
DataContext when it contains custom exception classes imported from modules
|
||
that are not available in StatsActor's runtime environment.
|
||
|
||
The fix uses DataContextMetadata to sanitize DataContext before serialization,
|
||
converting custom classes to dictionary representations.
|
||
"""
|
||
import os
|
||
import tempfile
|
||
|
||
def create_driver_script_with_dependency(working_dir, ray_address):
|
||
"""Create custom module and driver script that depends on it."""
|
||
custom_module_path = os.path.join(working_dir, "test_custom_module.py")
|
||
with open(custom_module_path, "w") as f:
|
||
f.write(
|
||
"""class CustomRetryException(Exception):
|
||
def __init__(self):
|
||
pass
|
||
"""
|
||
)
|
||
|
||
driver_script = f"""
|
||
import sys
|
||
# Add working_dir to sys.path so we can import test_custom_module
|
||
sys.path.insert(0, r"{working_dir}")
|
||
|
||
import ray
|
||
import ray.data
|
||
from ray.data.context import DataContext
|
||
|
||
ray.init(
|
||
address="{ray_address}",
|
||
ignore_reinit_error=True,
|
||
runtime_env={{"working_dir": r"{working_dir}"}}
|
||
)
|
||
|
||
import test_custom_module
|
||
|
||
data_context = DataContext.get_current()
|
||
data_context.actor_task_retry_on_errors = [test_custom_module.CustomRetryException]
|
||
|
||
ds = ray.data.range(10)
|
||
ds.take(1)
|
||
ray.shutdown()
|
||
"""
|
||
return driver_script
|
||
|
||
# Job 1: Create dataset to trigger StatsActor creation
|
||
ds = ray.data.range(10)
|
||
ds.take(1)
|
||
|
||
# Job 2: Run job that imports custom exception from module
|
||
with tempfile.TemporaryDirectory() as working_dir:
|
||
ray_address = ray.get_runtime_context().gcs_address
|
||
driver_script = create_driver_script_with_dependency(working_dir, ray_address)
|
||
|
||
# This should succeed without ModuleNotFoundError if the fix is applied
|
||
run_string_as_driver(driver_script)
|
||
|
||
# Verify StatsActor can retrieve datasets without errors
|
||
stats_actor = get_or_create_stats_actor()
|
||
datasets = ray.get(stats_actor.get_datasets.remote())
|
||
assert len(datasets) == 2, (
|
||
f"Expected exactly 2 datasets (one from Job 1 and one from Job 2), "
|
||
f"but found {len(datasets)}"
|
||
)
|
||
|
||
|
||
class TestTimerPercentile:
|
||
"""Tests for Timer's KLL-sketch-backed percentile().
|
||
|
||
Every ``Timer.add(v)`` feeds the internal ``DistributionTracker``
|
||
sketch, so ``percentile`` returns an approximate quantile bounded
|
||
by the KLL accuracy guarantee (~1.65% rank error at k=200). For
|
||
sample counts at or below k the sketch keeps every item and
|
||
returns exact values; the relaxed tolerances below cover both
|
||
regimes.
|
||
"""
|
||
|
||
def test_zero_samples(self):
|
||
t = Timer()
|
||
assert t.percentile(0.0) == 0
|
||
assert t.percentile(0.5) == 0
|
||
assert t.percentile(0.9) == 0
|
||
assert t.percentile(1.0) == 0
|
||
|
||
def test_existing_aggregate_stats_unchanged(self):
|
||
# Sanity: wiring DistributionTracker into add() must not perturb
|
||
# Timer's pre-existing sum/min/max/avg semantics.
|
||
t = Timer()
|
||
for v in [0.001, 0.01, 0.1, 1.0]:
|
||
t.add(v)
|
||
assert t.get() == pytest.approx(1.111)
|
||
assert t.max() == pytest.approx(1.0)
|
||
assert t.min() == pytest.approx(0.001)
|
||
assert t.avg() == pytest.approx(0.27775)
|
||
|
||
def test_single_sample(self):
|
||
t = Timer()
|
||
t.add(0.042)
|
||
for p in [0.0, 0.5, 0.9, 1.0]:
|
||
assert t.percentile(p) == pytest.approx(0.042)
|
||
|
||
@pytest.mark.parametrize("p", [0.5, 0.9, 0.99])
|
||
def test_uniform_samples(self, p):
|
||
# All samples identical — every quantile must equal the sample.
|
||
t = Timer()
|
||
for _ in range(100):
|
||
t.add(0.005)
|
||
assert t.percentile(p) == pytest.approx(0.005)
|
||
|
||
def test_linearly_spaced_samples_within_kll_tolerance(self):
|
||
# 11 samples in [0.0, 1.0]. With k=200 the sketch is exact at
|
||
# this size; allow a small abs tolerance for safety.
|
||
t = Timer()
|
||
for v in [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]:
|
||
t.add(v)
|
||
assert t.percentile(0.0) == pytest.approx(0.0, abs=0.05)
|
||
assert t.percentile(0.5) == pytest.approx(0.5, abs=0.1)
|
||
assert t.percentile(0.9) == pytest.approx(0.9, abs=0.1)
|
||
assert t.percentile(1.0) == pytest.approx(1.0, abs=0.05)
|
||
|
||
def test_percentiles_are_monotonic(self):
|
||
# Across arbitrary quantiles, the sketch must return a
|
||
# monotonically non-decreasing sequence.
|
||
t = Timer()
|
||
for v in range(1, 1001):
|
||
t.add(float(v))
|
||
ps = [t.percentile(q) for q in [0.1, 0.25, 0.5, 0.75, 0.9, 0.99]]
|
||
assert ps == sorted(ps)
|
||
|
||
def test_bimodal_distribution(self):
|
||
# 80 samples at 5ms, 20 samples at 200ms.
|
||
# true p50 = 5ms (50% of mass is at 5ms)
|
||
# true p90 ≈ 200ms (top 20% of mass is at 200ms)
|
||
t = Timer()
|
||
for _ in range(80):
|
||
t.add(0.005)
|
||
for _ in range(20):
|
||
t.add(0.2)
|
||
assert t.percentile(0.5) == pytest.approx(0.005, abs=0.01)
|
||
assert t.percentile(0.9) == pytest.approx(0.2, abs=0.05)
|
||
|
||
def test_worker_scaling_regression_case(self):
|
||
# Regression test for the worker_scaling-scale workload that
|
||
# motivated proper percentile tracking. KLL-backed percentiles
|
||
# land close to the true distribution and stay monotonic.
|
||
t = Timer()
|
||
for v in [8.0, 9.0, 10.0, 11.0, 12.0] * 16: # 80 in 8-12s
|
||
t.add(v)
|
||
for v in [15.0, 18.0, 22.0, 28.0] * 4: # 16 in 15-28s
|
||
t.add(v)
|
||
for v in [35.0, 37.0]:
|
||
t.add(v)
|
||
max_v = t.max()
|
||
p50 = t.percentile(0.5)
|
||
p90 = t.percentile(0.9)
|
||
assert 0 < p50 <= p90 <= max_v
|
||
# p50 in the bulk (8-12s); p90 well into the tail.
|
||
assert 8.0 <= p50 <= 12.0
|
||
assert p90 >= 15.0
|
||
|
||
@pytest.mark.parametrize("bad_p", [-0.1, 1.1, 90, -1.0, 2.0])
|
||
def test_rejects_out_of_range_p(self, bad_p):
|
||
# Catch the common ``percentile(90)`` typo (instead of 0.9).
|
||
t = Timer()
|
||
t.add(0.005)
|
||
with pytest.raises(ValueError, match="p must be in"):
|
||
t.percentile(bad_p)
|
||
|
||
@pytest.mark.parametrize("ok_p", [0.0, 1.0])
|
||
def test_accepts_boundary_p(self, ok_p):
|
||
t = Timer()
|
||
t.add(0.005)
|
||
# Should not raise.
|
||
t.percentile(ok_p)
|
||
|
||
def test_cloudpickle_roundtrip(self):
|
||
# Regression: Timer is embedded in DatasetStats, which is
|
||
# cloudpickled when Datasets cross actor / process boundaries.
|
||
# The KLL sketch under DistributionTracker is C++-backed; an
|
||
# earlier version of this PR broke ``cloudpickle.dumps(ds)``
|
||
# with ``cannot pickle 'kll_doubles_sketch' object``.
|
||
import cloudpickle
|
||
|
||
t = Timer()
|
||
for v in [0.001, 0.01, 0.1, 1.0]:
|
||
t.add(v)
|
||
t2 = cloudpickle.loads(cloudpickle.dumps(t))
|
||
assert t2.get() == pytest.approx(t.get())
|
||
assert t2.max() == pytest.approx(t.max())
|
||
assert t2.avg() == pytest.approx(t.avg())
|
||
# Percentiles must survive the round-trip.
|
||
assert t2.percentile(0.5) == pytest.approx(t.percentile(0.5))
|
||
assert t2.percentile(0.9) == pytest.approx(t.percentile(0.9))
|
||
|
||
def test_as_dict_is_json_serializable(self):
|
||
# Regression: Timer.__dict__ holds a DistributionTracker (not
|
||
# JSON-serializable) since percentile tracking was added. Code
|
||
# that persists Timer stats to JSON (e.g. the training-ingest
|
||
# benchmark checkpointing metrics.json) must use as_dict(), which
|
||
# exposes only the scalar fields.
|
||
import json
|
||
|
||
t = Timer()
|
||
for v in [0.001, 0.01, 0.1, 1.0]:
|
||
t.add(v)
|
||
d = t.as_dict()
|
||
assert "_distribution" not in d
|
||
# Must not raise ``Object of type DistributionTracker is not JSON
|
||
# serializable``.
|
||
json.loads(json.dumps(d))
|
||
|
||
def test_as_dict_from_dict_roundtrip(self):
|
||
t = Timer()
|
||
for v in [0.001, 0.01, 0.1, 1.0]:
|
||
t.add(v)
|
||
|
||
restored = Timer()
|
||
restored.from_dict(t.as_dict())
|
||
assert restored.get() == pytest.approx(t.get())
|
||
assert restored.min() == pytest.approx(t.min())
|
||
assert restored.max() == pytest.approx(t.max())
|
||
assert restored.avg() == pytest.approx(t.avg())
|
||
|
||
def test_as_dict_from_dict_empty(self):
|
||
# An untouched Timer reports min/max as None (inf is not
|
||
# JSON-representable) and restores back to the empty sentinels.
|
||
t = Timer()
|
||
d = t.as_dict()
|
||
assert d["_min"] is None and d["_max"] is None
|
||
assert d["_total"] == 0 and d["_total_count"] == 0
|
||
|
||
restored = Timer()
|
||
restored.from_dict(d)
|
||
assert restored.min() == float("inf")
|
||
assert restored.get() == 0
|
||
|
||
@pytest.mark.parametrize("bad_state", [None, [], "x", 42])
|
||
def test_from_dict_ignores_non_dict(self, bad_state):
|
||
# A malformed/missing checkpoint payload must not crash restore;
|
||
# the Timer keeps its empty-state defaults.
|
||
t = Timer()
|
||
t.from_dict(bad_state)
|
||
assert t.get() == 0
|
||
assert t.min() == float("inf")
|
||
|
||
def test_from_dict_handles_none_values(self):
|
||
# Explicit None values must fall back to defaults — .get(k, 0)
|
||
# would wrongly keep None since the key is present.
|
||
t = Timer()
|
||
t.from_dict({"_total": None, "_min": None, "_max": None, "_total_count": None})
|
||
assert t.get() == 0.0
|
||
assert t._total_count == 0.0
|
||
assert t.min() == float("inf")
|
||
assert t.max() == 0.0
|
||
|
||
|
||
class TestTimeSpan:
|
||
"""Tests for TimeSpan dataclass."""
|
||
|
||
def test_default_values(self):
|
||
"""Default TimeSpan has start_s=0 and end_s=0."""
|
||
t = TimeSpan()
|
||
assert t.start_s == 0.0
|
||
assert t.end_s == 0.0
|
||
|
||
def test_duration(self):
|
||
"""Duration is end_s - start_s."""
|
||
t = TimeSpan(start_s=1.0, end_s=3.5)
|
||
assert t.duration == pytest.approx(2.5)
|
||
|
||
def test_zero_duration(self):
|
||
"""Default TimeSpan has zero duration."""
|
||
t = TimeSpan()
|
||
assert t.duration == 0.0
|
||
|
||
|
||
class TestTimerSpan:
|
||
"""Tests for Timer.timer() returning a TimeSpan and accumulating."""
|
||
|
||
def test_timer_yields_timespan(self, monkeypatch):
|
||
"""timer() yields a fresh TimeSpan whose duration is accumulated."""
|
||
perf = [0.0]
|
||
monkeypatch.setattr("time.perf_counter", lambda: perf[0])
|
||
|
||
t = Timer()
|
||
perf[0] = 1.0
|
||
with t.timer() as span:
|
||
perf[0] = 1.5
|
||
assert isinstance(span, TimeSpan)
|
||
assert span.duration == 0.5
|
||
assert t.get() == 0.5
|
||
assert t.max() == 0.5
|
||
assert t.min() == 0.5
|
||
|
||
def test_each_call_returns_fresh_span(self, monkeypatch):
|
||
"""Each timer() call yields a distinct TimeSpan instance."""
|
||
perf = [0.0]
|
||
monkeypatch.setattr("time.perf_counter", lambda: perf[0])
|
||
|
||
t = Timer()
|
||
perf[0] = 1.0
|
||
with t.timer() as s1:
|
||
perf[0] = 2.0
|
||
perf[0] = 10.0
|
||
with t.timer() as s2:
|
||
perf[0] = 12.0
|
||
assert s1 is not s2
|
||
assert s1.duration == 1.0
|
||
assert s2.duration == 2.0
|
||
assert t.get() == 3.0
|
||
|
||
def test_maybe_time_skips_when_timer_none(self):
|
||
"""_maybe_time(None) yields None."""
|
||
with _maybe_time(None) as span:
|
||
assert span is None
|
||
assert span is None
|
||
|
||
def test_maybe_time_yields_span_when_timer_given(self, monkeypatch):
|
||
"""_maybe_time(Timer) yields a TimeSpan backed by the Timer."""
|
||
perf = [0.0]
|
||
monkeypatch.setattr("time.perf_counter", lambda: perf[0])
|
||
|
||
t = Timer()
|
||
perf[0] = 1.0
|
||
with _maybe_time(t) as span:
|
||
perf[0] = 1.5
|
||
assert isinstance(span, TimeSpan)
|
||
assert span.duration == 0.5
|
||
assert t.get() == 0.5
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"stage,attr",
|
||
[
|
||
(IterationStage.PRODUCTION_WAIT, "iter_blocked_production_wait_s"),
|
||
(IterationStage.DATA_TRANSFER, "iter_blocked_data_transfer_s"),
|
||
(IterationStage.BATCHING, "iter_blocked_batching_s"),
|
||
(IterationStage.FORMAT, "iter_blocked_format_s"),
|
||
(IterationStage.COLLATE, "iter_blocked_collate_s"),
|
||
(IterationStage.FINALIZE, "iter_blocked_finalize_s"),
|
||
],
|
||
)
|
||
class TestGetBlockedTimer:
|
||
"""Tests for DatasetStats.get_blocked_timer() stage->Timer mapping."""
|
||
|
||
def test_get_blocked_timer_returns_correct_attribute(self, stage, attr):
|
||
"""get_blocked_timer(stage) returns the Timer matching the stage."""
|
||
stats = DatasetStats(metadata={}, parent=None)
|
||
assert stats.get_blocked_timer(stage) is getattr(stats, attr)
|
||
|
||
def test_get_blocked_timer_returns_timer_instance(self, stage, attr):
|
||
"""get_blocked_timer returns a real Timer (not None)."""
|
||
stats = DatasetStats(metadata={}, parent=None)
|
||
assert isinstance(stats.get_blocked_timer(stage), Timer)
|
||
|
||
|
||
def test_streaming_exec_schedule_percentiles_populated(ray_start_regular_shared):
|
||
# KLL-sketch percentile tracking is always on (bounded memory), so
|
||
# the percentile fields are populated end-to-end with no env-var
|
||
# gating. ``Dataset.materialize`` runs the streaming executor on a
|
||
# deep copy of the dataset, so stats land on the
|
||
# ``MaterializedDataset`` it returns — read stats from there.
|
||
mds = ray.data.range(100).map(lambda r: r).materialize()
|
||
summary = mds.get_stats_summary(detail=True)
|
||
p50 = summary.streaming_exec_schedule_p50_s
|
||
p90 = summary.streaming_exec_schedule_p90_s
|
||
schedule_max = summary.streaming_exec_schedule_max_s
|
||
# Percentiles are populated, monotonic, and bounded by max.
|
||
assert p90 > 0
|
||
assert 0 <= p50 <= p90 <= schedule_max
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
|
||
sys.exit(pytest.main(["-vv", __file__]))
|