128 lines
4.9 KiB
Python
128 lines
4.9 KiB
Python
from typing import Dict, TypeVar
|
|
|
|
from ray.data._internal.execution.interfaces import ExecutionResources
|
|
|
|
# The math functions defined in this module use a generic type rather than
|
|
# `PhysicalOperator` so it's easier to test. We already pass in all of the necessary
|
|
# inputs, so the actual type doesn't matter.
|
|
T = TypeVar("T")
|
|
|
|
|
|
_SCHEDULABLE_RESOURCE_NAMES = ("cpu", "gpu", "memory")
|
|
|
|
|
|
def allocate_resources(
|
|
throughput: float,
|
|
*,
|
|
rates: Dict[T, float],
|
|
resource_requirements: Dict[T, ExecutionResources],
|
|
) -> Dict[T, ExecutionResources]:
|
|
"""Allocate resources for a pipeline to sustain the given throughput.
|
|
|
|
Key insight: in a pipeline, all operators must sustain the same throughput T.
|
|
Operator i with per-task rate r_i needs T/r_i tasks to sustain T. So maximizing
|
|
throughput is equivalent to finding the largest feasible T, then deriving task
|
|
counts from it.
|
|
|
|
Args:
|
|
throughput: The throughput for the pipeline in the same units as the rates.
|
|
rates: The rate at which a task or actor produces outputs for each operator.
|
|
resource_requirements: The logical resources required to schedule a task or
|
|
actor for each operator.
|
|
|
|
Returns:
|
|
A dictionary mapping operators to the allocated resources.
|
|
"""
|
|
assert throughput >= 0, "Throughput must be non-negative"
|
|
assert all(rate > 0 for rate in rates.values()), "Rates must be positive"
|
|
|
|
if not rates:
|
|
return {}
|
|
|
|
if throughput == 0:
|
|
return {op: ExecutionResources.zero() for op in rates}
|
|
|
|
# NOTE: This implementation computes fractional task counts. In practice, you
|
|
# can't schedule a fractional task or actor, so the allocations might be infeasible.
|
|
task_counts = {op: throughput / rate for op, rate in rates.items()}
|
|
return {op: resource_requirements[op].scale(task_counts[op]) for op in rates}
|
|
|
|
|
|
def compute_optimal_throughput(
|
|
*,
|
|
rates: Dict[T, float],
|
|
resource_requirements: Dict[T, ExecutionResources],
|
|
resource_limits: ExecutionResources,
|
|
concurrency_limits: Dict[T, int | None],
|
|
) -> float:
|
|
"""Compute the optimal throughput for a pipeline.
|
|
|
|
The optimal throughput is bounded by two constraints (we take the tightest):
|
|
1. Resource limits — total resource usage across all operators must fit the
|
|
budget.
|
|
2. Concurrency limits — each operator's task count cannot exceed its limit.
|
|
|
|
Args:
|
|
rates: The rate at which a task or actor produces outputs for each operator.
|
|
resource_requirements: The logical resources required to schedule a task or
|
|
actor for each operator.
|
|
resource_limits: The resource limits for the cluster.
|
|
concurrency_limits: The maximum number of tasks or actors that can be scheduled
|
|
concurrently for each operator.
|
|
|
|
Returns:
|
|
The optimal throughput for the pipeline in the same units as the rates.
|
|
"""
|
|
assert rates, "Rates must be non-empty"
|
|
return min(
|
|
_max_throughput_from_resources(rates, resource_requirements, resource_limits),
|
|
_max_throughput_from_concurrency(rates, concurrency_limits),
|
|
)
|
|
|
|
|
|
def _max_throughput_from_resources(
|
|
rates: Dict[T, float],
|
|
resource_requirements: Dict[T, ExecutionResources],
|
|
resource_limits: ExecutionResources,
|
|
) -> float:
|
|
"""For each resource type, compute the max throughput the resource budget allows."""
|
|
assert rates, "Rates must be non-empty"
|
|
assert all(rate > 0 for rate in rates.values()), "Rates must be positive"
|
|
assert (
|
|
rates.keys() <= resource_requirements.keys()
|
|
), "You must provide a resource requirement for each operator with a rate."
|
|
|
|
max_throughput = float("inf")
|
|
|
|
for resource_name in _SCHEDULABLE_RESOURCE_NAMES:
|
|
resource_limit = getattr(resource_limits, resource_name)
|
|
resource_cost_per_unit_throughput = sum(
|
|
getattr(resource_requirements[op], resource_name) / rates[op]
|
|
for op in rates
|
|
)
|
|
if resource_cost_per_unit_throughput > 0:
|
|
max_throughput = min(
|
|
max_throughput, resource_limit / resource_cost_per_unit_throughput
|
|
)
|
|
|
|
assert max_throughput >= 0, "Max throughput must be non-negative"
|
|
return max_throughput
|
|
|
|
|
|
def _max_throughput_from_concurrency(
|
|
rates: Dict[T, float],
|
|
concurrency_limits: Dict[T, int | None],
|
|
) -> float:
|
|
"""Each operator's throughput is capped at rate * concurrency_limit."""
|
|
assert rates, "Rates must be non-empty"
|
|
assert (
|
|
rates.keys() <= concurrency_limits.keys()
|
|
), "You must provide a concurrency limit for each operator with a rate."
|
|
|
|
# Convert `None` to float("inf") for operators with no concurrency limit
|
|
normalized_concurrency_limits: Dict[T, float] = {
|
|
op: limit if limit is not None else float("inf")
|
|
for op, limit in concurrency_limits.items()
|
|
}
|
|
return min(rates[op] * normalized_concurrency_limits[op] for op in rates)
|