1087 lines
44 KiB
Python
1087 lines
44 KiB
Python
import math
|
|
import time
|
|
from dataclasses import replace
|
|
from datetime import timedelta
|
|
from typing import Any, Dict, Optional
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from freezegun import freeze_time
|
|
|
|
import ray
|
|
from ray.data._internal.compute import ComputeStrategy
|
|
from ray.data._internal.execution.block_ref_counter import BlockRefCounter
|
|
from ray.data._internal.execution.interfaces import BlockEntry, PhysicalOperator
|
|
from ray.data._internal.execution.interfaces.execution_options import (
|
|
ExecutionOptions,
|
|
ExecutionResources,
|
|
)
|
|
from ray.data._internal.execution.interfaces.physical_operator import (
|
|
ObjectStoreUsage,
|
|
TaskExecDriverStats,
|
|
)
|
|
from ray.data._internal.execution.operators.base_physical_operator import (
|
|
AllToAllOperator,
|
|
)
|
|
from ray.data._internal.execution.operators.input_data_buffer import InputDataBuffer
|
|
from ray.data._internal.execution.operators.join import JoinOperator
|
|
from ray.data._internal.execution.operators.limit_operator import LimitOperator
|
|
from ray.data._internal.execution.operators.map_operator import MapOperator
|
|
from ray.data._internal.execution.operators.union_operator import UnionOperator
|
|
from ray.data._internal.execution.resource_manager import (
|
|
ResourceManager,
|
|
create_resource_allocator,
|
|
)
|
|
from ray.data._internal.execution.streaming_executor_state import (
|
|
IdleDetector,
|
|
OutputBackpressureGuard,
|
|
build_streaming_topology,
|
|
)
|
|
from ray.data._internal.execution.util import make_ref_bundles
|
|
from ray.data.block import TaskExecWorkerStats
|
|
from ray.data.context import DataContext
|
|
from ray.data.tests.conftest import * # noqa
|
|
from ray.data.tests.conftest import noop_counter
|
|
|
|
|
|
def mock_map_op(
|
|
input_op: PhysicalOperator,
|
|
ray_remote_args: Optional[Dict[str, Any]] = None,
|
|
compute_strategy: Optional[ComputeStrategy] = None,
|
|
name="Map",
|
|
):
|
|
op = MapOperator.create(
|
|
MagicMock(),
|
|
input_op,
|
|
DataContext.get_current(),
|
|
ray_remote_args=ray_remote_args or {},
|
|
compute_strategy=compute_strategy,
|
|
name=name,
|
|
)
|
|
return op
|
|
|
|
|
|
def mock_union_op(input_ops):
|
|
op = UnionOperator(
|
|
DataContext.get_current(),
|
|
*input_ops,
|
|
)
|
|
op.start = MagicMock(side_effect=lambda *_: None)
|
|
return op
|
|
|
|
|
|
def mock_join_op(left_input_op, right_input_op):
|
|
left_input_op._logical_operators = [MagicMock()]
|
|
right_input_op._logical_operators = [MagicMock()]
|
|
|
|
with patch(
|
|
"ray.data._internal.execution.operators.hash_shuffle._get_total_cluster_resources"
|
|
) as mock:
|
|
mock.return_value = ExecutionResources(cpu=1)
|
|
|
|
op = JoinOperator(
|
|
DataContext.get_current(),
|
|
left_input_op,
|
|
right_input_op,
|
|
("id",),
|
|
("id",),
|
|
"inner",
|
|
num_partitions=1,
|
|
partition_size_hint=1,
|
|
)
|
|
|
|
op.start = MagicMock(side_effect=lambda *_: None)
|
|
return op
|
|
|
|
|
|
def mock_all_to_all_op(input_op, name="MockShuffle"):
|
|
"""Create a mock AllToAllOperator (shuffle) for testing."""
|
|
op = AllToAllOperator(
|
|
bulk_fn=MagicMock(),
|
|
input_op=input_op,
|
|
data_context=DataContext.get_current(),
|
|
name=name,
|
|
)
|
|
return op
|
|
|
|
|
|
def _resource_manager_for_limits_only_test(
|
|
options: ExecutionOptions,
|
|
get_total_resources,
|
|
):
|
|
"""``ResourceManager`` requires a valid single-sink topology; these tests only
|
|
call ``get_global_limits()`` and never iterate real operators."""
|
|
sink = MagicMock(spec=PhysicalOperator)
|
|
sink.output_dependencies = []
|
|
topology = {sink: MagicMock()}
|
|
return ResourceManager(
|
|
topology,
|
|
options,
|
|
get_total_resources,
|
|
DataContext.get_current(),
|
|
BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True),
|
|
)
|
|
|
|
|
|
class TestResourceManager:
|
|
"""Unit tests for ResourceManager."""
|
|
|
|
def test_global_limits(self):
|
|
cluster_resources = {"CPU": 10, "GPU": 5, "object_store_memory": 1000}
|
|
default_object_store_memory_limit = math.ceil(
|
|
cluster_resources["object_store_memory"]
|
|
* ResourceManager.DEFAULT_OBJECT_STORE_MEMORY_LIMIT_FRACTION
|
|
)
|
|
|
|
def get_total_resources():
|
|
return ExecutionResources.from_resource_dict(cluster_resources)
|
|
|
|
# Test default resource limits.
|
|
# When no resource limits are set, the resource limits should default to
|
|
# the cluster resources for CPU/GPU, and
|
|
# DEFAULT_OBJECT_STORE_MEMORY_LIMIT_FRACTION of cluster object store memory.
|
|
options = ExecutionOptions()
|
|
resource_manager = _resource_manager_for_limits_only_test(
|
|
options, get_total_resources
|
|
)
|
|
expected = ExecutionResources(
|
|
cpu=cluster_resources["CPU"],
|
|
gpu=cluster_resources["GPU"],
|
|
object_store_memory=default_object_store_memory_limit,
|
|
)
|
|
assert resource_manager.get_global_limits() == expected
|
|
|
|
# Test setting resource_limits
|
|
options = ExecutionOptions()
|
|
options.resource_limits = ExecutionResources(
|
|
cpu=1, gpu=2, object_store_memory=100
|
|
)
|
|
resource_manager = _resource_manager_for_limits_only_test(
|
|
options, get_total_resources
|
|
)
|
|
expected = ExecutionResources(
|
|
cpu=1,
|
|
gpu=2,
|
|
object_store_memory=100,
|
|
)
|
|
assert resource_manager.get_global_limits() == expected
|
|
|
|
# Test setting exclude_resources
|
|
# The actual limit should be the default limit minus the excluded resources.
|
|
options = ExecutionOptions()
|
|
options.exclude_resources = ExecutionResources(
|
|
cpu=1, gpu=2, object_store_memory=100
|
|
)
|
|
resource_manager = _resource_manager_for_limits_only_test(
|
|
options, get_total_resources
|
|
)
|
|
expected = ExecutionResources(
|
|
cpu=cluster_resources["CPU"] - 1,
|
|
gpu=cluster_resources["GPU"] - 2,
|
|
object_store_memory=default_object_store_memory_limit - 100,
|
|
)
|
|
assert resource_manager.get_global_limits() == expected
|
|
|
|
# Test that we don't support setting both resource_limits
|
|
# and exclude_resources.
|
|
with pytest.raises(ValueError):
|
|
options = ExecutionOptions()
|
|
options.resource_limits = ExecutionResources(cpu=2)
|
|
options.exclude_resources = ExecutionResources(cpu=1)
|
|
options.validate()
|
|
|
|
def test_global_limits_cache(self):
|
|
get_total_resources = MagicMock(return_value=ExecutionResources(4, 1, 0))
|
|
|
|
cache_interval_s = 0.1
|
|
with patch.object(
|
|
ResourceManager,
|
|
"GLOBAL_LIMITS_UPDATE_INTERVAL_S",
|
|
cache_interval_s,
|
|
):
|
|
resource_manager = _resource_manager_for_limits_only_test(
|
|
ExecutionOptions(),
|
|
get_total_resources,
|
|
)
|
|
expected_resource = ExecutionResources(4, 1, 0)
|
|
# The first call should call ray.cluster_resources().
|
|
assert resource_manager.get_global_limits() == expected_resource
|
|
assert get_total_resources.call_count == 1
|
|
# The second call should return the cached value.
|
|
assert resource_manager.get_global_limits() == expected_resource
|
|
assert get_total_resources.call_count == 1
|
|
time.sleep(cache_interval_s)
|
|
# After the cache interval, the third call should call
|
|
# ray.cluster_resources() again.
|
|
assert resource_manager.get_global_limits() == expected_resource
|
|
assert get_total_resources.call_count == 2
|
|
|
|
def test_update_usage(self):
|
|
"""Test calculating op_usage."""
|
|
o1 = InputDataBuffer(DataContext.get_current(), [])
|
|
o2 = mock_map_op(o1)
|
|
o3 = mock_map_op(o2)
|
|
topo = build_streaming_topology(o3, ExecutionOptions(), noop_counter())
|
|
|
|
# Mock different metrics that contribute to the resource usage.
|
|
mock_cpu = {
|
|
o1: 0,
|
|
o2: 5,
|
|
o3: 8,
|
|
}
|
|
mock_pending_task_outputs = {
|
|
o1: 0,
|
|
o2: 100,
|
|
o3: 200,
|
|
}
|
|
mock_internal_outqueue = {
|
|
o1: 0,
|
|
o2: 300,
|
|
o3: 400,
|
|
}
|
|
mock_external_outqueue_sizes = {
|
|
o1: 100,
|
|
o2: 500,
|
|
o3: 600,
|
|
}
|
|
mock_internal_inqueue = {
|
|
o1: 0,
|
|
o2: 700,
|
|
o3: 800,
|
|
}
|
|
mock_pending_task_inputs = {
|
|
o1: 0,
|
|
o2: 900,
|
|
o3: 1000,
|
|
}
|
|
|
|
for op in [o1, o2, o3]:
|
|
op.current_logical_usage = MagicMock(
|
|
return_value=ExecutionResources(cpu=mock_cpu[op], gpu=0, memory=0)
|
|
)
|
|
op.running_logical_usage = MagicMock(
|
|
return_value=ExecutionResources(cpu=mock_cpu[op], gpu=0, memory=0)
|
|
)
|
|
op.pending_logical_usage = MagicMock(return_value=ExecutionResources.zero())
|
|
op.extra_resource_usage = MagicMock(return_value=ExecutionResources.zero())
|
|
op._metrics = MagicMock(
|
|
obj_store_mem_pending_task_outputs=mock_pending_task_outputs[op],
|
|
obj_store_mem_internal_outqueue=mock_internal_outqueue[op],
|
|
obj_store_mem_internal_inqueue=mock_internal_inqueue[op],
|
|
obj_store_mem_pending_task_inputs=mock_pending_task_inputs[op],
|
|
)
|
|
op._metrics.obj_store_mem_internal_inqueue_for_input = MagicMock(
|
|
return_value=mock_internal_inqueue[op],
|
|
)
|
|
ref_bundle = MagicMock(
|
|
size_bytes=MagicMock(return_value=mock_external_outqueue_sizes[op]),
|
|
output_split_idx=None,
|
|
)
|
|
topo[op].add_output(ref_bundle)
|
|
|
|
resource_manager = ResourceManager(
|
|
topo,
|
|
ExecutionOptions(),
|
|
MagicMock(),
|
|
DataContext.get_current(),
|
|
BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True),
|
|
)
|
|
resource_manager._op_resource_allocator = None
|
|
resource_manager.update_usages()
|
|
|
|
global_cpu = 0
|
|
global_mem = 0
|
|
for op in [o1, o2, o3]:
|
|
if op == o1:
|
|
# Resource usage of InputDataBuffer doesn't count.
|
|
expected_mem = 0
|
|
else:
|
|
expected_mem = (
|
|
mock_pending_task_outputs[op]
|
|
+ mock_internal_outqueue[op]
|
|
+ mock_external_outqueue_sizes[op]
|
|
)
|
|
for next_op in op.output_dependencies:
|
|
expected_mem += (
|
|
+mock_internal_inqueue[next_op]
|
|
+ mock_pending_task_inputs[next_op]
|
|
)
|
|
op_usage = resource_manager.get_op_usage(op)
|
|
assert op_usage.cpu == mock_cpu[op]
|
|
assert op_usage.gpu == 0
|
|
assert op_usage.object_store_memory == expected_mem
|
|
if op != o1:
|
|
# _mem_op_internal only includes pending_task_outputs
|
|
assert (
|
|
resource_manager._mem_op_internal[op]
|
|
== mock_pending_task_outputs[op]
|
|
)
|
|
assert (
|
|
resource_manager._mem_op_outputs[op]
|
|
== expected_mem - resource_manager._mem_op_internal[op]
|
|
)
|
|
global_cpu += mock_cpu[op]
|
|
global_mem += expected_mem
|
|
|
|
assert resource_manager.get_global_usage() == ExecutionResources(
|
|
global_cpu, 0, global_mem
|
|
)
|
|
|
|
def test_object_store_usage(self, restore_data_context):
|
|
input = make_ref_bundles([[x] for x in range(1)])[0]
|
|
# Set block metadata size_bytes to 1 (rather than mocking the method on the
|
|
# instance, which doesn't survive dataclasses.replace in OpBufferQueue.pop).
|
|
entry = input.blocks[0]
|
|
input = replace(
|
|
input,
|
|
blocks=[BlockEntry(entry.ref, replace(entry.metadata, size_bytes=1))],
|
|
)
|
|
|
|
o1 = InputDataBuffer(DataContext.get_current(), [input])
|
|
o2 = mock_map_op(o1)
|
|
o3 = mock_map_op(o2)
|
|
|
|
topo = build_streaming_topology(o3, ExecutionOptions(), noop_counter())
|
|
resource_manager = ResourceManager(
|
|
topo,
|
|
ExecutionOptions(),
|
|
MagicMock(return_value=ExecutionResources.zero()),
|
|
DataContext.get_current(),
|
|
BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True),
|
|
)
|
|
ray.data.DataContext.get_current()._max_num_blocks_in_streaming_gen_buffer = 1
|
|
ray.data.DataContext.get_current().target_max_block_size = 2
|
|
|
|
resource_manager.update_usages()
|
|
assert resource_manager.get_op_usage(o1).object_store_memory == 0
|
|
assert resource_manager.get_op_usage(o2).object_store_memory == 0
|
|
assert resource_manager.get_op_usage(o3).object_store_memory == 0
|
|
|
|
# Objects in an operator's internal inqueue typically count toward the previous
|
|
# operator's object store memory usage. However, data from an
|
|
# `InputDataBuffer` aren't counted because they were created outside of this
|
|
# execution.
|
|
o2.metrics.on_input_queued(input, input_index=0)
|
|
resource_manager.update_usages()
|
|
assert resource_manager.get_op_usage(o1).object_store_memory == 0
|
|
assert resource_manager.get_op_usage(o2).object_store_memory == 0
|
|
assert resource_manager.get_op_usage(o3).object_store_memory == 0
|
|
|
|
# During no-sample phase, obj_store_mem_pending_task_outputs uses fallback
|
|
# estimate based on target_max_block_size.
|
|
o2.metrics.on_input_dequeued(input, input_index=0)
|
|
o2.metrics.on_task_submitted(0, input)
|
|
resource_manager.update_usages()
|
|
assert resource_manager.get_op_usage(o1).object_store_memory == 0
|
|
# No sample available yet, returns None
|
|
assert o2.metrics.obj_store_mem_pending_task_outputs is None
|
|
op2_usage = resource_manager.get_op_usage(o2).object_store_memory
|
|
# When pending task outputs is None, it's treated as 0
|
|
assert op2_usage == 0
|
|
assert resource_manager.get_op_usage(o3).object_store_memory == 0
|
|
|
|
# When the task finishes, we move the data from the streaming generator to the
|
|
# operator's internal outqueue.
|
|
o2.metrics.on_output_queued(input)
|
|
o2.metrics.on_task_finished(
|
|
0,
|
|
None,
|
|
TaskExecWorkerStats(task_wall_time_s=0.0),
|
|
TaskExecDriverStats(task_output_backpressure_s=0),
|
|
)
|
|
resource_manager.update_usages()
|
|
assert resource_manager.get_op_usage(o1).object_store_memory == 0
|
|
assert resource_manager.get_op_usage(o2).object_store_memory == 1
|
|
assert resource_manager.get_op_usage(o3).object_store_memory == 0
|
|
|
|
o2.metrics.on_output_dequeued(input)
|
|
topo[o2].output_queue.append(input)
|
|
resource_manager.update_usages()
|
|
assert resource_manager.get_op_usage(o1).object_store_memory == 0
|
|
assert resource_manager.get_op_usage(o2).object_store_memory == 1
|
|
assert resource_manager.get_op_usage(o3).object_store_memory == 0
|
|
|
|
# Objects in the current operator's internal inqueue count towards the previous
|
|
# operator's object store memory usage.
|
|
# NOTE: `pop()` returns a copy of the bundle (via `dataclasses.replace`), so we
|
|
# must use the returned reference for subsequent o3 metric calls.
|
|
o3_input = topo[o2].output_queue.pop()
|
|
o3.metrics.on_input_queued(o3_input, input_index=0)
|
|
resource_manager.update_usages()
|
|
assert resource_manager.get_op_usage(o1).object_store_memory == 0
|
|
assert resource_manager.get_op_usage(o2).object_store_memory == 1
|
|
assert resource_manager.get_op_usage(o3).object_store_memory == 0
|
|
|
|
# Task inputs count toward the previous operator's object store memory
|
|
# usage. During no-sample phase, pending task outputs uses fallback estimate.
|
|
o3.metrics.on_input_dequeued(o3_input, input_index=0)
|
|
o3.metrics.on_task_submitted(0, o3_input)
|
|
resource_manager.update_usages()
|
|
assert resource_manager.get_op_usage(o1).object_store_memory == 0
|
|
assert resource_manager.get_op_usage(o2).object_store_memory == 1
|
|
# No sample available yet, returns None
|
|
assert o3.metrics.obj_store_mem_pending_task_outputs is None
|
|
op3_usage = resource_manager.get_op_usage(o3).object_store_memory
|
|
# When pending task outputs is None, it's treated as 0
|
|
assert op3_usage == 0
|
|
|
|
# Task inputs no longer count once the task is finished.
|
|
o3.metrics.on_output_queued(o3_input)
|
|
o3.metrics.on_task_finished(
|
|
0,
|
|
None,
|
|
TaskExecWorkerStats(task_wall_time_s=0.0),
|
|
TaskExecDriverStats(task_output_backpressure_s=0),
|
|
)
|
|
resource_manager.update_usages()
|
|
assert resource_manager.get_op_usage(o1).object_store_memory == 0
|
|
assert resource_manager.get_op_usage(o2).object_store_memory == 0
|
|
assert resource_manager.get_op_usage(o3).object_store_memory == 1
|
|
|
|
def test_object_store_accounting_delegates_to_op(self, restore_data_context):
|
|
"""``ResourceManager`` must dispatch to ``op.estimate_object_store_usage`` so subclasses can override the accounting."""
|
|
# Real upstream so the override op has a valid input dependency.
|
|
input = make_ref_bundles([[x] for x in range(1)])[0]
|
|
upstream = InputDataBuffer(DataContext.get_current(), [input])
|
|
|
|
# Subclass that overrides the accounting to return hard-coded
|
|
# values — bypasses the generic metrics+state computation.
|
|
override = mock_map_op(upstream)
|
|
override.estimate_object_store_usage = lambda state: ObjectStoreUsage(
|
|
internal=42, outputs=100
|
|
)
|
|
|
|
topo = build_streaming_topology(override, ExecutionOptions(), noop_counter())
|
|
resource_manager = ResourceManager(
|
|
topo,
|
|
ExecutionOptions(),
|
|
MagicMock(return_value=ExecutionResources.zero()),
|
|
DataContext.get_current(),
|
|
BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True),
|
|
)
|
|
|
|
resource_manager.update_usages()
|
|
|
|
# The override's hard-coded values flow through unchanged into
|
|
# both the per-component dicts and the aggregated op usage.
|
|
assert resource_manager.get_mem_op_internal(override) == 42
|
|
assert resource_manager.get_mem_op_outputs(override) == 100
|
|
assert resource_manager.get_op_usage(override).object_store_memory == 42 + 100
|
|
|
|
def test_get_completed_ops_usage(self, restore_data_context):
|
|
"""Test that _get_completed_ops_usage returns total usage of completed ops."""
|
|
o1 = InputDataBuffer(DataContext.get_current(), [])
|
|
o2 = mock_map_op(o1)
|
|
o3 = LimitOperator(1, o2, DataContext.get_current())
|
|
o4 = mock_map_op(o3)
|
|
o5 = mock_map_op(o4)
|
|
|
|
topo = build_streaming_topology(o5, ExecutionOptions(), noop_counter())
|
|
|
|
o1.mark_execution_finished()
|
|
o2.mark_execution_finished()
|
|
|
|
op_usages = {
|
|
o1: ExecutionResources.zero(),
|
|
o2: ExecutionResources(cpu=2, object_store_memory=50),
|
|
o3: ExecutionResources(cpu=1, object_store_memory=25),
|
|
o4: ExecutionResources.zero(),
|
|
o5: ExecutionResources.zero(),
|
|
}
|
|
|
|
resource_manager = ResourceManager(
|
|
topo,
|
|
ExecutionOptions(),
|
|
MagicMock(),
|
|
DataContext.get_current(),
|
|
BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True),
|
|
)
|
|
resource_manager.get_op_usage = MagicMock(side_effect=lambda op: op_usages[op])
|
|
|
|
# o2 is completed and o3 is downstream ineligible (LimitOperator)
|
|
# Total usage should be o2 + o3
|
|
completed_ops_usage = resource_manager._get_completed_ops_usage()
|
|
assert completed_ops_usage == ExecutionResources(cpu=3, object_store_memory=75)
|
|
|
|
def test_get_completed_ops_usage_complex_graph(self, restore_data_context):
|
|
"""
|
|
o1 (InputDataBuffer)
|
|
|
|
|
v
|
|
o2 (MapOperator, completed)
|
|
|
|
|
v
|
|
o3 (LimitOperator)
|
|
|
|
|
v o4 (InputDataBuffer)
|
|
| |
|
|
| v
|
|
| o5 (MapOperator, completed)
|
|
| |
|
|
v v
|
|
o6 (UnionOperator) <--
|
|
|
|
|
v
|
|
o8 (JoinOperator) <-- o7 (InputDataBuffer, completed)
|
|
"""
|
|
o1 = InputDataBuffer(DataContext.get_current(), [])
|
|
o2 = mock_map_op(o1)
|
|
o3 = LimitOperator(1, o2, DataContext.get_current())
|
|
o4 = InputDataBuffer(DataContext.get_current(), [])
|
|
o5 = mock_map_op(o4)
|
|
o6 = mock_union_op([o3, o5])
|
|
o7 = InputDataBuffer(DataContext.get_current(), [])
|
|
o8 = mock_join_op(o7, o6)
|
|
|
|
topo = build_streaming_topology(o8, ExecutionOptions(), noop_counter())
|
|
|
|
o1.mark_execution_finished()
|
|
o2.mark_execution_finished()
|
|
o4.mark_execution_finished()
|
|
o5.mark_execution_finished()
|
|
o7.mark_execution_finished()
|
|
|
|
op_usages = {
|
|
o1: ExecutionResources.zero(),
|
|
o2: ExecutionResources(cpu=2, object_store_memory=150),
|
|
o3: ExecutionResources(cpu=2, object_store_memory=50),
|
|
o4: ExecutionResources.zero(),
|
|
o5: ExecutionResources(cpu=3, object_store_memory=100),
|
|
o6: ExecutionResources.zero(),
|
|
o7: ExecutionResources(cpu=1, object_store_memory=100),
|
|
o8: ExecutionResources.zero(),
|
|
}
|
|
|
|
resource_manager = ResourceManager(
|
|
topo,
|
|
ExecutionOptions(),
|
|
MagicMock(),
|
|
DataContext.get_current(),
|
|
BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True),
|
|
)
|
|
resource_manager.get_op_usage = MagicMock(side_effect=lambda op: op_usages[op])
|
|
|
|
# Completed ops: o2, o5, o7
|
|
# Downstream ineligible: o3 (LimitOperator after o2)
|
|
# Total usage should be o2 + o3 + o5 + o7
|
|
completed_ops_usage = resource_manager._get_completed_ops_usage()
|
|
|
|
assert completed_ops_usage == ExecutionResources(cpu=8, object_store_memory=400)
|
|
|
|
def test_external_consumer_bytes_attributed_to_terminal_operator(
|
|
self, restore_data_context
|
|
):
|
|
"""External consumer bytes (e.g., iterator prefetch buffers) are charged
|
|
to the terminal operator's object store usage, not as a global deduction."""
|
|
cluster_resources = ExecutionResources(cpu=10, gpu=0, object_store_memory=1000)
|
|
|
|
o1 = InputDataBuffer(DataContext.get_current(), [])
|
|
o2 = mock_map_op(o1)
|
|
o3 = mock_map_op(o2)
|
|
|
|
topo = build_streaming_topology(o3, ExecutionOptions(), noop_counter())
|
|
|
|
o1.mark_execution_finished()
|
|
o2.mark_execution_finished()
|
|
|
|
resource_manager = ResourceManager(
|
|
topo,
|
|
ExecutionOptions(),
|
|
lambda: cluster_resources,
|
|
DataContext.get_current(),
|
|
BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True),
|
|
)
|
|
|
|
for op in [o1, o2, o3]:
|
|
op.current_logical_usage = MagicMock(return_value=ExecutionResources.zero())
|
|
op.running_logical_usage = MagicMock(return_value=ExecutionResources.zero())
|
|
op.pending_logical_usage = MagicMock(return_value=ExecutionResources.zero())
|
|
|
|
assert resource_manager._op_resource_allocator is not None
|
|
|
|
resource_manager.update_usages()
|
|
baseline_terminal = resource_manager.get_op_usage(o3).object_store_memory
|
|
baseline_upstream = resource_manager.get_op_usage(o2).object_store_memory
|
|
|
|
def _available_pool_object_store():
|
|
return (
|
|
resource_manager.get_global_limits()
|
|
.subtract(resource_manager._get_completed_ops_usage())
|
|
.max(ExecutionResources.zero())
|
|
.object_store_memory
|
|
)
|
|
|
|
pool_before = _available_pool_object_store()
|
|
|
|
resource_manager.set_external_consumer_bytes(200)
|
|
resource_manager.update_usages()
|
|
|
|
assert (
|
|
resource_manager.get_op_usage(o3).object_store_memory
|
|
== baseline_terminal + 200
|
|
)
|
|
assert (
|
|
resource_manager.get_op_usage(o2).object_store_memory == baseline_upstream
|
|
)
|
|
assert _available_pool_object_store() == pool_before
|
|
|
|
resource_manager.set_external_consumer_bytes(0)
|
|
resource_manager.update_usages()
|
|
assert (
|
|
resource_manager.get_op_usage(o3).object_store_memory == baseline_terminal
|
|
)
|
|
|
|
# Very large external bytes: terminal usage reflects them; update still succeeds.
|
|
resource_manager.set_external_consumer_bytes(999999)
|
|
resource_manager.update_usages()
|
|
assert (
|
|
resource_manager.get_op_usage(o3).object_store_memory
|
|
== baseline_terminal + 999999
|
|
)
|
|
|
|
def test_set_external_consumer_bytes_rejects_negative(self, restore_data_context):
|
|
resource_manager = _resource_manager_for_limits_only_test(
|
|
ExecutionOptions(),
|
|
MagicMock(return_value=ExecutionResources.zero()),
|
|
)
|
|
with pytest.raises(AssertionError):
|
|
resource_manager.set_external_consumer_bytes(-1)
|
|
|
|
def test_external_consumer_bytes_input_data_buffer_sink(self, restore_data_context):
|
|
"""When the execute DAG is only an InputDataBuffer, prefetch bytes still
|
|
attach to that terminal sink instead of being dropped by the
|
|
InputDataBuffer early return."""
|
|
buf = InputDataBuffer(DataContext.get_current(), [])
|
|
topo = build_streaming_topology(buf, ExecutionOptions(), noop_counter())
|
|
resource_manager = ResourceManager(
|
|
topo,
|
|
ExecutionOptions(),
|
|
lambda: ExecutionResources(cpu=10, gpu=0, object_store_memory=1000),
|
|
DataContext.get_current(),
|
|
BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True),
|
|
)
|
|
buf.current_logical_usage = MagicMock(return_value=ExecutionResources.zero())
|
|
buf.running_logical_usage = MagicMock(return_value=ExecutionResources.zero())
|
|
buf.pending_logical_usage = MagicMock(return_value=ExecutionResources.zero())
|
|
|
|
resource_manager.update_usages()
|
|
assert resource_manager.get_op_usage(buf).object_store_memory == 0
|
|
|
|
resource_manager.set_external_consumer_bytes(150)
|
|
resource_manager.update_usages()
|
|
assert resource_manager.get_op_usage(buf).object_store_memory == 150
|
|
|
|
def test_external_consumer_bytes_surfaced_in_op_usage_str(
|
|
self, restore_data_context
|
|
):
|
|
"""The terminal operator's verbose usage string should include
|
|
external_consumer=... when an external consumer is registered, so users
|
|
can see how much of the operator's object-store memory is held by a
|
|
downstream iterator vs. the operator's own queues."""
|
|
cluster_resources = ExecutionResources(cpu=10, gpu=0, object_store_memory=1000)
|
|
|
|
o1 = InputDataBuffer(DataContext.get_current(), [])
|
|
o2 = mock_map_op(o1)
|
|
o3 = mock_map_op(o2)
|
|
|
|
topo = build_streaming_topology(o3, ExecutionOptions(), noop_counter())
|
|
resource_manager = ResourceManager(
|
|
topo,
|
|
ExecutionOptions(),
|
|
lambda: cluster_resources,
|
|
DataContext.get_current(),
|
|
BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True),
|
|
)
|
|
|
|
for op in [o1, o2, o3]:
|
|
op.current_logical_usage = MagicMock(return_value=ExecutionResources.zero())
|
|
op.running_logical_usage = MagicMock(return_value=ExecutionResources.zero())
|
|
op.pending_logical_usage = MagicMock(return_value=ExecutionResources.zero())
|
|
|
|
resource_manager.update_usages()
|
|
|
|
# No external consumer yet: nothing extra in the usage string.
|
|
terminal_str = resource_manager.get_op_usage_str(o3, verbose=True)
|
|
upstream_str = resource_manager.get_op_usage_str(o2, verbose=True)
|
|
assert "external_consumer=" not in terminal_str
|
|
assert "external_consumer=" not in upstream_str
|
|
|
|
# Register an external consumer. Only the terminal operator's string
|
|
# should pick up `external_consumer=...`.
|
|
resource_manager.set_external_consumer_bytes(200)
|
|
resource_manager.update_usages()
|
|
terminal_str = resource_manager.get_op_usage_str(o3, verbose=True)
|
|
upstream_str = resource_manager.get_op_usage_str(o2, verbose=True)
|
|
assert "external_consumer=200.0B" in terminal_str
|
|
assert "external_consumer=" not in upstream_str
|
|
|
|
# The field is inside the existing `(in=...,out=...)` parenthetical.
|
|
assert ",external_consumer=" in terminal_str
|
|
|
|
# Non-verbose output omits the field (existing format unchanged).
|
|
terminal_str_brief = resource_manager.get_op_usage_str(o3, verbose=False)
|
|
assert "external_consumer=" not in terminal_str_brief
|
|
|
|
def test_topology_rejects_multiple_terminal_operators(self, restore_data_context):
|
|
ctx = DataContext.get_current()
|
|
a = PhysicalOperator("a", [], ctx)
|
|
b = PhysicalOperator("b", [], ctx)
|
|
topology = {a: MagicMock(), b: MagicMock()}
|
|
with pytest.raises(ValueError, match="Expected exactly one terminal operator"):
|
|
ResourceManager(
|
|
topology,
|
|
ExecutionOptions(),
|
|
MagicMock(return_value=ExecutionResources.zero()),
|
|
DataContext.get_current(),
|
|
BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True),
|
|
)
|
|
|
|
def test_topology_rejects_empty_topology(self, restore_data_context):
|
|
with pytest.raises(ValueError, match="topology must be non-empty"):
|
|
ResourceManager(
|
|
{},
|
|
ExecutionOptions(),
|
|
MagicMock(return_value=ExecutionResources.zero()),
|
|
DataContext.get_current(),
|
|
BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True),
|
|
)
|
|
|
|
def test_topology_rejects_no_terminal_operator(self, restore_data_context):
|
|
# Every op has a downstream in this dict, so there should be no operator with empty
|
|
# output_dependencies (e.g. a 2-node cycle). Real streaming DAGs from
|
|
# build_streaming_topology always have a unique sink.
|
|
a = MagicMock(spec=PhysicalOperator)
|
|
b = MagicMock(spec=PhysicalOperator)
|
|
a.output_dependencies = [b]
|
|
b.output_dependencies = [a]
|
|
topology = {a: MagicMock(), b: MagicMock()}
|
|
with pytest.raises(ValueError, match="No terminal operator found"):
|
|
ResourceManager(
|
|
topology,
|
|
ExecutionOptions(),
|
|
MagicMock(return_value=ExecutionResources.zero()),
|
|
DataContext.get_current(),
|
|
BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True),
|
|
)
|
|
|
|
def test_is_blocking_materializing_op(self, restore_data_context):
|
|
"""Test _is_blocking_materializing_op correctly identifies blocking materializing ops.
|
|
|
|
Cases tested:
|
|
1. Operator itself is a blocking materializing op (AllToAllOperator) -> True
|
|
2. Operator has downstream ineligible blocking materializing op -> True
|
|
3. Operator with no downstream blocking materializing ops -> False
|
|
|
|
Note: AllToAllOperator.throttling_disabled() returns True, making it
|
|
ineligible for resource allocation. This means shuffle operators are
|
|
always in the "downstream ineligible" chain from eligible operators.
|
|
"""
|
|
# Build pipeline: o1 -> o2 -> o3 (limit) -> o4 (shuffle) -> o5
|
|
o1 = InputDataBuffer(DataContext.get_current(), [])
|
|
o2 = mock_map_op(o1, name="Map1")
|
|
o3 = LimitOperator(1, o2, DataContext.get_current())
|
|
o4 = mock_all_to_all_op(o3, name="Sort")
|
|
o5 = mock_map_op(o4, name="Map2")
|
|
|
|
topo = build_streaming_topology(o5, ExecutionOptions(), noop_counter())
|
|
|
|
resource_manager = ResourceManager(
|
|
topo,
|
|
ExecutionOptions(),
|
|
MagicMock(),
|
|
DataContext.get_current(),
|
|
BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True),
|
|
)
|
|
|
|
# Case 1: Shuffle operator itself is blocking materializing
|
|
assert resource_manager._is_blocking_materializing_op(o4) is True
|
|
|
|
# Case 2: Map operator before shuffle (o2) should return True because
|
|
# its downstream ineligible chain includes:
|
|
# - o3 (LimitOperator - ineligible, not in eligible types)
|
|
# - o4 (AllToAllOperator - ineligible because throttling_disabled=True)
|
|
# Since o4 is a blocking materializing op, the check returns True
|
|
assert resource_manager._is_blocking_materializing_op(o2) is True
|
|
|
|
# o3 (LimitOperator) also returns True because its downstream ineligible
|
|
# chain includes o4 (shuffle)
|
|
assert resource_manager._is_blocking_materializing_op(o3) is True
|
|
|
|
# Case 3: o5 (Map after shuffle) has no downstream ops -> False
|
|
assert resource_manager._is_blocking_materializing_op(o5) is False
|
|
|
|
# Case 4: Extend pipeline with ops that have no blocking materializing downstream
|
|
# o5 -> o6 (limit) -> o7
|
|
o6 = LimitOperator(1, o5, DataContext.get_current())
|
|
o7 = mock_map_op(o6, name="Map3")
|
|
|
|
topo2 = build_streaming_topology(o7, ExecutionOptions(), noop_counter())
|
|
resource_manager2 = ResourceManager(
|
|
topo2,
|
|
ExecutionOptions(),
|
|
MagicMock(),
|
|
DataContext.get_current(),
|
|
BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True),
|
|
)
|
|
|
|
# o5's downstream (o6, o7) has no blocking materializing ops
|
|
assert resource_manager2._is_blocking_materializing_op(o5) is False
|
|
assert resource_manager2._is_blocking_materializing_op(o7) is False
|
|
|
|
def test_memory_limit_blocks_task_submission(self, restore_data_context):
|
|
"""Test that tasks are blocked when memory limit is exceeded."""
|
|
# Cluster has 1000 bytes of memory
|
|
cluster_resources = ExecutionResources(cpu=1, gpu=0, memory=1000)
|
|
|
|
# Request 2000 bytes memory
|
|
o1 = InputDataBuffer(DataContext.get_current(), [])
|
|
o2 = mock_map_op(
|
|
o1,
|
|
ray_remote_args={"num_cpus": 1, "memory": 2000},
|
|
name="HighMemoryTask",
|
|
)
|
|
|
|
topo = build_streaming_topology(o2, ExecutionOptions(), noop_counter())
|
|
options = ExecutionOptions()
|
|
|
|
resource_manager = ResourceManager(
|
|
topology=topo,
|
|
options=options,
|
|
get_total_resources=lambda: cluster_resources,
|
|
data_context=DataContext.get_current(),
|
|
block_ref_counter=BlockRefCounter(
|
|
add_object_out_of_scope_callback=lambda *_: True
|
|
),
|
|
)
|
|
resource_manager.update_usages()
|
|
|
|
# Task cannot be submitted because it exceeds memory limit
|
|
allocator = create_resource_allocator(
|
|
resource_manager, DataContext.get_current()
|
|
)
|
|
assert allocator is not None
|
|
allocator.update_budgets(limits=resource_manager.get_global_limits())
|
|
can_submit = allocator.can_submit_new_task(o2)
|
|
assert (
|
|
not can_submit
|
|
), "Task should be blocked: requires 2000 bytes but only 1000 bytes memory available"
|
|
|
|
|
|
class TestOutputBackpressureGuard:
|
|
"""Tests for OutputBackpressureGuard.should_unblock."""
|
|
|
|
def test_unblock_backpressure_terminal_operator(self, restore_data_context):
|
|
"""Terminal operator (no downstream eligible ops) with no external
|
|
consumer should always unblock (e.g., write pipeline)."""
|
|
o1 = InputDataBuffer(DataContext.get_current(), [])
|
|
o2 = mock_map_op(o1)
|
|
o3 = LimitOperator(1, o2, DataContext.get_current())
|
|
|
|
topo = build_streaming_topology(o3, ExecutionOptions(), noop_counter())
|
|
|
|
resource_manager = ResourceManager(
|
|
topo,
|
|
ExecutionOptions(),
|
|
MagicMock(),
|
|
DataContext.get_current(),
|
|
BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True),
|
|
)
|
|
guard = OutputBackpressureGuard(topo, resource_manager)
|
|
|
|
# o2 is terminal (no downstream eligible ops beyond it) and no external
|
|
# consumer — should unblock (e.g., write pipeline).
|
|
assert guard.should_unblock(o2) is True
|
|
|
|
# Add o4 operator - o2 is no longer terminal
|
|
o4 = mock_map_op(o3)
|
|
|
|
topo = build_streaming_topology(o4, ExecutionOptions(), noop_counter())
|
|
|
|
resource_manager = ResourceManager(
|
|
topo,
|
|
ExecutionOptions(),
|
|
MagicMock(),
|
|
DataContext.get_current(),
|
|
BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True),
|
|
)
|
|
guard = OutputBackpressureGuard(topo, resource_manager)
|
|
|
|
# Mock downstream (o4) having active tasks and input blocks (ie unblocking
|
|
# conditions not met)
|
|
o4.num_active_tasks = MagicMock(return_value=1)
|
|
guard._idle_detector.detect_idle = MagicMock(return_value=False)
|
|
|
|
# o2 is not terminal anymore, falls back to idle detector which returns False
|
|
assert guard.should_unblock(o2) is False
|
|
|
|
def test_no_unblock_backpressure_terminal_with_external_consumer(
|
|
self, restore_data_context
|
|
):
|
|
"""Terminal operator with an external consumer should only unblock
|
|
when consumers are starving (blocked waiting for output)."""
|
|
o1 = InputDataBuffer(DataContext.get_current(), [])
|
|
o2 = mock_map_op(o1)
|
|
o3 = LimitOperator(1, o2, DataContext.get_current())
|
|
|
|
topo = build_streaming_topology(o3, ExecutionOptions(), noop_counter())
|
|
|
|
resource_manager = ResourceManager(
|
|
topo,
|
|
ExecutionOptions(),
|
|
MagicMock(),
|
|
DataContext.get_current(),
|
|
BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True),
|
|
)
|
|
guard = OutputBackpressureGuard(topo, resource_manager)
|
|
|
|
# Register an external consumer (e.g., iter_batches or streaming_split).
|
|
resource_manager.set_external_consumer_bytes(0)
|
|
|
|
dag_output_state = topo[o3]
|
|
|
|
# No consumers waiting — should NOT unblock (prevents pileup).
|
|
dag_output_state._num_waiting_consumers = 0
|
|
assert guard.should_unblock(o2) is False
|
|
|
|
# Simulate a consumer blocked in get_output_blocking (starving).
|
|
# The output node is o3 (LimitOperator), which tracks waiting consumers.
|
|
dag_output_state._num_waiting_consumers = 1
|
|
assert guard.should_unblock(o2) is True
|
|
|
|
# Consumer gets data and stops waiting — should NOT unblock again.
|
|
dag_output_state._num_waiting_consumers = 0
|
|
assert guard.should_unblock(o2) is False
|
|
|
|
def test_unblock_backpressure_downstream_idle(self, restore_data_context):
|
|
"""Unblock when downstream is idle (no active tasks) to maintain liveness."""
|
|
o1 = InputDataBuffer(DataContext.get_current(), [])
|
|
o2 = mock_map_op(o1)
|
|
o3 = mock_map_op(o2)
|
|
|
|
topo = build_streaming_topology(o3, ExecutionOptions(), noop_counter())
|
|
|
|
resource_manager = ResourceManager(
|
|
topo,
|
|
ExecutionOptions(),
|
|
MagicMock(),
|
|
DataContext.get_current(),
|
|
BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True),
|
|
)
|
|
guard = OutputBackpressureGuard(topo, resource_manager)
|
|
o3.num_active_tasks = MagicMock(return_value=0)
|
|
|
|
# Case 1: Downstream cannot submit (resource constrained) - unblock to free resources
|
|
resource_manager.op_resource_allocator.can_submit_new_task = MagicMock(
|
|
return_value=False
|
|
)
|
|
assert guard.should_unblock(o2) is True
|
|
|
|
# Case 2: Downstream can submit but has no input blocks - unblock to produce data
|
|
resource_manager.op_resource_allocator.can_submit_new_task = MagicMock(
|
|
return_value=True
|
|
)
|
|
topo[o3].total_enqueued_input_blocks = MagicMock(return_value=0)
|
|
assert guard.should_unblock(o2) is True
|
|
|
|
def test_unblock_backpressure_fallback_to_idle_detector(self, restore_data_context):
|
|
"""When unblock conditions not met, falls back to idle detector result."""
|
|
o1 = InputDataBuffer(DataContext.get_current(), [])
|
|
o2 = mock_map_op(o1)
|
|
o3 = mock_map_op(o2)
|
|
|
|
topo = build_streaming_topology(o3, ExecutionOptions(), noop_counter())
|
|
|
|
resource_manager = ResourceManager(
|
|
topo,
|
|
ExecutionOptions(),
|
|
MagicMock(),
|
|
DataContext.get_current(),
|
|
BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True),
|
|
)
|
|
guard = OutputBackpressureGuard(topo, resource_manager)
|
|
|
|
# Case: Downstream has active tasks - falls back to idle detector
|
|
o3.num_active_tasks = MagicMock(return_value=2)
|
|
guard._idle_detector.detect_idle = MagicMock(return_value=False)
|
|
assert guard.should_unblock(o2) is False
|
|
|
|
# Case: Idle detector returns True - should unblock
|
|
guard._idle_detector.detect_idle = MagicMock(return_value=True)
|
|
assert guard.should_unblock(o2) is True
|
|
|
|
# Case: Downstream has no active tasks but has input blocks - falls back to idle detector
|
|
resource_manager.op_resource_allocator.can_submit_new_task = MagicMock(
|
|
return_value=True
|
|
)
|
|
o3.num_active_tasks = MagicMock(return_value=0)
|
|
topo[o3].total_enqueued_input_blocks = MagicMock(return_value=5)
|
|
guard._idle_detector.detect_idle = MagicMock(return_value=False)
|
|
assert guard.should_unblock(o2) is False
|
|
|
|
def test_unblock_when_resource_allocator_disabled(self, restore_data_context):
|
|
"""When the op resource allocator is disabled, the guard treats
|
|
downstream as schedulable (no budget to consult), so
|
|
"downstream resource constrained" case never fires, but the other
|
|
liveness conditions still do.
|
|
"""
|
|
# Disable resource allocator
|
|
DataContext.get_current().op_resource_reservation_enabled = False
|
|
|
|
o1 = InputDataBuffer(DataContext.get_current(), [])
|
|
o2 = mock_map_op(o1)
|
|
o3 = mock_map_op(o2)
|
|
|
|
topo = build_streaming_topology(o3, ExecutionOptions(), noop_counter())
|
|
|
|
resource_manager = ResourceManager(
|
|
topo,
|
|
ExecutionOptions(),
|
|
MagicMock(),
|
|
DataContext.get_current(),
|
|
BlockRefCounter(add_object_out_of_scope_callback=lambda *_: True),
|
|
)
|
|
assert not resource_manager.op_resource_allocator_enabled()
|
|
|
|
guard = OutputBackpressureGuard(topo, resource_manager)
|
|
o3.num_active_tasks = MagicMock(return_value=0)
|
|
|
|
# "Downstream idle with empty input queue" case should fire and unblock.
|
|
topo[o3].total_enqueued_input_blocks = MagicMock(return_value=0)
|
|
assert guard.should_unblock(o2) is True
|
|
|
|
|
|
class TestIdleDetector:
|
|
"""Tests for IdleDetector."""
|
|
|
|
def test_idle_detector(self, restore_data_context):
|
|
"""Test IdleDetector behavior through its public interface."""
|
|
idle_detector = IdleDetector()
|
|
op = MagicMock()
|
|
op.metrics.num_task_outputs_generated = 0
|
|
|
|
with freeze_time() as frozen:
|
|
# First call initializes state, returns False
|
|
assert idle_detector.detect_idle(op) is False
|
|
|
|
# Call within interval returns False (rate limited)
|
|
frozen.tick(timedelta(seconds=idle_detector.DETECTION_INTERVAL_S - 1))
|
|
assert idle_detector.detect_idle(op) is False
|
|
|
|
# Call after interval with no output returns True (idle)
|
|
frozen.tick(timedelta(seconds=2))
|
|
assert idle_detector.detect_idle(op) is True
|
|
|
|
# Operator produces output - next detection returns False (active)
|
|
op.metrics.num_task_outputs_generated = 5
|
|
assert idle_detector.detect_idle(op) is False
|
|
|
|
# After output, wait for interval with no new output - returns True (idle again)
|
|
frozen.tick(timedelta(seconds=idle_detector.DETECTION_INTERVAL_S + 1))
|
|
assert idle_detector.detect_idle(op) is True
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
sys.exit(pytest.main(["-v", __file__]))
|