Skip to content

[autoscaler] Improve V2 scheduler pre-filter precision for multi-resource demands - #65171

Open
Jade07-1 wants to merge 3 commits into
ray-project:masterfrom
Jade07-1:fix/upstream-prefilter-and-logic
Open

[autoscaler] Improve V2 scheduler pre-filter precision for multi-resource demands#65171
Jade07-1 wants to merge 3 commits into
ray-project:masterfrom
Jade07-1:fix/upstream-prefilter-and-logic

Conversation

@Jade07-1

@Jade07-1 Jade07-1 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Why are these changes needed?

Since #64175, I've been continuing to optimize the V2 autoscaler scheduling path for large clusters — including demand vector truncation, SerializeToString hotspot elimination, and homogeneous-batch fast-path. During benchmarking across different workload configurations, I noticed the quick-reject pre-filter from #64175 becomes ineffective when task resource demands are multi-dimensional but only one dimension saturates the node.

Root cause: The pre-filter uses OR logic across resource dimensions — a node is kept in the scheduling loop if any single dimension meets the minimum demand. For workloads like @ray.remote(num_cpus=0.2, memory=30MB) on nodes with 1 CPU + 1 GB memory, after 5 tasks fill the CPU, the node still has ~850 MB memory available. The OR check sees memory >= 30MB → returns True → the node is NOT skipped → expensive deepcopy + try_schedule runs on every reconcile round for thousands of fully-loaded nodes.

Changes

Change the pre-filter to use AND logic within each resource shape and OR logic across shapes: a node is only kept if it can satisfy ALL dimensions of at least one pending request shape simultaneously.

  • Rename _compute_min_resource_demand()_collect_unique_resource_shapes(): returns a deduplicated list of complete resource bundles instead of collapsing all dimensions into per-key minimums.
  • Update _can_fit_any_request(): checks each shape holistically (all() within a shape) instead of checking dimensions independently.

Complexity: O(S × D) where S = number of unique shapes (typically 1–10), D = dimensions per shape (typically 2–4). For the common single-shape workload, S=1 and cost is identical to the previous O(D).

Benchmark

Scaling to 3000 nodes (15000 tasks × 0.2 CPU + 30 MB memory, 1 CPU per worker):

The OR pre-filter fails to skip nodes when CPU is exhausted but memory has headroom — the common case for small-memory tasks on large-memory nodes. With AND logic, these nodes are correctly skipped:

Metric Before (OR) After (AND)
Time to 3000 nodes ~50 min ~18 min

Related issue number

Follow-up to #64175. This does NOT address the UnschedulableRequestCache.contains() bottleneck noted there (separate follow-up).

Checks

  • I've signed all my commits
  • I've run scripts/format.sh to lint the changes in this PR.
  • I've included any doc changes needed for https://docs.ray.io/en/master/.
  • I've made sure the tests are passing. Testing Strategy:
    • Unit tests covering: single-shape AND reject, mixed-shape OR keep, mixed-shape all-exhausted reject.

wangjia23 and others added 2 commits August 3, 2026 17:32
…urce demands

The quick-reject pre-filter in `_try_schedule` previously used OR logic
across resource dimensions: a node was kept in the scheduling loop if ANY
single dimension met the minimum demand. This caused the optimization to
be ineffective when one dimension (e.g., CPU) was exhausted but another
(e.g., memory) had plenty of headroom.

Change the pre-filter to use AND logic within each resource shape and OR
logic across shapes: a node is only kept if it can satisfy ALL dimensions
of at least one pending request shape simultaneously.

Benchmark (scaling to 3000 nodes, 15000 tasks × 0.2 CPU + 30MB memory):
- Before: pre-filter ineffective, ~2h+ to reach 3000 nodes
- After: pre-filter correctly skips CPU-exhausted nodes, ~1081s

Signed-off-by: wangjia23 <wangjia23@xiaomi.com>
… add mixed-shape tests

- Rename _compute_min_resource_demand -> _collect_unique_resource_shapes
- Rename parameter/variable min_resource_demand -> resource_shapes
- Update call-site comment to clarify AND-within-shape, OR-across-shapes
- Add test_quick_reject_mixed_shapes_or_across_shapes: verifies nodes are
  kept when at least one shape fits (OR across shapes)
