Repository navigation
[Data] Suppress cluster scale-up when pipeline is backpressured by iteration #66569
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 3 commits
5e5200a
5159bbb
fd01baa
34f3b25
c12bbd8
fd6e23f
47a3b87
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -277,6 +277,42 @@ 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_usage_excluding_consumer_blocked_ops(self) -> ExecutionResources: | ||
| """Return the global usage, excluding ops blocked on a slow consumer. | ||
|
|
||
| An op is excluded if it's blocked on downstream and all its downstream ops | ||
| are excluded too, i.e., the chain of blocked ops ends at the consumer. | ||
| """ | ||
| excluded_ops = set() | ||
| # Visit downstream ops first. | ||
| for op in reversed(self._topology): | ||
| if self._is_blocked_on_downstream(op) and all( | ||
| downstream_op in excluded_ops | ||
| for downstream_op in self.get_downstream_eligible_ops(op) | ||
| ): | ||
| excluded_ops.add(op) | ||
| excluded_ops.update(self._get_downstream_ineligible_ops(op)) | ||
|
|
||
| return ExecutionResources.combine_sum( | ||
| usage for op, usage in self._op_usages.items() if op not in excluded_ops | ||
| ) | ||
|
|
||
| def _is_blocked_on_downstream(self, op: PhysicalOperator) -> bool: | ||
| """Whether the op is waiting for its outputs to be read downstream.""" | ||
| if op.in_task_output_backpressure: | ||
| return True | ||
| if op.num_active_tasks() > 0 or not op.in_task_submission_backpressure: | ||
| return False | ||
| # More nodes help if the budget can't fit another task's CPU, GPU, or memory. | ||
| budget = ( | ||
| self._op_resource_allocator.get_budget(op) | ||
| if self._op_resource_allocator is not None | ||
| else None | ||
| ) | ||
| return budget is None or op.incremental_resource_usage().satisfies_limit( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you help me understand the 2nd part of the if statement. Why would we consider the operator blocked if it can submit another task within its budget?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We checked
I reconstructed the logic to make it clearer.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see, can we rename and update the docstring to highlight that this is only checking if adding more nodes will help non-blocked operators. So this function is only if object store memory available for op? I think it's still ambiguous that we exclude resources for
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I renamed it to |
||
| budget, ignore_object_store_memory=True | ||
| ) | ||
|
Yonghui-Lee marked this conversation as resolved.
Outdated
|
||
|
|
||
| def get_global_limits(self) -> ExecutionResources: | ||
| """Return the global resource limits at the current time. | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| """Tests that Ray Data doesn't scale up while blocked on a slow consumer (#45331).""" | ||
|
|
||
| import time | ||
| from unittest.mock import MagicMock, patch | ||
|
|
||
| import pytest | ||
|
|
||
| import ray | ||
| from ray.data._internal.cluster_autoscaler import ( | ||
| CLUSTER_AUTOSCALER_ENV_KEY, | ||
| rate_based_cluster_autoscaler, | ||
| resource_utilization_gauge, | ||
| ) | ||
| from ray.data._internal.cluster_autoscaler.default_cluster_autoscaler_v2 import ( | ||
| DefaultClusterAutoscalerV2, | ||
| _NodeResourceSpec, | ||
| ) | ||
| from ray.data._internal.cluster_autoscaler.rate_based_cluster_autoscaler import ( | ||
| RateBasedClusterAutoscaler, | ||
| ) | ||
| from ray.data._internal.cluster_autoscaler.resource_utilization_gauge import ( | ||
| RollingLogicalUtilizationGauge, | ||
| ) | ||
| from ray.data._internal.execution.interfaces.execution_options import ( | ||
| ExecutionResources, | ||
| ) | ||
| from ray.data._internal.util import GiB, MiB | ||
| from ray.data.tests.conftest import * # noqa | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("exclude, expected_cpu_util", [(True, 0.25), (False, 1.0)]) | ||
| def test_gauge_excludes_consumer_blocked_usage(exclude, expected_cpu_util): | ||
| resource_manager = MagicMock() | ||
| resource_manager.get_global_limits.return_value = ExecutionResources(cpu=8) | ||
| resource_manager.get_global_usage.return_value = ExecutionResources(cpu=8) | ||
| resource_manager.get_global_usage_excluding_consumer_blocked_ops.return_value = ( | ||
| ExecutionResources(cpu=2) | ||
| ) | ||
|
|
||
| with patch.object(resource_utilization_gauge, "Gauge") as mock_gauge_cls: | ||
| gauge = RollingLogicalUtilizationGauge( | ||
| resource_manager, | ||
| execution_id="ds", | ||
| exclude_consumer_blocked_usage=exclude, | ||
| ) | ||
| gauge.observe() | ||
|
|
||
| assert gauge.get().cpu == pytest.approx(expected_cpu_util) | ||
| # Exported metrics always report raw utilization. | ||
| mock_gauge_cls.return_value.set.assert_any_call(100, tags={"dataset": "ds"}) | ||
|
|
||
|
|
||
| @pytest.fixture(params=["V2", "RATE_BASED"]) | ||
| def scale_up_requests(request, monkeypatch): | ||
| """Use the given autoscaler, make it decide fast, and record scale-up times.""" | ||
| monkeypatch.setenv(CLUSTER_AUTOSCALER_ENV_KEY, request.param) | ||
| requests = [] | ||
|
|
||
| if request.param == "V2": | ||
| orig_init = DefaultClusterAutoscalerV2.__init__ | ||
| orig_log = DefaultClusterAutoscalerV2._log_resource_request | ||
|
|
||
| def fast_init(self, *args, **kwargs): | ||
| kwargs["min_gap_between_autoscaling_requests_s"] = 0.2 | ||
| kwargs["cluster_util_avg_window_s"] = 1 | ||
| # A local cluster has no worker node shapes to request more of. | ||
| node_spec = _NodeResourceSpec.of(cpu=1, mem=GiB) | ||
| kwargs["get_node_counts"] = lambda: {node_spec: 1} | ||
| orig_init(self, *args, **kwargs) | ||
|
|
||
| # V2 only logs a request that adds nodes. | ||
| def log(self, *args): | ||
| requests.append(time.time()) | ||
| orig_log(self, *args) | ||
|
|
||
| monkeypatch.setattr(DefaultClusterAutoscalerV2, "__init__", fast_init) | ||
| monkeypatch.setattr(DefaultClusterAutoscalerV2, "_log_resource_request", log) | ||
| else: | ||
| orig_init = RateBasedClusterAutoscaler.__init__ | ||
| orig_send = RateBasedClusterAutoscaler._send_resource_request | ||
|
|
||
| def fast_gauge(resource_manager, **kwargs): | ||
| kwargs["cluster_util_avg_window_s"] = 1 | ||
| return RollingLogicalUtilizationGauge(resource_manager, **kwargs) | ||
|
|
||
| def fast_init(self, *args, **kwargs): | ||
| kwargs["min_gap_between_autoscaling_requests_s"] = 0.2 | ||
| orig_init(self, *args, **kwargs) | ||
|
|
||
| # Rate-based sends `None` when utilization is too low to scale up. | ||
| def send(self, resource_request): | ||
| if resource_request: | ||
| requests.append(time.time()) | ||
| return orig_send(self, resource_request) | ||
|
|
||
| monkeypatch.setattr( | ||
| rate_based_cluster_autoscaler, "RollingLogicalUtilizationGauge", fast_gauge | ||
| ) | ||
| monkeypatch.setattr(RateBasedClusterAutoscaler, "__init__", fast_init) | ||
| monkeypatch.setattr(RateBasedClusterAutoscaler, "_send_resource_request", send) | ||
|
|
||
| return requests | ||
|
|
||
|
|
||
| def _scale_up_stops(scale_up_requests, quiet_s=3, timeout_s=30): | ||
| """Return whether scale-up requests stop for `quiet_s` seconds within | ||
| `timeout_s` seconds. Requests while the pipeline fills up are fine.""" | ||
| start = time.time() | ||
| while time.time() - start < timeout_s: | ||
| if time.time() - max([start, *scale_up_requests]) >= quiet_s: | ||
| return True | ||
| time.sleep(0.1) | ||
| return False | ||
|
|
||
|
|
||
| def test_no_scale_up_while_consumer_is_slow( | ||
| ray_start_10_cpus_shared, restore_data_context, scale_up_requests | ||
| ): | ||
| ctx = ray.data.DataContext.get_current() | ||
| ctx.execution_options.resource_limits = ExecutionResources.for_limits( | ||
| object_store_memory=20 * MiB | ||
| ) | ||
|
|
||
| ds = ray.data.range(200, override_num_blocks=40).map_batches( | ||
| lambda batch: {"data": [b"x" * (2 * MiB) for _ in batch["id"]]}, | ||
| batch_size=1, | ||
| ) | ||
| it = iter(ds.iter_batches(batch_size=1, prefetch_batches=0)) | ||
| next(it) | ||
| # The consumer stays paused while the tasks wait in output backpressure. | ||
| stopped = _scale_up_stops(scale_up_requests) | ||
| del it | ||
|
|
||
| assert stopped | ||
|
|
||
|
|
||
| def test_no_scale_up_while_consumer_is_slow_two_ops( | ||
| ray_start_10_cpus_shared, restore_data_context, scale_up_requests | ||
| ): | ||
| ctx = ray.data.DataContext.get_current() | ||
| ctx.execution_options.resource_limits = ExecutionResources.for_limits( | ||
| object_store_memory=20 * MiB | ||
| ) | ||
|
|
||
| # One task at a time, so tasks finish and both ops sit idle with queued outputs. | ||
| ds = ( | ||
| ray.data.from_items(list(range(200)), override_num_blocks=200).map_batches( | ||
| lambda batch: {"data": [b"x" * MiB for _ in batch["item"]]}, | ||
| num_cpus=0.5, | ||
| concurrency=1, | ||
| ) | ||
| # A different `num_cpus` keeps the two maps from being fused. | ||
| .map_batches(lambda batch: batch, num_cpus=1, concurrency=1) | ||
| ) | ||
| it = iter(ds.iter_batches(batch_size=None, prefetch_batches=0)) | ||
| next(it) | ||
| stopped = _scale_up_stops(scale_up_requests) | ||
| del it | ||
|
|
||
| assert stopped | ||
|
|
||
|
|
||
| def test_scale_up_when_compute_bound( | ||
| ray_start_10_cpus_shared, restore_data_context, scale_up_requests | ||
| ): | ||
| def slow_udf(batch): | ||
| time.sleep(1) | ||
| return batch | ||
|
|
||
| # 40 tasks keep the 10 CPUs busy for about 4 seconds. | ||
| ds = ray.data.range(40, override_num_blocks=40).map_batches( | ||
| slow_udf, batch_size=None, num_cpus=1 | ||
| ) | ||
| for _ in ds.iter_batches(batch_size=None): | ||
| pass | ||
|
|
||
| assert scale_up_requests | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| import sys | ||
|
|
||
| sys.exit(pytest.main(["-v", __file__])) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can remove the
excluded_opsset, and just iterate in reverse order (skipping ineligible operators) on the fly. As soon as one operator isn't_is_blocked_on_downstream, return the usage (we can update this incrementally).I recommend this approach because thinking about ineligble and eligble ops makes it hard to read. We can then remove the first if statement in
_is_blocked_on_downstreamThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for your advice, I revised.