Skip to content

Commit 78aa66d

Browse files
[serve] Cache per-tick replica id walks behind a container membership fingerprint
The per-tick id walks (alive-actor-ids, active-node-ids, running-ids, the autoscaler id-set rebuild) recompute identical results on every steady-state tick. Memoize each on a ReplicaStateContainer membership fingerprint, and disarm while STARTING or RECOVERING replicas exist, since their actor_id/actor_node_id materialize in place without touching the container. The fingerprint is the summed per-replica hash of the container's {replica id -> state} content rather than a mutation counter, because the health and migration passes pop a whole bucket and re-add every replica to the state it came from. A counter reads that churn as a membership change, and one node draining anywhere in the cluster is enough to trigger it: at 4 replicas it bumps 5 times per otherwise-idle tick, which invalidates all three memos and re-runs the O(N) rank-consistency pass on every tick. Summing per-replica terms makes pop-and-re-add cancel exactly, while a real arrival, departure or state transition still changes it. The same fingerprint replaces the id-set key of the rank-consistency gate and of its error backoff, so both keep an O(1) key without losing the content-based semantics that made them hold under churn. Manager-level getters return frozensets so the memo cannot be mutated by callers (with set() at the one mutating caller), and AutoscalingContext.running_replicas is copied to a list at the public boundary. Signed-off-by: john.taylor <john.taylor@anyscale.com> Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 85ce817 commit 78aa66d

4 files changed

Lines changed: 333 additions & 38 deletions

File tree

python/ray/serve/_private/autoscaling_state.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,9 @@ def get_num_replicas_upper_bound(self) -> int:
210210