- Add test_quick_reject_mixed_shapes_all_exhausted: verifies nodes are
  rejected when no shape fits

Signed-off-by: wangjia23 <wangjia23@xiaomi.com>

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: wangjia23 <wangjia23@xiaomi.com>
@Jade07-1
Jade07-1 requested a review from a team as a code owner August 3, 2026 13:01

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the scheduler's quick feasibility pre-check by replacing the minimum resource demand calculation with a check based on unique resource shapes. This ensures that all resource dimensions of a request shape are satisfied simultaneously (AND logic) rather than independently. New unit tests are added to cover various rejection scenarios. The reviewer identified a critical issue in _can_fit_any_request where implicit resources default to 0.0 instead of 1.0, which could cause valid nodes to be incorrectly skipped, and provided a code suggestion to resolve this.

Comment on lines +168 to 173
if not resource_shapes:
return True
for k, min_v in min_resource_demand.items():
if available.get(k, 0.0) >= min_v:
for shape in resource_shapes:
if all(available.get(k, 0.0) >= v for k, v in shape.items()):
return True
return False

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of _can_fit_any_request uses available.get(k, 0.0) which defaults to 0.0 for missing resource keys. However, for implicit resources (which start with ray._raylet.IMPLICIT_RESOURCE_PREFIX), the default value on a node is 1.0 if not explicitly present (as implemented in _fits).\n\nWith the new all() check (AND logic), if a request shape contains both a standard resource (like CPU) and an implicit resource, and the node does not explicitly list the implicit resource, available.get(implicit_resource, 0.0) >= v will evaluate to False (since 0.0 < v). This causes the entire shape check to fail, and the node is incorrectly skipped (pre-filtered) even though it actually has enough resources.\n\nTo fix this, we should default the available resource value to 1.0 for implicit resources, matching the behavior in _fits.

    if not resource_shapes:\n        return True\n    import ray\n    implicit_prefix = ray._raylet.IMPLICIT_RESOURCE_PREFIX\n    for shape in resource_shapes:\n        if all(\n            available.get(k, 1.0 if k.startswith(implicit_prefix) else 0.0) >= v\n            for k, v in shape.items()\n        ):\n            return True\n    return False

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 069a835. Configure here.

Comment thread python/ray/autoscaler/v2/scheduler.py Outdated
@ray-gardener ray-gardener Bot added core Issues that should be addressed in Ray Core community-contribution Contributed by the community labels Aug 3, 2026
@rueian rueian self-assigned this Aug 3, 2026
@rueian rueian added the go add ONLY when ready to merge, run all tests label Aug 3, 2026
@rueian

rueian commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Hi @Jade07-1, there is a test failure:


