943 lines
40 KiB
Python
943 lines
40 KiB
Python
import logging
|
|
import math
|
|
import time
|
|
from abc import ABC, abstractmethod
|
|
from collections import defaultdict
|
|
from typing import TYPE_CHECKING, Callable, Dict, Iterable, List, Optional
|
|
|
|
from ray._common.utils import env_bool, env_float
|
|
from ray.data._internal.execution import create_resource_allocator
|
|
from ray.data._internal.execution.block_ref_counter import BlockRefCounter
|
|
from ray.data._internal.execution.interfaces.execution_options import (
|
|
ExecutionOptions,
|
|
ExecutionResources,
|
|
)
|
|
from ray.data._internal.execution.interfaces.physical_operator import (
|
|
PhysicalOperator,
|
|
ReportsExtraResourceUsage,
|
|
)
|
|
from ray.data._internal.execution.operators.base_physical_operator import (
|
|
AllToAllOperator,
|
|
)
|
|
from ray.data._internal.execution.operators.hash_shuffle import (
|
|
HashShufflingOperatorBase,
|
|
)
|
|
from ray.data._internal.execution.operators.input_data_buffer import InputDataBuffer
|
|
from ray.data._internal.execution.operators.shuffle_operators.shuffle_map_operator import ( # noqa: E501
|
|
ShuffleMapOp,
|
|
)
|
|
from ray.data._internal.execution.operators.zip_operator import ZipOperator
|
|
from ray.data._internal.execution.util import memory_string
|
|
from ray.data.context import DataContext
|
|
from ray.util.debug import log_once
|
|
|
|
if TYPE_CHECKING:
|
|
from ray.data._internal.execution.streaming_executor_state import OpState, Topology
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
LOG_DEBUG_TELEMETRY_FOR_RESOURCE_MANAGER_OVERRIDE: Optional[bool] = env_bool(
|
|
"RAY_DATA_DEBUG_RESOURCE_MANAGER", None
|
|
)
|
|
|
|
# Only warn that the cluster can't run any task once the operator has been starved of
|
|
# its minimum resources for this long. This avoids spurious warnings while the cluster
|
|
# is still scaling up or waiting for a response from the autoscaling coordinator.
|
|
#
|
|
# I arbitrarily chose the default delay.
|
|
STARVATION_WARNING_DELAY_S = env_float("RAY_DATA_STARVATION_WARNING_DELAY_S", 60)
|
|
|
|
|
|
# Following list is a list of *blocking* materializing operators, that prevent
|
|
# operators downstream from them from starting execution until these operators
|
|
# finish executing.
|
|
_BLOCKING_MATERIALIZING_OPERATORS = (
|
|
HashShufflingOperatorBase,
|
|
AllToAllOperator,
|
|
ShuffleMapOp,
|
|
# TODO remove after zip made fully streaming
|
|
ZipOperator,
|
|
)
|
|
|
|
|
|
def terminal_operator_from_topology(topology: "Topology") -> PhysicalOperator:
|
|
"""Return the executor sink: the unique op with no in-DAG downstream consumers.
|
|
|
|
``build_streaming_topology`` is rooted at the same node passed to
|
|
``StreamingExecutor``; that root is the only operator whose
|
|
``output_dependencies`` is empty.
|
|
"""
|
|
if not topology:
|
|
raise ValueError("topology must be non-empty")
|
|
sinks = [op for op in topology if not op.output_dependencies]
|
|
if len(sinks) == 1:
|
|
return sinks[0]
|
|
if not sinks:
|
|
raise ValueError(
|
|
"No terminal operator found in topology (expected exactly one "
|
|
"operator with empty output_dependencies)"
|
|
)
|
|
raise ValueError(
|
|
"Expected exactly one terminal operator in topology, found "
|
|
f"{len(sinks)}: {sinks!r}"
|
|
)
|
|
|
|
|
|
class ResourceManager:
|
|
"""A class that manages the resource usage of a streaming executor."""
|
|
|
|
# The interval in seconds at which the global resource limits are refreshed.
|
|
GLOBAL_LIMITS_UPDATE_INTERVAL_S = 1
|
|
|
|
# The fraction of the object store capacity that will be used as the default object
|
|
# store memory limit for the streaming executor,
|
|
# when `OpResourceAllocator` is enabled.
|
|
DEFAULT_OBJECT_STORE_MEMORY_LIMIT_FRACTION = env_float(
|
|
"RAY_DATA_OBJECT_STORE_MEMORY_LIMIT_FRACTION", 0.5
|
|
)
|
|
|
|
# The fraction of the object store capacity that will be used as the default object
|
|
# store memory limit for the streaming executor,
|
|
# when `OpResourceAllocator` is not enabled.
|
|
DEFAULT_OBJECT_STORE_MEMORY_LIMIT_FRACTION_NO_RESERVATION = 0.25
|
|
|
|
def __init__(
|
|
self,
|
|
topology: "Topology",
|
|
options: ExecutionOptions,
|
|
get_total_resources: Callable[[], ExecutionResources],
|
|
data_context: DataContext,
|
|
block_ref_counter: BlockRefCounter,
|
|
):
|
|
self._topology = topology
|
|
self._options = options
|
|
self._get_total_resources = get_total_resources
|
|
self._global_limits = ExecutionResources.zero()
|
|
self._global_limits_last_update_time = 0
|
|
self._global_usage = ExecutionResources.zero()
|
|
self._global_running_usage = ExecutionResources.zero()
|
|
self._global_pending_usage = ExecutionResources.zero()
|
|
self._op_usages: Dict[PhysicalOperator, ExecutionResources] = {}
|
|
self._op_running_usages: Dict[PhysicalOperator, ExecutionResources] = {}
|
|
self._op_pending_usages: Dict[PhysicalOperator, ExecutionResources] = {}
|
|
# Object store memory usage of pending task outputs (blocks being
|
|
# generated by running tasks but not yet yielded).
|
|
self._mem_op_internal: Dict[PhysicalOperator, int] = defaultdict(int)
|
|
# Object store memory usage of the operator's outputs, including:
|
|
# internal output queue, external output buffer in OpState, and
|
|
# downstream operators' input buffers (inqueue + pending task inputs).
|
|
self._mem_op_outputs: Dict[PhysicalOperator, int] = defaultdict(int)
|
|
|
|
# Bytes buffered by external consumers (iterators) consuming Batches
|
|
# (including the prefetched blocks). For example,
|
|
# - ds.iter_batches -> one iterator
|
|
# - streaming_split -> multiple iterators
|
|
self._external_consumer_bytes: int = 0
|
|
self._has_external_consumer: bool = False
|
|
|
|
# Executor sink (DAG root: unique op with no output_dependencies).
|
|
# Iterator/streaming_split prefetch bytes are charged on this
|
|
# operator's output usage.
|
|
self._output_operator = terminal_operator_from_topology(topology)
|
|
|
|
self._block_ref_counter = block_ref_counter
|
|
|
|
self._op_resource_allocator: Optional[
|
|
"OpResourceAllocator"
|
|
] = create_resource_allocator(self, data_context)
|
|
|
|
self._object_store_memory_limit_fraction = (
|
|
data_context.override_object_store_memory_limit_fraction
|
|
if data_context.override_object_store_memory_limit_fraction is not None
|
|
else (
|
|
self.DEFAULT_OBJECT_STORE_MEMORY_LIMIT_FRACTION
|
|
if self.op_resource_allocator_enabled()
|
|
else self.DEFAULT_OBJECT_STORE_MEMORY_LIMIT_FRACTION_NO_RESERVATION
|
|
)
|
|
)
|
|
|
|
@property
|
|
def has_external_consumer(self) -> bool:
|
|
"""Return whether there is any external consumer."""
|
|
return self._has_external_consumer
|
|
|
|
def set_external_consumer_bytes(self, num_bytes: int) -> None:
|
|
"""Set the bytes buffered by external consumers."""
|
|
assert (
|
|
num_bytes >= 0
|
|
), f"external consumer bytes must be non-negative, got {num_bytes}"
|
|
self._external_consumer_bytes = num_bytes
|
|
self._has_external_consumer = True
|
|
|
|
def get_external_consumer_bytes(self) -> int:
|
|
"""Get the bytes buffered by external consumers."""
|
|
return self._external_consumer_bytes
|
|
|
|
def _estimate_object_store_memory_usage(
|
|
self, op: "PhysicalOperator", state: "OpState"
|
|
) -> int:
|
|
# Don't count input refs towards dynamic memory usage, as they have been
|
|
# pre-created already outside this execution.
|
|
if isinstance(op, InputDataBuffer):
|
|
if op is self._output_operator:
|
|
self._mem_op_internal[op] = 0
|
|
self._mem_op_outputs[op] = self._external_consumer_bytes
|
|
return self._external_consumer_bytes
|
|
return 0
|
|
|
|
usage = op.estimate_object_store_usage(state)
|
|
self._mem_op_internal[op] = usage.internal
|
|
self._mem_op_outputs[op] = usage.outputs
|
|
|
|
# Attribute iterator / streaming_split prefetch to the executor sink only.
|
|
if op is self._output_operator:
|
|
self._mem_op_outputs[op] += self._external_consumer_bytes
|
|
|
|
return self._mem_op_outputs[op] + self._mem_op_internal[op]
|
|
|
|
def update_usages(self):
|
|
"""Recalculate resource usages."""
|
|
# TODO(hchen): This method will be called frequently during the execution loop.
|
|
# And some computations are redundant. We should either remove redundant
|
|
# computations or remove this method entirely and compute usages on demand.
|
|
self._op_usages.clear()
|
|
self._op_running_usages.clear()
|
|
self._op_pending_usages.clear()
|
|
|
|
# Iterate from last to first operator.
|
|
for op, state in reversed(self._topology.items()):
|
|
# Update `self._op_usages`, `self._op_running_usages`,
|
|
# and `self._op_pending_usages`.
|
|
op_usage = op.current_logical_usage()
|
|
op_running_usage = op.running_logical_usage()
|
|
op_pending_usage = op.pending_logical_usage()
|
|
|
|
assert not op_usage.object_store_memory
|
|
assert not op_running_usage.object_store_memory
|
|
assert not op_pending_usage.object_store_memory
|
|
|
|
used_object_store = self._estimate_object_store_memory_usage(op, state)
|
|
|
|
op_usage = op_usage.copy(object_store_memory=used_object_store)
|
|
op_running_usage = op_running_usage.copy(
|
|
object_store_memory=used_object_store
|
|
)
|
|
|
|
if isinstance(op, ReportsExtraResourceUsage):
|
|
op_usage.add(op.extra_resource_usage())
|
|
|
|
self._op_usages[op] = op_usage
|
|
self._op_running_usages[op] = op_running_usage
|
|
self._op_pending_usages[op] = op_pending_usage
|
|
|
|
# Update operator's object store usage, which is used by
|
|
# DatasetStats and updated on the Ray Data dashboard.
|
|
op._metrics.obj_store_mem_used = op_usage.object_store_memory
|
|
|
|
# Roll the per-op usages up into the global totals in a single pass
|
|
# each (one allocation per total instead of one per operator).
|
|
self._global_usage = ExecutionResources.combine_sum(self._op_usages.values())
|
|
self._global_running_usage = ExecutionResources.combine_sum(
|
|
self._op_running_usages.values()
|
|
)
|
|
self._global_pending_usage = ExecutionResources.combine_sum(
|
|
self._op_pending_usages.values()
|
|
)
|
|
|
|
if self._op_resource_allocator is not None:
|
|
self._update_allocated_budgets()
|
|
|
|
def _update_allocated_budgets(self):
|
|
completed_ops_usage = self._get_completed_ops_usage()
|
|
|
|
available_limits = (
|
|
self.get_global_limits()
|
|
.subtract(completed_ops_usage)
|
|
.max(ExecutionResources.zero())
|
|
)
|
|
|
|
self._op_resource_allocator.update_budgets(limits=available_limits)
|
|
|
|
def get_global_usage(self) -> ExecutionResources:
|
|
"""Return the global resource usage at the current time."""
|
|
assert (
|
|
self._global_usage.is_non_negative()
|
|
), f"Global usage should be non-negative, got {self._global_usage}"
|
|
return self._global_usage
|
|
|
|
def get_global_running_usage(self) -> ExecutionResources:
|
|
"""Return the global running resource usage at the current time."""
|
|
return self._global_running_usage
|
|
|
|
def get_global_pending_usage(self) -> ExecutionResources:
|
|
"""Return the global pending resource usage at the current time."""
|
|
return self._global_pending_usage
|
|
|
|
def get_global_limits(self) -> ExecutionResources:
|
|
"""Return the global resource limits at the current time.
|
|
|
|
This method autodetects any unspecified execution resource limits based on the
|
|
current cluster size, refreshing these values periodically to support cluster
|
|
autoscaling.
|
|
"""
|
|
if (
|
|
time.time() - self._global_limits_last_update_time
|
|
< self.GLOBAL_LIMITS_UPDATE_INTERVAL_S
|
|
):
|
|
return self._global_limits
|
|
|
|
self._global_limits_last_update_time = time.time()
|
|
default_limits = self._options.resource_limits
|
|
exclude = self._options.exclude_resources
|
|
total_resources = self._get_total_resources()
|
|
default_mem_fraction = self._object_store_memory_limit_fraction
|
|
total_resources = total_resources.copy(
|
|
object_store_memory=total_resources.object_store_memory
|
|
* default_mem_fraction
|
|
)
|
|
# Clamp to non-negative because exclude_resources (e.g., training worker
|
|
# CPUs) can exceed the total resources reported by the cluster autoscaler,
|
|
# such as when Ray Train reserves more CPUs than are visible to Ray Data.
|
|
self._global_limits = (
|
|
default_limits.min(total_resources)
|
|
.subtract(exclude)
|
|
.max(ExecutionResources.zero())
|
|
)
|
|
return self._global_limits
|
|
|
|
def get_op_usage(
|
|
self, op: PhysicalOperator, include_ineligible_downstream: bool = False
|
|
) -> ExecutionResources:
|
|
"""Return the resource usage of the given operator at the current time."""
|
|
own_usage = self._op_usages[op]
|
|
|
|
if not include_ineligible_downstream:
|
|
return own_usage
|
|
|
|
return own_usage.add(self._get_downstream_ineligible_ops_usage(op))
|
|
|
|
def _get_downstream_ineligible_ops_usage(
|
|
self, op: PhysicalOperator
|
|
) -> ExecutionResources:
|
|
return ExecutionResources.combine_sum(
|
|
self.get_op_usage(op) for op in self._get_downstream_ineligible_ops(op)
|
|
)
|
|
|
|
def get_mem_op_internal(self, op: PhysicalOperator) -> int:
|
|
"""Return the memory usage of pending task outputs for the given operator."""
|
|
return self._mem_op_internal[op]
|
|
|
|
def get_mem_op_outputs(
|
|
self,
|
|
op: PhysicalOperator,
|
|
include_ineligible_downstream: bool = False,
|
|
) -> int:
|
|
"""Return the memory usage of the outputs of the given operator."""
|
|
# Outputs usage of the current operator.
|
|
op_outputs_usage = self._mem_op_outputs[op]
|
|
|
|
if not include_ineligible_downstream:
|
|
return op_outputs_usage
|
|
|
|
# Also account the downstream ineligible operators' memory usage.
|
|
return (
|
|
op_outputs_usage
|
|
+ self._get_downstream_ineligible_ops_usage(op).object_store_memory
|
|
)
|
|
|
|
def get_op_usage_str(self, op: PhysicalOperator, *, verbose: bool) -> str:
|
|
"""Return a human-readable string representation of the resource usage of
|
|
the given operator."""
|
|
# Handle case where operator is not in _op_running_usages dict
|
|
if op not in self._op_running_usages:
|
|
usage_str = "n/a"
|
|
else:
|
|
usage_str = f"{self._op_running_usages[op].cpu:.1f} CPU"
|
|
if self._op_running_usages[op].memory:
|
|
usage_str += f", {self._op_running_usages[op].memory_str()} memory"
|
|
if self._op_running_usages[op].gpu:
|
|
usage_str += f", {self._op_running_usages[op].gpu:.1f} GPU"
|
|
usage_str += f", {self._op_running_usages[op].object_store_memory_str()} object store"
|
|
|
|
# NOTE: Config can override requested verbosity level
|
|
if LOG_DEBUG_TELEMETRY_FOR_RESOURCE_MANAGER_OVERRIDE is not None:
|
|
verbose = LOG_DEBUG_TELEMETRY_FOR_RESOURCE_MANAGER_OVERRIDE
|
|
|
|
if verbose:
|
|
usage_str += (
|
|
f" (in={memory_string(self.get_mem_op_internal(op))},"
|
|
f"out={memory_string(self.get_mem_op_outputs(op))}"
|
|
)
|
|
# External-consumer bytes (iterator / streaming_split prefetch) are
|
|
# only attached to the output operator. Surface them in its line so
|
|
# users can see how much of `out` is held by the downstream iterator
|
|
# vs. the operator's own output queues.
|
|
if op is self._output_operator and self._has_external_consumer:
|
|
usage_str += (
|
|
f",external_consumer="
|
|
f"{memory_string(self._external_consumer_bytes)}"
|
|
)
|
|
usage_str += ")"
|
|
if self._op_resource_allocator is not None:
|
|
allocation = self._op_resource_allocator.get_allocation(op)
|
|
if allocation:
|
|
usage_str += f", alloc=(cpu={allocation.cpu:.1f}"
|
|
usage_str += f",mem={allocation.memory_str()}"
|
|
usage_str += f",gpu={allocation.gpu:.1f}"
|
|
usage_str += f",obj_store={allocation.object_store_memory_str()})"
|
|
|
|
budget = self._op_resource_allocator.get_budget(op)
|
|
if budget:
|
|
usage_str += f", budget=(cpu={budget.cpu:.1f}"
|
|
usage_str += f",mem={budget.memory_str()}"
|
|
usage_str += f",gpu={budget.gpu:.1f}"
|
|
usage_str += f",obj_store={budget.object_store_memory_str()}"
|
|
|
|
# Remaining memory budget for producing new task outputs.
|
|
if isinstance(
|
|
self._op_resource_allocator, ReservationOpResourceAllocator
|
|
):
|
|
reserved_for_output = memory_string(
|
|
self._op_resource_allocator._output_budgets.get(op, 0)
|
|
)
|
|
usage_str += f",out={reserved_for_output})"
|
|
|
|
return usage_str
|
|
|
|
def op_resource_allocator_enabled(self) -> bool:
|
|
"""Return whether OpResourceAllocator is enabled."""
|
|
return self._op_resource_allocator is not None
|
|
|
|
@property
|
|
def op_resource_allocator(self) -> "OpResourceAllocator":
|
|
"""Return the OpResourceAllocator."""
|
|
assert self._op_resource_allocator is not None
|
|
return self._op_resource_allocator
|
|
|
|
def get_budget(self, op: PhysicalOperator) -> Optional[ExecutionResources]:
|
|
"""Return the budget for the given operator, or None if the operator
|
|
has unlimited budget."""
|
|
if self._op_resource_allocator is None:
|
|
return None
|
|
return self._op_resource_allocator.get_budget(op)
|
|
|
|
def get_allocation(self, op: PhysicalOperator) -> Optional[ExecutionResources]:
|
|
"""Return the allocation of the given operator, or None if the operator
|
|
doesn't have a designated allocation."""
|
|
if self._op_resource_allocator is None:
|
|
return None
|
|
return self._op_resource_allocator.get_allocation(op)
|
|
|
|
def is_op_eligible(self, op: PhysicalOperator) -> bool:
|
|
"""Whether the op is eligible for memory reservation."""
|
|
return (
|
|
not op.throttling_disabled()
|
|
# As long as the op has finished execution, even if there are still
|
|
# non-taken outputs, we don't need to allocate resources for it.
|
|
and not op.has_execution_finished()
|
|
)
|
|
|
|
def _get_downstream_ineligible_ops(
|
|
self, op: PhysicalOperator
|
|
) -> Iterable[PhysicalOperator]:
|
|
"""Get the downstream ineligible operators of the given operator.
|
|
|
|
E.g.,
|
|
- "cur_map->downstream_map" will return an empty list.
|
|
- "cur_map->limit1->limit2->downstream_map" will return [limit1, limit2].
|
|
"""
|
|
for next_op in op.output_dependencies:
|
|
if not self.is_op_eligible(next_op):
|
|
yield next_op
|
|
yield from self._get_downstream_ineligible_ops(next_op)
|
|
|
|
def get_downstream_eligible_ops(
|
|
self, op: PhysicalOperator
|
|
) -> Iterable[PhysicalOperator]:
|
|
"""Get the downstream eligible operators of the given operator, ignoring
|
|
intermediate ineligible operators.
|
|
|
|
E.g.,
|
|
- "cur_map->downstream_map" will return [downstream_map].
|
|
- "cur_map->limit1->limit2->downstream_map" will return [downstream_map].
|
|
"""
|
|
for next_op in op.output_dependencies:
|
|
if self.is_op_eligible(next_op):
|
|
yield next_op
|
|
else:
|
|
yield from self.get_downstream_eligible_ops(next_op)
|
|
|
|
def max_task_output_bytes_to_read(self, op: PhysicalOperator) -> Optional[int]:
|
|
if self._op_resource_allocator is not None:
|
|
return self._op_resource_allocator.max_task_output_bytes_to_read(op)
|
|
return None
|
|
|
|
def _get_completed_ops_usage(self) -> ExecutionResources:
|
|
"""
|
|
Resource reservation is based on the number of eligible operators.
|
|
However, there might be completed operators that still have blocks in their output queue, which we need to exclude them from the reservation.
|
|
And we also need to exclude the downstream ineligible operators.
|
|
|
|
E.g., for the following pipeline:
|
|
```
|
|
map1 (completed, but still has blocks in its output queue) -> limit1 (ineligible, not completed) -> map2 (not completed) -> limit2 -> map3
|
|
```
|
|
|
|
The reservation is based on the number of eligible operators (map2 and map3), but we need to exclude map1 and limit1 from the reservation.
|
|
"""
|
|
|
|
last_completed_ops = []
|
|
ops_to_exclude = []
|
|
# Traverse operator tree collecting all operators that have already finished
|
|
for op in self._topology:
|
|
if not op.has_execution_finished():
|
|
for dep in op.input_dependencies:
|
|
if dep.has_execution_finished():
|
|
last_completed_ops.append(dep)
|
|
|
|
# In addition to completed operators,
|
|
# filter out downstream ineligible operators since they are omitted from reservation calculations.
|
|
for op in last_completed_ops:
|
|
ops_to_exclude.extend(list(self._get_downstream_ineligible_ops(op)))
|
|
ops_to_exclude.append(op)
|
|
|
|
completed_ops = list(set(ops_to_exclude))
|
|
completed_ops_usage = ExecutionResources.combine_sum(
|
|
self.get_op_usage(op) for op in completed_ops
|
|
)
|
|
|
|
return completed_ops_usage
|
|
|
|
def _is_blocking_materializing_op(self, op: PhysicalOperator) -> bool:
|
|
"""This method checks whether either
|
|
|
|
1. Operator itself or
|
|
2. One of operator's immediate *ineligible* downstream dependencies
|
|
|
|
Are blocking, materializing operators.
|
|
|
|
NOTE: That downstream ineligible operators are considered an "extension" of their
|
|
first preceding eligible operator from resource allocation standpoint.
|
|
"""
|
|
|
|
# Check if Op itself is a blocking, materializing operator
|
|
if isinstance(op, _BLOCKING_MATERIALIZING_OPERATORS):
|
|
return True
|
|
|
|
# Check if any of its direct *ineligible* downstream dependencies are
|
|
# blocking, materializing operators.
|
|
#
|
|
# NOTE: We only check ineligible downstream deps, since eligible downstream
|
|
# deps will have their own allocation that is adjusted appropriately
|
|
return any(
|
|
isinstance(op, _BLOCKING_MATERIALIZING_OPERATORS)
|
|
for op in self._get_downstream_ineligible_ops(op)
|
|
)
|
|
|
|
|
|
def _get_first_pending_materializing_op(topology: "Topology") -> int:
|
|
for idx, op in enumerate(topology):
|
|
if isinstance(op, _BLOCKING_MATERIALIZING_OPERATORS) and not op.has_completed():
|
|
return idx
|
|
|
|
return -1
|
|
|
|
|
|
class OpResourceAllocator(ABC):
|
|
"""An interface for dynamic operator resource allocation.
|
|
|
|
This interface allows dynamically allocating available resources to each operator,
|
|
limiting how many tasks each operator can submit, and how much data each operator
|
|
can read from its running tasks.
|
|
"""
|
|
|
|
def __init__(self, resource_manager: "ResourceManager"):
|
|
self._resource_manager = resource_manager
|
|
self._topology = resource_manager._topology
|
|
|
|
@abstractmethod
|
|
def update_budgets(
|
|
self,
|
|
*,
|
|
limits: ExecutionResources,
|
|
):
|
|
"""Callback to update resource usages."""
|
|
...
|
|
|
|
@abstractmethod
|
|
def can_submit_new_task(self, op: PhysicalOperator) -> bool:
|
|
"""Return whether the given operator can submit a new task."""
|
|
...
|
|
|
|
@abstractmethod
|
|
def max_task_output_bytes_to_read(self, op: PhysicalOperator) -> Optional[int]:
|
|
"""Return the maximum bytes of pending task outputs can be read for
|
|
the given operator. None means no limit."""
|
|
...
|
|
|
|
@abstractmethod
|
|
def get_budget(self, op: PhysicalOperator) -> Optional[ExecutionResources]:
|
|
"""Returns the budget for the given operator or `None` if the operator
|
|
has unlimited budget. Operator's budget is defined as:
|
|
|
|
Budget = Allocation - Usage
|
|
"""
|
|
...
|
|
|
|
@abstractmethod
|
|
def get_output_budget(self, op: PhysicalOperator) -> Optional[int]:
|
|
"""Returns the budget for operator's outputs (in object store bytes) or
|
|
`None` if there's no limit.
|
|
"""
|
|
...
|
|
|
|
@abstractmethod
|
|
def get_allocation(self, op: PhysicalOperator) -> Optional[ExecutionResources]:
|
|
"""Returns allocation for the given operator or `None` if operator's
|
|
allocation is unlimited."""
|
|
...
|
|
|
|
def _get_eligible_ops(self) -> List[PhysicalOperator]:
|
|
"""Returns a list of operators eligible for allocation.
|
|
|
|
Only operators upstream of the first non-completed *materializing* Operator,
|
|
(like shuffle, etc) are able to receive inputs and start execution.
|
|
|
|
Therefore only these operators are eligible for resource allocation.
|
|
"""
|
|
first_pending_materializing_op_idx = _get_first_pending_materializing_op(
|
|
self._topology
|
|
)
|
|
return [
|
|
op
|
|
for idx, op in enumerate(self._topology)
|
|
if self._is_op_eligible(op)
|
|
and (
|
|
first_pending_materializing_op_idx == -1
|
|
or idx <= first_pending_materializing_op_idx
|
|
)
|
|
]
|
|
|
|
@staticmethod
|
|
def _is_op_eligible(op: PhysicalOperator) -> bool:
|
|
"""Whether the op is eligible for memory reservation."""
|
|
return (
|
|
not op.throttling_disabled()
|
|
# As long as the op has finished execution, even if there are still
|
|
# non-taken outputs, we don't need to allocate resources for it.
|
|
and not op.has_execution_finished()
|
|
)
|
|
|
|
|
|
class ReservationOpResourceAllocator(OpResourceAllocator):
|
|
"""An OpResourceAllocator implementation that reserves resources for each operator.
|
|
|
|
This class reserves memory and CPU resources for eligible operators, and considers
|
|
runtime resource usages to limit the resources that each operator can use.
|
|
|
|
It works in the following way:
|
|
1. An operator is eligible for resource reservation, if it has enabled throttling
|
|
and hasn't completed. Ineligible operators are not throttled, but
|
|
their usage will be accounted for their upstream eligible operators. E.g., for
|
|
such a dataset "map1->limit->map2->streaming_split", we'll treat "map1->limit" as
|
|
a group and "map2->streaming_split" as another group.
|
|
2. For each eligible operator, we reserve `reservation_ratio * global_resources /
|
|
num_eligible_ops` resources, half of which is reserved only for the operator
|
|
outputs, excluding pending task outputs.
|
|
3. Non-reserved resources are shared among all operators.
|
|
4. In each scheduling iteration, each eligible operator will get "remaining of their
|
|
own reserved resources" + "remaining of shared resources / num_eligible_ops"
|
|
resources.
|
|
|
|
The `reservation_ratio` is set to 50% by default. Users can tune this value to
|
|
adjust how aggressive or conservative the resource allocation is. A higher value
|
|
will make the resource allocation more even, but may lead to underutilization and
|
|
worse performance. And vice versa.
|
|
"""
|
|
|
|
def __init__(self, resource_manager: ResourceManager, reservation_ratio: float):
|
|
super().__init__(resource_manager)
|
|
|
|
self._reservation_ratio = reservation_ratio
|
|
assert 0.0 <= self._reservation_ratio <= 1.0
|
|
# Per-op reserved resources, excluding `_reserved_for_op_outputs`.
|
|
self._op_reserved: Dict[PhysicalOperator, ExecutionResources] = {}
|
|
# Memory reserved exclusively for the outputs of each operator.
|
|
# "Op outputs" refer to blocks that have been taken out of an operator,
|
|
# i.e., `RessourceManager._mem_op_outputs`.
|
|
#
|
|
# Note, if we don't reserve memory for op outputs, all the budget may be used by
|
|
# the pending task outputs, and/or op's internal output buffers (the latter can
|
|
# happen when `preserve_order=True`).
|
|
# Then we'll have no budget to pull blocks from the op.
|
|
self._reserved_for_op_outputs: Dict[PhysicalOperator, float] = {}
|
|
# Total shared resources.
|
|
self._total_shared = ExecutionResources.zero()
|
|
# Resource budgets for each operator, excluding `_reserved_for_op_outputs`.
|
|
self._op_budgets: Dict[PhysicalOperator, ExecutionResources] = {}
|
|
# Remaining memory budget for generating new task outputs, per operator.
|
|
self._output_budgets: Dict[PhysicalOperator, float] = {}
|
|
# Whether each operator has reserved the minimum resources to run
|
|
# at least one task.
|
|
# This is used to avoid edge cases where the entire resource limits are not
|
|
# enough to run one task of each op.
|
|
# See `test_no_deadlock_on_small_cluster_resources` as an example.
|
|
self._reserved_min_resources: Dict[PhysicalOperator, bool] = {}
|
|
# `time.monotonic()` timestamp at which each operator most recently became
|
|
# starved of its minimum resources, or None if it currently has them.
|
|
self._op_starved_since: Dict[PhysicalOperator, Optional[float]] = {}
|
|
|
|
def _update_reservation(self, limits: ExecutionResources):
|
|
eligible_ops = self._get_eligible_ops()
|
|
|
|
self._op_reserved.clear()
|
|
self._reserved_for_op_outputs.clear()
|
|
self._reserved_min_resources.clear()
|
|
|
|
if len(eligible_ops) == 0:
|
|
return
|
|
|
|
remaining = limits.copy()
|
|
|
|
# Reserve `reservation_ratio * global_limits / num_ops` resources for each
|
|
# operator.
|
|
default_reserved = limits.scale(self._reservation_ratio / (len(eligible_ops)))
|
|
for index, op in enumerate(eligible_ops):
|
|
# Reserve at least half of the default reserved resources for the outputs.
|
|
# This makes sure that we will have enough budget to pull blocks from the
|
|
# op.
|
|
reserved_for_outputs = ExecutionResources(
|
|
0, 0, max(default_reserved.object_store_memory / 2, 1)
|
|
)
|
|
|
|
reserved_for_tasks = default_reserved.subtract(reserved_for_outputs)
|
|
|
|
min_resource_usage, max_resource_usage = op.min_max_resource_requirements()
|
|
|
|
if min_resource_usage is not None:
|
|
reserved_for_tasks = reserved_for_tasks.max(min_resource_usage)
|
|
if max_resource_usage is not None:
|
|
reserved_for_tasks = reserved_for_tasks.min(max_resource_usage)
|
|
|
|
# Check if the remaining resources are enough for both reserved_for_tasks
|
|
# and reserved_for_outputs. Note, we only consider CPU and GPU, but not
|
|
# object_store_memory, because object_store_memory can be oversubscribed,
|
|
# but CPU/GPU cannot.
|
|
if reserved_for_tasks.add(reserved_for_outputs).satisfies_limit(
|
|
remaining, ignore_object_store_memory=True
|
|
):
|
|
self._reserved_min_resources[op] = True
|
|
self._op_starved_since[op] = None
|
|
else:
|
|
self._reserved_min_resources[op] = False
|
|
if self._op_starved_since.get(op) is None:
|
|
self._op_starved_since[op] = time.monotonic()
|
|
|
|
# If the remaining resources are not enough to reserve the minimum
|
|
# resources for this operator, we'll only reserve the minimum object
|
|
# store memory, but not the CPU and GPU resources.
|
|
# Because Ray Core doesn't allow CPU/GPU resources to be oversubscribed.
|
|
# NOTE: we prioritize upstream operators for minimum resource reservation.
|
|
# ops. It's fine that downstream ops don't get the minimum reservation,
|
|
# because they can wait for upstream ops to finish and release resources.
|
|
reserved_for_tasks = ExecutionResources(
|
|
0, 0, min_resource_usage.object_store_memory
|
|
)
|
|
|
|
# Log a warning if even the first operator cannot reserve the minimum
|
|
# resources.
|
|
if index == 0:
|
|
self._warn_if_op_starved_too_long(op)
|
|
|
|
self._op_reserved[op] = reserved_for_tasks
|
|
self._reserved_for_op_outputs[op] = reserved_for_outputs.object_store_memory
|
|
|
|
op_total_reserved = reserved_for_tasks.add(reserved_for_outputs)
|
|
remaining = remaining.subtract(op_total_reserved)
|
|
remaining = remaining.max(ExecutionResources.zero())
|
|
|
|
self._total_shared = remaining
|
|
|
|
def _warn_if_op_starved_too_long(self, op: PhysicalOperator) -> None:
|
|
# The operator isn't starved. Return early.
|
|
if self._op_starved_since.get(op) is None:
|
|
return
|
|
|
|
op_starved_duration = time.monotonic() - self._op_starved_since[op]
|
|
if (
|
|
op_starved_duration >= STARVATION_WARNING_DELAY_S
|
|
# Add `id(self)` to the log_once key so that it will be logged once per
|
|
# execution.
|
|
and log_once(f"starvation_warning_{id(self)}")
|
|
):
|
|
logger.warning(
|
|
f"Cluster resources are not enough to run any task from {op}."
|
|
" The job may hang forever unless the cluster scales up."
|
|
)
|
|
|
|
def can_submit_new_task(self, op: PhysicalOperator) -> bool:
|
|
"""Return whether the given operator can submit a new task based on budget."""
|
|
budget = self.get_budget(op)
|
|
|
|
if budget is None:
|
|
return True
|
|
|
|
return (
|
|
op.incremental_resource_usage().satisfies_limit(budget)
|
|
and
|
|
# Avoid scheduling if there's no more Object Store budget (for
|
|
# task outputs)
|
|
budget.object_store_memory
|
|
>= (op.metrics.obj_store_mem_max_pending_output_per_task or 0)
|
|
)
|
|
|
|
def get_budget(self, op: PhysicalOperator) -> Optional[ExecutionResources]:
|
|
return self._op_budgets.get(op)
|
|
|
|
def get_output_budget(self, op: PhysicalOperator) -> Optional[int]:
|
|
return self._output_budgets.get(op)
|
|
|
|
def get_allocation(self, op: PhysicalOperator) -> Optional[ExecutionResources]:
|
|
budget = self.get_budget(op)
|
|
if budget is None:
|
|
return None
|
|
return budget.add(self._resource_manager.get_op_usage(op))
|
|
|
|
def _get_total_reserved(self, op: PhysicalOperator) -> ExecutionResources:
|
|
"""Get total reserved resources for an operator, including outputs reservation."""
|
|
op_reserved = self._op_reserved[op]
|
|
reserved_for_outputs = self._reserved_for_op_outputs[op]
|
|
return op_reserved.copy(
|
|
object_store_memory=op_reserved.object_store_memory + reserved_for_outputs
|
|
)
|
|
|
|
def max_task_output_bytes_to_read(self, op: PhysicalOperator) -> Optional[int]:
|
|
if op not in self._op_budgets:
|
|
return None
|
|
|
|
res = self._op_budgets[op].object_store_memory
|
|
# Add the remaining of `_reserved_for_op_outputs`.
|
|
op_outputs_usage = self._resource_manager.get_mem_op_outputs(
|
|
op, include_ineligible_downstream=True
|
|
)
|
|
|
|
res += max(self._reserved_for_op_outputs[op] - op_outputs_usage, 0)
|
|
if math.isinf(res):
|
|
self._output_budgets[op] = res
|
|
return None
|
|
|
|
res = int(res)
|
|
assert res >= 0
|
|
self._output_budgets[op] = res
|
|
return res
|
|
|
|
def update_budgets(
|
|
self,
|
|
*,
|
|
limits: ExecutionResources,
|
|
):
|
|
# Remaining resources to be distributed across operators
|
|
remaining_shared = self._update_reservation(limits)
|
|
|
|
self._op_budgets.clear()
|
|
eligible_ops = self._get_eligible_ops()
|
|
if len(eligible_ops) == 0:
|
|
return
|
|
|
|
# Remaining of shared resources.
|
|
remaining_shared = self._total_shared
|
|
for op in eligible_ops:
|
|
# Calculate the memory usage of the operator.
|
|
op_mem_usage = 0
|
|
# Add the memory usage of the operator itself,
|
|
# excluding `_reserved_for_op_outputs`.
|
|
op_mem_usage += self._resource_manager.get_mem_op_internal(op)
|
|
# Add the portion of op outputs usage that has
|
|
# exceeded `_reserved_for_op_outputs`.
|
|
op_outputs_usage = self._resource_manager.get_mem_op_outputs(
|
|
op, include_ineligible_downstream=True
|
|
)
|
|
op_mem_usage += max(op_outputs_usage - self._reserved_for_op_outputs[op], 0)
|
|
|
|
op_usage = self._resource_manager.get_op_usage(op).copy(
|
|
object_store_memory=op_mem_usage
|
|
)
|
|
|
|
op_reserved = self._op_reserved[op]
|
|
# How much of the reserved resources are remaining.
|
|
op_reserved_remaining = op_reserved.subtract(op_usage).max(
|
|
ExecutionResources.zero()
|
|
)
|
|
|
|
self._op_budgets[op] = op_reserved_remaining
|
|
# How much of the reserved resources are exceeded.
|
|
# If exceeded, we need to subtract from the remaining shared resources.
|
|
op_reserved_exceeded = op_usage.subtract(op_reserved).max(
|
|
ExecutionResources.zero()
|
|
)
|
|
remaining_shared = remaining_shared.subtract(op_reserved_exceeded)
|
|
|
|
remaining_shared = remaining_shared.max(ExecutionResources.zero())
|
|
|
|
# Allocate the remaining shared resources to each operator.
|
|
for i, op in enumerate(reversed(eligible_ops)):
|
|
# By default, divide the remaining shared resources equally.
|
|
op_shared = remaining_shared.scale(1.0 / (len(eligible_ops) - i))
|
|
# But if the op's budget is less than `min_scheduling_resources`,
|
|
# it will be useless. So we'll let the downstream operator
|
|
# borrow some resources from the upstream operator, if remaining_shared
|
|
# is still enough.
|
|
to_borrow = (
|
|
op.min_scheduling_resources()
|
|
.subtract(self._op_budgets[op].add(op_shared))
|
|
.max(ExecutionResources.zero())
|
|
)
|
|
if not to_borrow.is_zero() and op_shared.add(to_borrow).satisfies_limit(
|
|
remaining_shared
|
|
):
|
|
op_shared = op_shared.add(to_borrow)
|
|
|
|
# Cap op_shared so that total allocation doesn't exceed max_resource_usage.
|
|
# Total allocation = max(total_reserved, op_usage) + op_shared
|
|
# This ensures excess resources stay in remaining_shared for other operators.
|
|
_, max_resource_usage = op.min_max_resource_requirements()
|
|
if max_resource_usage != ExecutionResources.inf():
|
|
total_reserved = self._get_total_reserved(op)
|
|
op_usage = self._resource_manager.get_op_usage(op)
|
|
current_allocation = total_reserved.max(op_usage)
|
|
max_shared = max_resource_usage.subtract(current_allocation).max(
|
|
ExecutionResources.zero()
|
|
)
|
|
op_shared = op_shared.min(max_shared)
|
|
|
|
remaining_shared = remaining_shared.subtract(op_shared)
|
|
assert remaining_shared.is_non_negative(), (
|
|
remaining_shared,
|
|
op,
|
|
op_shared,
|
|
to_borrow,
|
|
)
|
|
|
|
self._op_budgets[op] = self._op_budgets[op].add(op_shared)
|
|
|
|
# Give any remaining shared resources to the most downstream uncapped op.
|
|
# This can happen when some ops have their shared allocation capped.
|
|
if eligible_ops and not remaining_shared.is_zero():
|
|
for op in reversed(eligible_ops):
|
|
_, max_resource_usage = op.min_max_resource_requirements()
|
|
if max_resource_usage == ExecutionResources.inf():
|
|
self._op_budgets[op] = self._op_budgets[op].add(remaining_shared)
|
|
break
|
|
|
|
# A materializing operator like `AllToAllOperator` waits for all its input
|
|
# operator's outputs before processing data. This often forces the input
|
|
# operator to exceed its object store memory budget. To prevent deadlock, we
|
|
# disable object store memory backpressure for the input operator.
|
|
for op in eligible_ops:
|
|
if self._resource_manager._is_blocking_materializing_op(op):
|
|
self._op_budgets[op] = self._op_budgets[op].copy(
|
|
object_store_memory=float("inf")
|
|
)
|