211211
def update_running_replica_ids(self, running_replicas: List[ReplicaID]):
212212
"""Update cached set of running replica IDs for this deployment."""
213+
if running_replicas is self._running_replicas:
214+
# Same (cached) list object -- membership unchanged, skip rebuild.
215+
return
213216
self._running_replicas = running_replicas
214217
self._cached_running_replica_strs = {
215218
r.to_full_id_str() for r in running_replicas
@@ -395,7 +398,9 @@ def get_autoscaling_context(
395398
app_name=self._deployment_id.app_name,
396399
current_num_replicas=len(self._running_replicas),
397400
target_num_replicas=curr_target_num_replicas,
398-
running_replicas=self._running_replicas,
401+
# list(): AutoscalingContext.running_replicas is List[ReplicaID] on a
402+
# stable PublicAPI; the tuple stays internal so the identity skip works.
403+
running_replicas=list(self._running_replicas),
399404
total_num_requests=self.get_total_num_requests,
400405
capacity_adjusted_min_replicas=self.get_num_replicas_lower_bound(),
401406
capacity_adjusted_max_replicas=self.get_num_replicas_upper_bound(),

python/ray/serve/_private/controller.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,8 @@ def _update_proxy_nodes(self):
533533
Controller decides where proxy actors should run
534534
(head node and nodes with deployment replicas).
535535
"""
536-
new_proxy_nodes = self.deployment_state_manager.get_active_node_ids()
536+
# set(): the getter returns a frozenset memo, and this is mutated below.
537+
new_proxy_nodes = set(self.deployment_state_manager.get_active_node_ids())
537538
new_proxy_nodes = new_proxy_nodes - set(
538539
self.cluster_node_info_cache.get_draining_nodes()
539540
)

python/ray/serve/_private/deployment_state.py

Lines changed: 147 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,17 @@
1010
from copy import copy
1111
from dataclasses import dataclass
1212
from enum import Enum
13-
from typing import Any, Callable, Deque, Dict, List, Optional, Set, Tuple
13+
from typing import (
14+
Any,
15+
Callable,
16+
Deque,
17+
Dict,
18+
FrozenSet,
19+
List,
20+
Optional,
21+
Set,
22+
Tuple,
23+
)
1424

1525
import ray
1626
from ray import ObjectRef, cloudpickle
@@ -2171,6 +2181,25 @@ def get_outbound_deployments(self) -> Optional[List[DeploymentID]]:
21712181
return self._actor.get_outbound_deployments()
21722182

21732183

2184+
def _memoized_walk(obj, memo_attr: str, token, compute):
2185+
"""Return compute() memoized on *token* in obj.<memo_attr>.
2186+
2187+
token=None means "uncacheable right now": recompute and drop the memo.
2188+
Callers must treat returned collections as immutable (they are shared).
2189+
"""
2190+
memo = getattr(obj, memo_attr, None)
2191+
if token is not None and memo is not None and memo[0] == token:
2192+
return memo[1]
2193+
result = compute()
2194+
setattr(obj, memo_attr, None if token is None else (token, result))
2195+
return result
2196+
2197+
2198+
def _membership_term(replica_id: ReplicaID, state: ReplicaState) -> int:
2199+
"""One replica's term in ReplicaStateContainer.membership_fingerprint."""
2200+
return hash((replica_id, state))
2201+
2202+
21742203
class ReplicaStateContainer:
21752204
"""Container for mapping ReplicaStates to lists of DeploymentReplicas."""
21762205

@@ -2181,6 +2210,18 @@ def __init__(self, on_replica_state_change=None):
21812210
# Incremental (state, version) counts for O(1) version-filtered count()
21822211
# (maintained on add/pop/remove) -> replaces the per-tick O(N) version scan.
21832212
self._sv_counts: Dict[tuple, int] = defaultdict(int)
2213+
# Derived id collections memoize on this (see membership_fingerprint).
2214+
self._membership_fingerprint: int = 0
2215+
2216+
@property
2217+
def membership_fingerprint(self) -> int:
2218+
"""Fingerprint of the {replica id -> state} content, for memo keys.
2219+
2220+
Summed per-replica terms, so the pop-and-re-add churn of the health and
2221+
migration passes cancels exactly while real arrivals, departures and
2222+
state transitions change it.
2223+
"""
2224+
return self._membership_fingerprint
21842225

21852226
def __getstate__(self):
21862227
# Exclude the callback to keep the container picklable (the callback
@@ -2204,6 +2245,7 @@ def add(self, state: ReplicaState, replica: DeploymentReplica):
22042245
self._replicas[state].append(replica)
22052246
self._replica_id_index[replica.replica_id] = replica
22062247
self._sv_counts[(state, replica.version)] += 1
2248+
self._membership_fingerprint += _membership_term(replica.replica_id, state)
22072249
if self._on_replica_state_change and state != old_state:
22082250
self._on_replica_state_change(old_state, state)
22092251

@@ -2297,6 +2339,7 @@ def pop(
22972339
replicas.extend(popped)
22982340
for _r in popped:
22992341
self._sv_counts[(state, _r.version)] -= 1
2342+
self._membership_fingerprint -= _membership_term(_r.replica_id, state)
23002343

23012344
for replica in replicas:
23022345
self._replica_id_index.pop(replica.replica_id, None)
@@ -2367,6 +2410,9 @@ def remove(self, replica_ids: Set[ReplicaID]) -> List[DeploymentReplica]:
23672410
if remaining_to_find > 0 and replica.replica_id in replica_ids:
23682411
removed.append(replica)
23692412
self._sv_counts[(state, replica.version)] -= 1
2413+
self._membership_fingerprint -= _membership_term(
2414+
replica.replica_id, state
2415+
)
23702416
self._replica_id_index.pop(replica.replica_id, None)
23712417
remaining_to_find -= 1
23722418
found_any = True
@@ -2955,10 +3001,14 @@ def __init__(
29553001
self._rank_manager = DeploymentRankManager(
29563002
fail_on_rank_error=RAY_SERVE_FAIL_ON_RANK_ERROR
29573003
)
2958-
self._last_rank_membership_ids: Optional[Set[str]] = None
2959-
# Membership whose pass swallowed an error, and when, for retry backoff.
2960-
self._last_rank_error_ids: Optional[Set[str]] = None
3004+
self._last_rank_membership_fp: Optional[int] = None
3005+
# Fingerprint whose pass swallowed an error, and when, for retry backoff.
3006+
self._last_rank_error_fp: Optional[int] = None
29613007
self._last_rank_error_ts: float = 0.0
3008+
# (token, result) memos for per-tick id-collection walks.
3009+
self._alive_replica_actor_ids_memo = None
3010+
self._running_replica_ids_memo = None
3011+
self._active_node_ids_memo = None
29623012

29633013
self.replica_average_ongoing_requests: Dict[str, float] = {}
29643014

@@ -3335,16 +3385,42 @@ def _terminally_failed(self) -> bool:
33353385
)
33363386
return replica_failed or self.deployment_actor_terminally_failed()
33373387

3338-
def get_alive_replica_actor_ids(self) -> Set[str]:
3339-
return {replica.actor_id for replica in self._replicas.get()}
3388+
def _membership_cache_token(self) -> Optional[int]:
3389+
"""Cache key for derived id collections, or None while uncacheable.
33403390
3341-
def get_running_replica_ids(self) -> List[ReplicaID]:
3342-
return [
3343-
replica.replica_id
3344-
for replica in self._replicas.get(
3345-
[ReplicaState.RUNNING, ReplicaState.PENDING_MIGRATION]
3391+
The container fingerprint tracks membership, but STARTING/RECOVERING
3392+
replicas can have actor_id/actor_node_id materialize in place without
3393+
a membership change -- disable caching while any exist.
3394+
"""
3395+
if (
3396+
self._replicas.count(
3397+
states=[ReplicaState.STARTING, ReplicaState.RECOVERING]
33463398
)
3347-
]
3399+
> 0
3400+
):
3401+
return None
3402+
return self._replicas.membership_fingerprint
3403+
3404+
def get_alive_replica_actor_ids(self) -> FrozenSet[str]:
3405+
return _memoized_walk(
3406+
self,
3407+
"_alive_replica_actor_ids_memo",
3408+
self._membership_cache_token(),
3409+
lambda: frozenset(replica.actor_id for replica in self._replicas.get()),
3410+
)
3411+
3412+
def get_running_replica_ids(self) -> Tuple[ReplicaID, ...]:
3413+
return _memoized_walk(
3414+
self,
3415+
"_running_replica_ids_memo",
3416+
self._membership_cache_token(),
3417+
lambda: tuple(
3418+
replica.replica_id
3419+
for replica in self._replicas.get(
3420+
[ReplicaState.RUNNING, ReplicaState.PENDING_MIGRATION]
3421+
)
3422+
),
3423+
)
33483424

33493425
def get_running_replica_infos(self) -> List[RunningReplicaInfo]:
33503426
return [
@@ -3396,11 +3472,16 @@ def get_active_node_ids(self) -> Set[str]:
33963472
# node before all the replicas are migrated.
33973473
ReplicaState.PENDING_MIGRATION,
33983474
]
3399-
return {
3400-
replica.actor_node_id
3401-
for replica in self._replicas.get(active_states)
3402-
if replica.actor_node_id is not None
3403-
}
3475+
return _memoized_walk(
3476+
self,
3477+
"_active_node_ids_memo",
3478+
self._membership_cache_token(),
3479+
lambda: frozenset(
3480+
replica.actor_node_id
3481+
for replica in self._replicas.get(active_states)
3482+
if replica.actor_node_id is not None
3483+
),
3484+
)
34043485

34053486
def list_replica_details(self) -> List[ReplicaDetails]:
34063487
return [replica.actor_details for replica in self._replicas.get()]
@@ -5005,7 +5086,7 @@ def _maybe_check_rank_consistency(self) -> None:
50055086
The pass is O(N) with heavy constants and used to run on every
50065087
control-loop tick in steady state -- at 10K+ replicas it monopolizes
50075088
the controller loop. Rank consistency can only be violated by
5008-
membership changes, so the active replica-id set gates it.
5089+
membership changes, so the container membership fingerprint gates it.
50095090
"""
50105091
# O(1) guards first -- `get()` below copies the whole replica list, and these
50115092
# two rule out the busiest ticks (rollouts, migrations) without paying for it.
@@ -5018,17 +5099,18 @@ def _maybe_check_rank_consistency(self) -> None:
50185099
or self._replicas.count(states=[ReplicaState.STARTING]) != 0
50195100
):
50205101
return
5102+
fp = self._replicas.membership_fingerprint
5103+
if fp == self._last_rank_membership_fp:
5104+
return
50215105
active_replicas = self._replicas.get()
50225106
if not active_replicas:
5023-
return
5024-
active_replica_ids = {r.replica_id.unique_id for r in active_replicas}
5025-
if active_replica_ids == self._last_rank_membership_ids:
5107+
self._last_rank_membership_fp = fp
50265108
return
50275109
# An errored pass leaves the membership uncached so it is retried; rate-limit that
50285110
# retry while the membership still matches the one that failed. A real membership
50295111
# change bypasses this and runs immediately.
50305112
if (
5031-
self._last_rank_error_ids == active_replica_ids
5113+
self._last_rank_error_fp == fp
50325114
and time.time() - self._last_rank_error_ts < _RANK_ERROR_RETRY_S
50335115
):
50345116
return
@@ -5044,11 +5126,11 @@ def _maybe_check_rank_consistency(self) -> None:
50445126
# Reconfigure replicas that had their ranks reassigned
50455127
self._reconfigure_replicas_with_new_ranks(replicas_to_reconfigure)
50465128
if checked_cleanly:
5047-
self._last_rank_membership_ids = active_replica_ids
5048-
self._last_rank_error_ids = None
5129+
self._last_rank_membership_fp = fp
5130+
self._last_rank_error_fp = None
50495131
else:
50505132
# Not cached: an unvalidated membership must be rechecked, just not every tick.
5051-
self._last_rank_error_ids = active_replica_ids
5133+
self._last_rank_error_fp = fp
50525134
self._last_rank_error_ts = time.time()
50535135

50545136
def _handle_deployment_actor_failed_health_check(
@@ -5817,6 +5899,9 @@ def __init__(
58175899
self._shutting_down = False
58185900

58195901
self._deployment_states: Dict[DeploymentID, DeploymentState] = {}
5902+
# (key, result) memos for cross-deployment id-collection walks.
5903+
self._alive_replica_actor_ids_memo = None
5904+
self._active_node_ids_memo = None
58205905
# Monotonic counter bumped whenever an ingress deployment's running-replica
58215906
# set (node/ports included) changes; the controller gates the direct-ingress port
58225907
# reconcile on it, skipping the O(replicas) pass on ticks with no change.
@@ -6143,12 +6228,31 @@ def get_deployment_statuses(
61436228
statuses.append(state.curr_status_info)
61446229
return statuses
61456230

6146-
def get_alive_replica_actor_ids(self) -> Set[str]:
6147-
alive_replica_actor_ids = set()
6148-
for ds in self._deployment_states.values():
6149-
alive_replica_actor_ids |= ds.get_alive_replica_actor_ids()
6150-
6151-
return alive_replica_actor_ids
6231+
def _membership_cache_key(self) -> Optional[tuple]:
6232+
"""Combined per-deployment token, or None if any deployment is
6233+
uncacheable. Deployment add/remove changes the key shape."""
6234+
parts = []
6235+
for deployment_id, ds in self._deployment_states.items():
6236+
token = ds._membership_cache_token()
6237+
if token is None:
6238+
return None
6239+
parts.append((deployment_id, token))
6240+
return tuple(parts)
6241+
6242+
def get_alive_replica_actor_ids(self) -> FrozenSet[str]:
6243+
def compute():
6244+
alive_replica_actor_ids = set()
6245+
for ds in self._deployment_states.values():
6246+
alive_replica_actor_ids |= ds.get_alive_replica_actor_ids()
6247+
# Frozen: the memo is handed to callers and must not be mutable.
6248+
return frozenset(alive_replica_actor_ids)
6249+
6250+
return _memoized_walk(
6251+
self,
6252+
"_alive_replica_actor_ids_memo",
6253+
self._membership_cache_key(),
6254+
compute,
6255+
)
61526256

61536257
def get_deployment_ids(self) -> List[DeploymentID]:
61546258
return list(self._deployment_states.keys())
@@ -6575,16 +6679,23 @@ def record_request_routing_info(self, info: RequestRoutingInfo) -> None:
65756679
return
65766680
self._deployment_states[deployment_id].record_request_routing_info(info)
65776681

6578-
def get_active_node_ids(self) -> Set[str]:
6682+
def get_active_node_ids(self) -> FrozenSet[str]:
65796683
"""Return set of node ids with running replicas of any deployment.
65806684
65816685
This is used to determine which node has replicas. Only nodes with replicas and
65826686
head node should have active proxies.
65836687
"""
6584-
node_ids = set()
6585-
for deployment_state in self._deployment_states.values():
6586-
node_ids.update(deployment_state.get_active_node_ids())
6587-
return node_ids
6688+
6689+
def compute():
6690+
node_ids = set()
6691+
for deployment_state in self._deployment_states.values():
6692+
node_ids.update(deployment_state.get_active_node_ids())
6693+
# Frozen: the memo is handed to callers and must not be mutable.
6694+
return frozenset(node_ids)
6695+
6696+
return _memoized_walk(
6697+
self, "_active_node_ids_memo", self._membership_cache_key(), compute
6698+
)
65886699

65896700
def get_ingress_membership_version(self) -> int:
65906701
"""Monotonic counter of ingress running-replica-set changes (node/ports

0 commit comments

Comments
 (0)