[2026-08-03T13:26:35Z] =================================== FAILURES ===================================
--
  | [2026-08-03T13:26:35Z] _ TestSchedulerPerformanceOptimizations.test_quick_reject_mixed_shapes_all_exhausted _
  | [2026-08-03T13:26:35Z]
  | [2026-08-03T13:26:35Z] self = <python.ray.autoscaler.v2.tests.test_scheduler.TestSchedulerPerformanceOptimizations object at 0x7fbc04103850>
  | [2026-08-03T13:26:35Z]
  | [2026-08-03T13:26:35Z]     def test_quick_reject_mixed_shapes_all_exhausted(self):
  | [2026-08-03T13:26:35Z]         """Nodes rejected when they cannot fit ANY request shape.
  | [2026-08-03T13:26:35Z]
  | [2026-08-03T13:26:35Z]         Scenario: two types of requests with different resource shapes.
  | [2026-08-03T13:26:35Z]         - Shape A: {CPU: 2, memory: 100}
  | [2026-08-03T13:26:35Z]         - Shape B: {GPU: 1}
  | [2026-08-03T13:26:35Z]         Nodes have CPU=0 (exhausted) and no GPU. Neither shape can fit, so the
  | [2026-08-03T13:26:35Z]         node should be rejected by the pre-filter.
  | [2026-08-03T13:26:35Z]         """
  | [2026-08-03T13:26:35Z]         node_type_configs = {
  | [2026-08-03T13:26:35Z]             "type_1": NodeTypeConfig(
  | [2026-08-03T13:26:35Z]                 name="type_1",
  | [2026-08-03T13:26:35Z]                 resources={"CPU": 4, "memory": 500},
  | [2026-08-03T13:26:35Z]                 min_worker_nodes=0,
  | [2026-08-03T13:26:35Z]                 max_worker_nodes=100,
  | [2026-08-03T13:26:35Z]             ),
  | [2026-08-03T13:26:35Z]             "type_gpu": NodeTypeConfig(
  | [2026-08-03T13:26:35Z]                 name="type_gpu",
  | [2026-08-03T13:26:35Z]                 resources={"CPU": 4, "GPU": 1, "memory": 500},
  | [2026-08-03T13:26:35Z]                 min_worker_nodes=0,
  | [2026-08-03T13:26:35Z]                 max_worker_nodes=100,
  | [2026-08-03T13:26:35Z]             ),
  | [2026-08-03T13:26:35Z]         }
  | [2026-08-03T13:26:35Z]         # Nodes with CPU exhausted, plenty of memory, no GPU.
  | [2026-08-03T13:26:35Z]         instances = []
  | [2026-08-03T13:26:35Z]         for i in range(5):
  | [2026-08-03T13:26:35Z]             instances.append(
  | [2026-08-03T13:26:35Z]                 make_autoscaler_instance(
  | [2026-08-03T13:26:35Z]                     im_instance=Instance(
  | [2026-08-03T13:26:35Z]                         instance_type="type_1",
  | [2026-08-03T13:26:35Z]                         status=Instance.RAY_RUNNING,
  | [2026-08-03T13:26:35Z]                         instance_id=f"type_1-{i}",
  | [2026-08-03T13:26:35Z]                         node_id=f"r{i}type_1",
  | [2026-08-03T13:26:35Z]                     ),
  | [2026-08-03T13:26:35Z]                     ray_node=NodeState(
  | [2026-08-03T13:26:35Z]                         node_id=f"r{i}type_1".encode("utf-8"),
  | [2026-08-03T13:26:35Z]                         ray_node_type_name="type_1",
  | [2026-08-03T13:26:35Z]                         available_resources={"CPU": 0, "memory": 400},
  | [2026-08-03T13:26:35Z]                         total_resources={"CPU": 4, "memory": 500},
  | [2026-08-03T13:26:35Z]                         idle_duration_ms=0,
  | [2026-08-03T13:26:35Z]                         status=NodeStatus.RUNNING,
  | [2026-08-03T13:26:35Z]                     ),
  | [2026-08-03T13:26:35Z]                     cloud_instance_id=f"c-type_1-{i}",
  | [2026-08-03T13:26:35Z]                 )
  | [2026-08-03T13:26:35Z]             )
  | [2026-08-03T13:26:35Z]
  | [2026-08-03T13:26:35Z]         # Shape A needs CPU+memory; Shape B needs GPU.
  | [2026-08-03T13:26:35Z]         # Existing nodes have CPU=0 (fails shape A) and no GPU (fails shape B).
  | [2026-08-03T13:26:35Z]         resource_requests = [
  | [2026-08-03T13:26:35Z]             ResourceRequestUtil.make({"CPU": 2, "memory": 100})
  | [2026-08-03T13:26:35Z]         ] * 3 + [ResourceRequestUtil.make({"GPU": 1})] * 2
  | [2026-08-03T13:26:35Z]         request = sched_request(
  | [2026-08-03T13:26:35Z]             node_type_configs=node_type_configs,
  | [2026-08-03T13:26:35Z]             resource_requests=resource_requests,
  | [2026-08-03T13:26:35Z]             instances=instances,
  | [2026-08-03T13:26:35Z]         )
  | [2026-08-03T13:26:35Z]         reply = ResourceDemandScheduler(event_logger).schedule(request)
  | [2026-08-03T13:26:35Z]         to_launch, _ = _launch_and_terminate(reply)
  | [2026-08-03T13:26:35Z]         # All existing nodes are rejected (can't fit shape A or B).
  | [2026-08-03T13:26:35Z]         # Shape A: 3 × {CPU:2, memory:100}. type_1 has 4 CPU, 500 mem → fits 2/node → need 2.
  | [2026-08-03T13:26:35Z]         # Shape B: 2 × {GPU:1}. type_gpu has 1 GPU each → need 2.
  | [2026-08-03T13:26:35Z] >       assert to_launch == {"type_1": 2, "type_gpu": 2}
  | [2026-08-03T13:26:35Z] E       AssertionError: assert {'type_gpu': 2} == {'type_1': 2, 'type_gpu': 2}
  | [2026-08-03T13:26:35Z] E         Omitting 1 identical items, use -vv to show
  | [2026-08-03T13:26:35Z] E         Right contains 1 more item:
  | [2026-08-03T13:26:35Z] E         {'type_1': 2}
  | [2026-08-03T13:26:35Z] E         Full diff:
  | [2026-08-03T13:26:35Z] E         - {'type_1': 2, 'type_gpu': 2}
  | [2026-08-03T13:26:35Z] E         + {'type_gpu': 2}


