495 lines
18 KiB
Python
495 lines
18 KiB
Python
import time
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pyarrow as pa
|
|
import pytest
|
|
|
|
import ray
|
|
from ray.data._internal.execution.interfaces import BlockEntry, RefBundle
|
|
from ray.data._internal.execution.interfaces.op_runtime_metrics import (
|
|
OpRuntimeMetrics,
|
|
)
|
|
from ray.data._internal.execution.interfaces.physical_operator import (
|
|
TaskExecDriverStats,
|
|
)
|
|
from ray.data._internal.util import KiB
|
|
from ray.data.block import BlockExecStats, BlockMetadata, TaskExecWorkerStats
|
|
from ray.data.context import DataContext
|
|
|
|
|
|
def test_average_max_uss_per_task():
|
|
op = MagicMock()
|
|
op.data_context.enable_get_object_locations_for_metrics = False
|
|
metrics = OpRuntimeMetrics(op)
|
|
assert metrics.average_max_uss_per_task is None
|
|
|
|
input_bundle = RefBundle([], owns_blocks=False, schema=None)
|
|
|
|
# Submit and finish first task with USS of 100 bytes.
|
|
metrics.on_task_submitted(0, input_bundle)
|
|
metrics.on_task_finished(
|
|
0,
|
|
None,
|
|
TaskExecWorkerStats(task_wall_time_s=1.0, max_uss_bytes=100),
|
|
TaskExecDriverStats(task_output_backpressure_s=0),
|
|
)
|
|
assert metrics.average_max_uss_per_task == 100
|
|
|
|
# Submit and finish second task with USS of 300 bytes.
|
|
metrics.on_task_submitted(1, input_bundle)
|
|
metrics.on_task_finished(
|
|
1,
|
|
None,
|
|
TaskExecWorkerStats(task_wall_time_s=1.0, max_uss_bytes=300),
|
|
TaskExecDriverStats(task_output_backpressure_s=0),
|
|
)
|
|
assert metrics.average_max_uss_per_task == 200 # (100 + 300) / 2
|
|
|
|
|
|
def test_task_completion_time_histogram():
|
|
"""Test task completion time histogram bucket assignment and counting."""
|
|
op = MagicMock()
|
|
op.data_context.enable_get_object_locations_for_metrics = False
|
|
metrics = OpRuntimeMetrics(op)
|
|
|
|
# Test different completion times
|
|
# Buckets: [0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 7.5, 10.0, 15.0, 20.0, 25.0, 50.0, 75.0, 100.0, 150.0, 500.0, 1000.0, 2500.0, 5000.0]
|
|
test_cases = [
|
|
(0.05, 0), # Very fast task (0.05s) - should go to first bucket (0.1)
|
|
(0.2, 1), # Fast task (0.2s) - should go to second bucket (0.25)
|
|
(0.6, 3), # Medium task (0.6s) - should go to fourth bucket (1.0)
|
|
(1.5, 4), # Slower task (1.5s) - should go to fifth bucket (2.5)
|
|
(3.0, 5), # Slow task (3.0s) - should go to sixth bucket (5.0)
|
|
]
|
|
|
|
for i, (completion_time, expected_bucket) in enumerate(test_cases):
|
|
# Create input bundle
|
|
input_bundle = RefBundle([], owns_blocks=False, schema=None)
|
|
|
|
# Submit task (this will create the RunningTaskInfo with current time)
|
|
metrics.on_task_submitted(i, input_bundle)
|
|
|
|
# Manually adjust the start time to simulate the completion time
|
|
metrics._running_tasks[i].start_time = time.perf_counter() - completion_time
|
|
|
|
# Complete the task
|
|
metrics.on_task_finished(
|
|
i,
|
|
None,
|
|
TaskExecWorkerStats(task_wall_time_s=completion_time),
|
|
TaskExecDriverStats(task_output_backpressure_s=0),
|
|
)
|
|
|
|
# Check that the correct bucket was incremented
|
|
assert metrics.task_completion_time._bucket_counts[expected_bucket] == 1
|
|
|
|
# Reset for next test
|
|
metrics.task_completion_time._bucket_counts[expected_bucket] = 0
|
|
|
|
|
|
def test_block_completion_time_histogram():
|
|
"""Test block completion time histogram bucket assignment and counting.
|
|
|
|
Block completion time = (cum_block_gen_time_s + cum_block_ser_time_s) / num_outputs
|
|
"""
|
|
op = MagicMock()
|
|
op.data_context.enable_get_object_locations_for_metrics = False
|
|
metrics = OpRuntimeMetrics(op)
|
|
|
|
# Test different block generation scenarios
|
|
# Buckets: [0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 7.5, 10.0, 15.0, 20.0, 25.0, 50.0, 75.0, 100.0, 150.0, 500.0, 1000.0, 2500.0, 5000.0]
|
|
# Each test case: (num_blocks, gen_time, ser_time, expected_bucket)
|
|
# Per-block time = (gen_time + ser_time) / num_blocks
|
|
test_cases = [
|
|
# 1 block, 0.08s gen + 0.02s ser = 0.1s total -> 0.1s per block -> bucket 0 (0.1)
|
|
(1, 0.08, 0.02, 0),
|
|
# 2 blocks, 0.4s gen + 0.1s ser = 0.5s total -> 0.25s per block -> bucket 1 (0.25)
|
|
(2, 0.4, 0.1, 1),
|
|
# 1 block, 0.5s gen + 0.1s ser = 0.6s total -> 0.6s per block -> bucket 3 (1.0)
|
|
(1, 0.5, 0.1, 3),
|
|
# 3 blocks, 1.2s gen + 0.3s ser = 1.5s total -> 0.5s per block -> bucket 2 (0.5)
|
|
(3, 1.2, 0.3, 2),
|
|
]
|
|
|
|
for i, (num_blocks, gen_time, ser_time, expected_bucket) in enumerate(test_cases):
|
|
# Create input bundle
|
|
input_bundle = RefBundle([], owns_blocks=False, schema=None)
|
|
|
|
# Submit task
|
|
metrics.on_task_submitted(i, input_bundle)
|
|
|
|
# Manually set the task info to simulate the block generation
|
|
metrics._running_tasks[i].num_outputs = num_blocks
|
|
metrics._running_tasks[i].cum_block_gen_time_s = gen_time
|
|
metrics._running_tasks[i].cum_block_ser_time_s = ser_time
|
|
|
|
# Complete the task
|
|
metrics.on_task_finished(
|
|
i,
|
|
None,
|
|
TaskExecWorkerStats(task_wall_time_s=gen_time + ser_time),
|
|
TaskExecDriverStats(task_output_backpressure_s=0),
|
|
)
|
|
|
|
# Check that the correct bucket was incremented by the number of blocks
|
|
assert (
|
|
metrics.block_completion_time._bucket_counts[expected_bucket] == num_blocks
|
|
)
|
|
|
|
# Reset for next test
|
|
metrics.block_completion_time._bucket_counts[expected_bucket] = 0
|
|
|
|
|
|
@patch("time.perf_counter")
|
|
def test_task_completion_time_excl_backpressure(mock_perf_counter):
|
|
"""Test that average_task_completion_time_excl_backpressure_s correctly
|
|
subtracts output backpressure from the driver's wall-clock task time.
|
|
|
|
Scheduling time is estimated as the time from task submission to the first
|
|
output arriving on the driver, minus the worker-side time to generate and
|
|
serialize that first block.
|
|
"""
|
|
op = MagicMock()
|
|
op.data_context.enable_get_object_locations_for_metrics = False
|
|
|
|
metrics = OpRuntimeMetrics(op)
|
|
|
|
test_cases = [
|
|
# (driver_wall_time_s, scheduling_time_s, backpressure_time_s, gen_time_s, ser_time_s, num_outputs)
|
|
(2.0, 0.2, 0.5, 0.25, 0.05, 2), # Task 0
|
|
(1.5, 0.2, 0.2, 0.3, 0.05, 1), # Task 1
|
|
(3.0, 0.2, 1.0, 0.3, 0.05, 3), # Task 2
|
|
]
|
|
|
|
def create_output_bundle(gen_time_s, ser_time_s):
|
|
block = ray.put(pa.Table.from_pydict({}))
|
|
stats = BlockExecStats(
|
|
wall_time_s=gen_time_s,
|
|
block_ser_time_s=ser_time_s,
|
|
)
|
|
metadata = BlockMetadata(
|
|
num_rows=1,
|
|
size_bytes=0,
|
|
input_files=None,
|
|
exec_stats=stats,
|
|
)
|
|
return RefBundle([BlockEntry(block, metadata)], owns_blocks=False, schema=None)
|
|
|
|
total_gen_ser = 0
|
|
cumulative_scheduling_time_s = 0.0
|
|
clock = 0.0
|
|
for i, tc in enumerate(test_cases):
|
|
(
|
|
driver_wall_time_s,
|
|
scheduling_time_s,
|
|
output_bp_time_s,
|
|
gen_time_s,
|
|
ser_time_s,
|
|
num_outputs,
|
|
) = tc
|
|
|
|
input_bundle = RefBundle([], owns_blocks=False, schema=None)
|
|
|
|
# Freeze time at task submission
|
|
submit_time = clock
|
|
mock_perf_counter.return_value = clock
|
|
metrics.on_task_submitted(i, input_bundle)
|
|
|
|
# Advance clock to first output arrival on driver:
|
|
# time_to_first_block = scheduling + gen + ser
|
|
clock = submit_time + scheduling_time_s + gen_time_s + ser_time_s
|
|
mock_perf_counter.return_value = clock
|
|
metrics.on_task_output_generated(
|
|
i, create_output_bundle(gen_time_s, ser_time_s)
|
|
)
|
|
|
|
# Verify that average_task_scheduling_time_s is correct *before* the
|
|
# task finishes. The numerator (task_scheduling_time_s) is incremented
|
|
# on first output, so the denominator must be num_tasks_have_outputs
|
|
# (not num_tasks_finished) for the average to be accurate mid-flight.
|
|
cumulative_scheduling_time_s += scheduling_time_s
|
|
num_tasks_with_output = i + 1
|
|
assert metrics.average_task_scheduling_time_s == pytest.approx(
|
|
cumulative_scheduling_time_s / num_tasks_with_output
|
|
)
|
|
|
|
# Generate remaining outputs (won't affect scheduling time)
|
|
for _ in range(num_outputs - 1):
|
|
clock += gen_time_s + ser_time_s
|
|
mock_perf_counter.return_value = clock
|
|
metrics.on_task_output_generated(
|
|
i, create_output_bundle(gen_time_s, ser_time_s)
|
|
)
|
|
|
|
total_gen_ser += num_outputs * (gen_time_s + ser_time_s)
|
|
|
|
# Advance clock to task finish
|
|
clock = submit_time + driver_wall_time_s
|
|
mock_perf_counter.return_value = clock
|
|
|
|
metrics.on_task_finished(
|
|
i,
|
|
None,
|
|
TaskExecWorkerStats(
|
|
task_wall_time_s=driver_wall_time_s - scheduling_time_s
|
|
),
|
|
TaskExecDriverStats(task_output_backpressure_s=output_bp_time_s),
|
|
)
|
|
|
|
num_tasks = len(test_cases)
|
|
|
|
total_driver_wall_time_s = sum(t[0] for t in test_cases)
|
|
total_scheduling_time_s = sum(t[1] for t in test_cases)
|
|
total_output_bp_time_s = sum(t[2] for t in test_cases)
|
|
|
|
total_worker_wall_time_s = sum(
|
|
t[0] - t[1] for t in test_cases # driver_wall_time_s - scheduling_time_s
|
|
)
|
|
|
|
# Raw counters
|
|
assert metrics.task_block_gen_and_ser_time_s == pytest.approx(total_gen_ser)
|
|
assert metrics.task_completion_time_s == pytest.approx(total_driver_wall_time_s)
|
|
assert metrics.task_worker_completion_time_s == pytest.approx(
|
|
total_worker_wall_time_s
|
|
)
|
|
assert metrics.task_scheduling_time_s == pytest.approx(total_scheduling_time_s)
|
|
assert metrics.task_output_backpressure_time_s == pytest.approx(
|
|
total_output_bp_time_s
|
|
)
|
|
|
|
# Derived averages
|
|
assert metrics.average_total_task_completion_time_s == pytest.approx(
|
|
total_driver_wall_time_s / num_tasks
|
|
)
|
|
assert metrics.average_task_scheduling_time_s == pytest.approx(
|
|
total_scheduling_time_s / num_tasks # all tasks produced output
|
|
)
|
|
assert metrics.average_task_output_backpressure_time_s == pytest.approx(
|
|
total_output_bp_time_s / num_tasks
|
|
)
|
|
assert metrics.average_task_completion_time_excl_backpressure_s == pytest.approx(
|
|
(total_driver_wall_time_s - total_output_bp_time_s) / num_tasks
|
|
)
|
|
|
|
|
|
def test_block_size_bytes_histogram():
|
|
"""Test block size bytes histogram bucket assignment and counting."""
|
|
op = MagicMock()
|
|
op.data_context.enable_get_object_locations_for_metrics = False
|
|
metrics = OpRuntimeMetrics(op)
|
|
|
|
def create_bundle_with_size(size_bytes):
|
|
block = ray.put(pa.Table.from_pydict({}))
|
|
stats = BlockExecStats(
|
|
wall_time_s=0,
|
|
block_ser_time_s=0,
|
|
)
|
|
metadata = BlockMetadata(
|
|
num_rows=0,
|
|
size_bytes=size_bytes,
|
|
input_files=None,
|
|
exec_stats=stats,
|
|
)
|
|
return RefBundle([BlockEntry(block, metadata)], owns_blocks=False, schema=None)
|
|
|
|
# Test different block sizes
|
|
# Buckets: [1KB, 8KB, 64KB, 128KB, 256KB, 512KB, 1MB, 8MB, 64MB, 128MB, 256MB, 512MB, 1GB, 4GB, 16GB, 64GB, 128GB, 256GB, 512GB, 1024GB, 4096GB]
|
|
test_cases = [
|
|
(512, 0), # 512 bytes -> first bucket (1KB)
|
|
(2 * KiB, 1), # 2 KiB -> second bucket (8KB)
|
|
(32 * KiB, 2), # 32 KiB -> third bucket (64KB)
|
|
(100 * KiB, 3), # 100 KiB -> fourth bucket (128KB)
|
|
(500 * KiB, 5), # 500 KiB -> sixth bucket (512KB)
|
|
]
|
|
|
|
for i, (size_bytes, expected_bucket) in enumerate(test_cases):
|
|
# Create input bundle (can be empty for this test)
|
|
input_bundle = RefBundle([], owns_blocks=False, schema=None)
|
|
|
|
# Submit task
|
|
metrics.on_task_submitted(i, input_bundle)
|
|
|
|
# Create output bundle with the size we want to test
|
|
output_bundle = create_bundle_with_size(size_bytes)
|
|
|
|
# Generate output
|
|
metrics.on_task_output_generated(i, output_bundle)
|
|
|
|
# Check that the correct bucket was incremented
|
|
assert metrics.block_size_bytes._bucket_counts[expected_bucket] == 1
|
|
|
|
# Reset for next test
|
|
metrics.block_size_bytes._bucket_counts[expected_bucket] = 0
|
|
|
|
|
|
def test_block_size_rows_histogram():
|
|
"""Test block size rows histogram bucket assignment and counting."""
|
|
op = MagicMock()
|
|
op.data_context.enable_get_object_locations_for_metrics = False
|
|
metrics = OpRuntimeMetrics(op)
|
|
|
|
def create_bundle_with_rows(num_rows):
|
|
block = ray.put(pa.Table.from_pydict({}))
|
|
stats = BlockExecStats(
|
|
wall_time_s=0,
|
|
block_ser_time_s=0,
|
|
)
|
|
metadata = BlockMetadata(
|
|
num_rows=num_rows,
|
|
size_bytes=0,
|
|
input_files=None,
|
|
exec_stats=stats,
|
|
)
|
|
return RefBundle([BlockEntry(block, metadata)], owns_blocks=False, schema=None)
|
|
|
|
# Test different row counts
|
|
# Buckets: [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 25000, 50000, 100000, 250000, 500000, 1000000, 2500000, 5000000, 10000000]
|
|
test_cases = [
|
|
(1, 0), # 1 row -> first bucket (1)
|
|
(3, 1), # 3 rows -> second bucket (5)
|
|
(7, 2), # 7 rows -> third bucket (10)
|
|
(15, 3), # 15 rows -> fourth bucket (25)
|
|
(30, 4), # 30 rows -> fifth bucket (50)
|
|
(75, 5), # 75 rows -> sixth bucket (100)
|
|
]
|
|
|
|
for i, (num_rows, expected_bucket) in enumerate(test_cases):
|
|
# Create input bundle (can be empty for this test)
|
|
input_bundle = RefBundle([], owns_blocks=False, schema=None)
|
|
|
|
# Submit task
|
|
metrics.on_task_submitted(i, input_bundle)
|
|
|
|
# Create output bundle with the row count we want to test
|
|
output_bundle = create_bundle_with_rows(num_rows)
|
|
|
|
# Generate output
|
|
metrics.on_task_output_generated(i, output_bundle)
|
|
|
|
# Check that the correct bucket was incremented
|
|
assert metrics.block_size_rows._bucket_counts[expected_bucket] == 1
|
|
|
|
# Reset for next test
|
|
metrics.block_size_rows._bucket_counts[expected_bucket] = 0
|
|
|
|
|
|
@pytest.fixture
|
|
def metrics_config_no_sample_with_target(restore_data_context): # noqa: F811
|
|
"""Fixture for no-sample scenario with target_max_block_size set."""
|
|
ctx = DataContext.get_current()
|
|
ctx.target_max_block_size = 128 * 1024 * 1024 # 128MB
|
|
ctx._max_num_blocks_in_streaming_gen_buffer = 2
|
|
|
|
op = MagicMock()
|
|
op.data_context = ctx
|
|
metrics = OpRuntimeMetrics(op)
|
|
return metrics
|
|
|
|
|
|
@pytest.fixture
|
|
def metrics_config_no_sample_with_none(restore_data_context): # noqa: F811
|
|
"""Fixture for no-sample scenario with target_max_block_size=None."""
|
|
ctx = DataContext.get_current()
|
|
ctx.target_max_block_size = None
|
|
ctx._max_num_blocks_in_streaming_gen_buffer = 1
|
|
|
|
op = MagicMock()
|
|
op.data_context = ctx
|
|
metrics = OpRuntimeMetrics(op)
|
|
return metrics
|
|
|
|
|
|
@pytest.fixture
|
|
def metrics_config_with_sample(restore_data_context): # noqa: F811
|
|
"""Fixture for scenario with average_bytes_per_output available."""
|
|
ctx = DataContext.get_current()
|
|
ctx.target_max_block_size = 128 * 1024 * 1024 # 128MB
|
|
ctx._max_num_blocks_in_streaming_gen_buffer = 1
|
|
|
|
op = MagicMock()
|
|
op.data_context = ctx
|
|
metrics = OpRuntimeMetrics(op)
|
|
|
|
# Simulate having samples: set bytes_task_outputs_generated and
|
|
# num_task_outputs_generated to make average_bytes_per_output available
|
|
actual_block_size = 150 * 1024 * 1024 # 150MB
|
|
metrics.bytes_task_outputs_generated = actual_block_size
|
|
metrics.num_task_outputs_generated = 1
|
|
|
|
return metrics
|
|
|
|
|
|
@pytest.fixture
|
|
def metrics_config_pending_outputs_no_sample(
|
|
restore_data_context, # noqa: F811
|
|
):
|
|
"""Fixture for pending outputs during no-sample with target set."""
|
|
ctx = DataContext.get_current()
|
|
ctx.target_max_block_size = 64 * 1024 * 1024 # 64MB
|
|
ctx._max_num_blocks_in_streaming_gen_buffer = 2
|
|
|
|
op = MagicMock()
|
|
op.data_context = ctx
|
|
metrics = OpRuntimeMetrics(op)
|
|
metrics.num_tasks_running = 3
|
|
return metrics
|
|
|
|
|
|
@pytest.fixture
|
|
def metrics_config_pending_outputs_none(restore_data_context): # noqa: F811
|
|
"""Fixture for pending outputs during no-sample with target=None."""
|
|
ctx = DataContext.get_current()
|
|
ctx.target_max_block_size = None
|
|
ctx._max_num_blocks_in_streaming_gen_buffer = 1
|
|
|
|
op = MagicMock()
|
|
op.data_context = ctx
|
|
metrics = OpRuntimeMetrics(op)
|
|
metrics.num_tasks_running = 2
|
|
return metrics
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"metrics_fixture,test_property,expected_calculator",
|
|
[
|
|
# When no sample is available, returns None
|
|
(
|
|
"metrics_config_no_sample_with_target",
|
|
"obj_store_mem_max_pending_output_per_task",
|
|
lambda m: None,
|
|
),
|
|
# When sample is available, uses average_bytes_per_output
|
|
(
|
|
"metrics_config_with_sample",
|
|
"obj_store_mem_max_pending_output_per_task",
|
|
lambda m: (
|
|
m.average_bytes_per_output
|
|
* m._op.data_context._max_num_blocks_in_streaming_gen_buffer
|
|
),
|
|
),
|
|
# When no sample is available, returns None
|
|
(
|
|
"metrics_config_pending_outputs_no_sample",
|
|
"obj_store_mem_pending_task_outputs",
|
|
lambda m: None,
|
|
),
|
|
],
|
|
)
|
|
def test_obj_store_mem_estimation(
|
|
request, metrics_fixture, test_property, expected_calculator
|
|
):
|
|
"""Test object store memory estimation for various scenarios."""
|
|
metrics = request.getfixturevalue(metrics_fixture)
|
|
actual = getattr(metrics, test_property)
|
|
expected = expected_calculator(metrics)
|
|
|
|
assert (
|
|
actual == expected
|
|
), f"Expected {test_property} to be {expected}, got {actual}"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
sys.exit(pytest.main(["-v", __file__]))
|