- Use IMPLICIT_RESOURCE_PREFIX-aware default (1.0) in _can_fit_any_request
  to match _fits() behavior, preventing false rejection of nodes with
  implicit resources not listed in available_resources.
- Fix test_quick_reject_mixed_shapes_all_exhausted: set type_gpu CPU=1
  so it cannot absorb shape A (CPU=2), making the assertion correct.

Signed-off-by: wangjia23 <wangjia23@xiaomi.com>
@Jade07-1

Jade07-1 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Hi @Jade07-1, there is a test failure:


[2026-08-03T13:26:35Z] =================================== FAILURES ===================================
--
  | [2026-08-03T13:26:35Z] _ TestSchedulerPerformanceOptimizations.test_quick_reject_mixed_shapes_all_exhausted _
  | [2026-08-03T13:26:35Z]
  | [2026-08-03T13:26:35Z] self = <python.ray.autoscaler.v2.tests.test_scheduler.TestSchedulerPerformanceOptimizations object at 0x7fbc04103850>
  | [2026-08-03T13:26:35Z]
  | [2026-08-03T13:26:35Z]     def test_quick_reject_mixed_shapes_all_exhausted(self):
  | [2026-08-03T13:26:35Z]         """Nodes rejected when they cannot fit ANY request shape.
  | [2026-08-03T13:26:35Z]
  | [2026-08-03T13:26:35Z]         Scenario: two types of requests with different resource shapes.
  | [2026-08-03T13:26:35Z]         - Shape A: {CPU: 2, memory: 100}
  | [2026-08-03T13:26:35Z]         - Shape B: {GPU: 1}
  | [2026-08-03T13:26:35Z]         Nodes have CPU=0 (exhausted) and no GPU. Neither shape can fit, so the
  | [2026-08-03T13:26:35Z]         node should be rejected by the pre-filter.
  | [2026-08-03T13:26:35Z]         """
  | [2026-08-03T13:26:35Z]         node_type_configs = {
  | [2026-08-03T13:26:35Z]             "type_1": NodeTypeConfig(
  | [2026-08-03T13:26:35Z]                 name="type_1",
  | [2026-08-03T13:26:35Z]                 resources={"CPU": 4, "memory": 500},
  | [2026-08-03T13:26:35Z]                 min_worker_nodes=0,
  | [2026-08-03T13:26:35Z]                 max_worker_nodes=100,
  | [2026-08-03T13:26:35Z]             ),
  | [2026-08-03T13:26:35Z]             "type_gpu": NodeTypeConfig(
  | [2026-08-03T13:26:35Z]                 name="type_gpu",
  | [2026-08-03T13:26:35Z]                 resources={"CPU": 4, "GPU": 1, "memory": 500},
  | [2026-08-03T13:26:35Z]                 min_worker_nodes=0,
  | [2026-08-03T13:26:35Z]                 max_worker_nodes=100,
  | [2026-08-03T13:26:35Z]             ),
  | [2026-08-03T13:26:35Z]         }
  | [2026-08-03T13:26:35Z]         # Nodes with CPU exhausted, plenty of memory, no GPU.
  | [2026-08-03T13:26:35Z]         instances = []
  | [2026-08-03T13:26:35Z]         for i in range(5):
  | [2026-08-03T13:26:35Z]             instances.append(
  | [2026-08-03T13:26:35Z]                 make_autoscaler_instance(
  | [2026-08-03T13:26:35Z]                     im_instance=Instance(
  | [2026-08-03T13:26:35Z]                         instance_type="type_1",
  | [2026-08-03T13:26:35Z]                         status=Instance.RAY_RUNNING,
  | [2026-08-03T13:26:35Z]                         instance_id=f"type_1-{i}",
  | [2026-08-03T13:26:35Z]                         node_id=f"r{i}type_1",
  | [2026-08-03T13:26:35Z]                     ),
  | [2026-08-03T13:26:35Z]                     ray_node=NodeState(
  | [2026-08-03T13:26:35Z]                         node_id=f"r{i}type_1".encode("utf-8"),
  | [2026-08-03T13:26:35Z]                         ray_node_type_name="type_1",
  | [2026-08-03T13:26:35Z]                         available_resources={"CPU": 0, "memory": 400},
  | [2026-08-03T13:26:35Z]                         total_resources={"CPU": 4, "memory": 500},
  | [2026-08-03T13:26:35Z]                         idle_duration_ms=0,
  | [2026-08-03T13:26:35Z]                         status=NodeStatus.RUNNING,
  | [2026-08-03T13:26:35Z]                     ),
  | [2026-08-03T13:26:35Z]                     cloud_instance_id=f"c-type_1-{i}",
  | [2026-08-03T13:26:35Z]                 )
  | [2026-08-03T13:26:35Z]             )
  | [2026-08-03T13:26:35Z]
  | [2026-08-03T13:26:35Z]         # Shape A needs CPU+memory; Shape B needs GPU.
  | [2026-08-03T13:26:35Z]         # Existing nodes have CPU=0 (fails shape A) and no GPU (fails shape B).
  | [2026-08-03T13:26:35Z]         resource_requests = [
  | [2026-08-03T13:26:35Z]             ResourceRequestUtil.make({"CPU": 2, "memory": 100})
  | [2026-08-03T13:26:35Z]         ] * 3 + [ResourceRequestUtil.make({"GPU": 1})] * 2
  | [2026-08-03T13:26:35Z]         request = sched_request(
  | [2026-08-03T13:26:35Z]             node_type_configs=node_type_configs,
  | [2026-08-03T13:26:35Z]             resource_requests=resource_requests,
  | [2026-08-03T13:26:35Z]             instances=instances,
  | [2026-08-03T13:26:35Z]         )
  | [2026-08-03T13:26:35Z]         reply = ResourceDemandScheduler(event_logger).schedule(request)
  | [2026-08-03T13:26:35Z]         to_launch, _ = _launch_and_terminate(reply)
  | [2026-08-03T13:26:35Z]         # All existing nodes are rejected (can't fit shape A or B).
  | [2026-08-03T13:26:35Z]         # Shape A: 3 × {CPU:2, memory:100}. type_1 has 4 CPU, 500 mem → fits 2/node → need 2.
  | [2026-08-03T13:26:35Z]         # Shape B: 2 × {GPU:1}. type_gpu has 1 GPU each → need 2.
  | [2026-08-03T13:26:35Z] >       assert to_launch == {"type_1": 2, "type_gpu": 2}
  | [2026-08-03T13:26:35Z] E       AssertionError: assert {'type_gpu': 2} == {'type_1': 2, 'type_gpu': 2}
  | [2026-08-03T13:26:35Z] E         Omitting 1 identical items, use -vv to show
  | [2026-08-03T13:26:35Z] E         Right contains 1 more item:
  | [2026-08-03T13:26:35Z] E         {'type_1': 2}
  | [2026-08-03T13:26:35Z] E         Full diff:
  | [2026-08-03T13:26:35Z] E         - {'type_1': 2, 'type_gpu': 2}
  | [2026-08-03T13:26:35Z] E         + {'type_gpu': 2}

@rueian Fixed in 9b0ae17. Thanks for catching this!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-contribution Contributed by the community core Issues that should be addressed in Ray Core go add ONLY when ready to merge, run all tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants