Skip to content

Commit 2c9f04a

Browse files
[serve] Cache per-tick replica id walks behind a container mutation version
A CPU profile of a controller managing 16K replicas showed the loop dominated by O(N) id-collection walks recomputed every tick (alive actor ids, active node ids, running replica ids, the autoscaler id-set rebuild) even though membership rarely changes in steady state. ReplicaStateContainer now bumps a mutation version on add / non-empty pop / non-empty remove, and the derived collections memoize on it at both DeploymentState and DeploymentStateManager level. Caching disarms while STARTING/RECOVERING replicas exist since their actor/node ids materialize in place. The autoscaler skips its id-set rebuild when handed the same (cached) list object, and the rank-consistency fingerprint becomes the version integer. Benchmark at 16K replicas: control loop 352.6ms -> 42.9ms. Signed-off-by: john.taylor <john.taylor@anyscale.com>
1 parent 7fa7c85 commit 2c9f04a

3 files changed

Lines changed: 194 additions & 30 deletions

File tree

python/ray/serve/_private/autoscaling_state.py

Lines changed: 3 additions & 0 deletions
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

python/ray/serve/_private/deployment_state.py

Lines changed: 112 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@
1010
from copy import copy
1111
from dataclasses import dataclass
1212
from enum import Enum
13-
from functools import reduce
14-
from operator import xor
1513
from typing import Any, Callable, Deque, Dict, List, Optional, Set, Tuple
1614

1715
import ray
@@ -2159,6 +2157,20 @@ def get_outbound_deployments(self) -> Optional[List[DeploymentID]]:
21592157
return self._actor.get_outbound_deployments()
21602158

21612159

2160+
def _memoized_walk(obj, memo_attr: str, token, compute):
2161+
"""Return compute() memoized on *token* in obj.<memo_attr>.
2162+
2163+
token=None means "uncacheable right now": recompute and drop the memo.
2164+
Callers must treat returned collections as immutable (they are shared).
2165+
"""
2166+
memo = getattr(obj, memo_attr)
2167+
if token is not None and memo is not None and memo[0] == token:
2168+
return memo[1]
2169+
result = compute()
2170+
setattr(obj, memo_attr, None if token is None else (token, result))
2171+
return result
2172+
2173+
21622174
class ReplicaStateContainer:
21632175
"""Container for mapping ReplicaStates to lists of DeploymentReplicas."""
21642176

@@ -2169,6 +2181,12 @@ def __init__(self, on_replica_state_change=None):
21692181
# Incremental (state, version) counts for O(1) version-filtered count()
21702182
# (maintained on add/pop/remove) -> replaces the per-tick O(N) version scan.
21712183
self._sv_counts: Dict[tuple, int] = defaultdict(int)
2184+
# Bumped on every mutation; derived id collections memoize on it.
2185+
self._mutation_version: int = 0
2186+
2187+
@property
2188+
def mutation_version(self) -> int:
2189+
return self._mutation_version
21722190

21732191
def __getstate__(self):
21742192
# Exclude the callback to keep the container picklable (the callback
@@ -2192,6 +2210,7 @@ def add(self, state: ReplicaState, replica: DeploymentReplica):
21922210
self._replicas[state].append(replica)
21932211
self._replica_id_index[replica.replica_id] = replica
21942212
self._sv_counts[(state, replica.version)] += 1
2213+
self._mutation_version += 1
21952214
if self._on_replica_state_change and state != old_state:
21962215
self._on_replica_state_change(old_state, state)
21972216

@@ -2289,6 +2308,8 @@ def pop(
22892308
for replica in replicas:
22902309
self._replica_id_index.pop(replica.replica_id, None)
22912310

2311+
if replicas:
2312+
self._mutation_version += 1
22922313
return replicas
22932314

22942315
def count(
@@ -2362,6 +2383,8 @@ def remove(self, replica_ids: Set[ReplicaID]) -> List[DeploymentReplica]:
23622383
remaining.append(replica)
23632384
if found_any:
23642385
self._replicas[state] = remaining
2386+
if removed:
2387+
self._mutation_version += 1
23652388
return removed
23662389

23672390
def __str__(self):
@@ -2922,6 +2945,10 @@ def __init__(
29222945
fail_on_rank_error=RAY_SERVE_FAIL_ON_RANK_ERROR
29232946
)
29242947
self._last_rank_membership_fingerprint: Optional[int] = None
2948+
# (token, result) memos for per-tick id-collection walks.
2949+
self._alive_replica_actor_ids_memo = None
2950+
self._running_replica_ids_memo = None
2951+
self._active_node_ids_memo = None
29252952

29262953
self.replica_average_ongoing_requests: Dict[str, float] = {}
29272954

@@ -3298,16 +3325,42 @@ def _terminally_failed(self) -> bool:
32983325
)
32993326
return replica_failed or self.deployment_actor_terminally_failed()
33003327

3328+
def _membership_cache_token(self) -> Optional[int]:
3329+
"""Cache key for derived id collections, or None while uncacheable.
3330+
3331+
The container version tracks add/pop/remove, but STARTING/RECOVERING
3332+
replicas can have actor_id/actor_node_id materialize in place without
3333+
a container mutation -- disable caching while any exist.
3334+
"""
3335+
if (
3336+
self._replicas.count(
3337+
states=[ReplicaState.STARTING, ReplicaState.RECOVERING]
3338+
)
3339+
> 0
3340+
):
3341+
return None
3342+
return self._replicas.mutation_version
3343+
33013344
def get_alive_replica_actor_ids(self) -> Set[str]:
3302-
return {replica.actor_id for replica in self._replicas.get()}
3345+
return _memoized_walk(
3346+
self,
3347+
"_alive_replica_actor_ids_memo",
3348+
self._membership_cache_token(),
3349+
lambda: {replica.actor_id for replica in self._replicas.get()},
3350+
)
33033351

33043352
def get_running_replica_ids(self) -> List[ReplicaID]:
3305-
return [
3306-
replica.replica_id
3307-
for replica in self._replicas.get(
3308-
[ReplicaState.RUNNING, ReplicaState.PENDING_MIGRATION]
3309-
)
3310-
]
3353+
return _memoized_walk(
3354+
self,
3355+
"_running_replica_ids_memo",
3356+
self._membership_cache_token(),
3357+
lambda: [
3358+
replica.replica_id
3359+
for replica in self._replicas.get(
3360+
[ReplicaState.RUNNING, ReplicaState.PENDING_MIGRATION]
3361+
)
3362+
],
3363+
)
33113364

33123365
def get_running_replica_infos(self) -> List[RunningReplicaInfo]:
33133366
return [
@@ -3359,11 +3412,16 @@ def get_active_node_ids(self) -> Set[str]:
33593412
# node before all the replicas are migrated.
33603413
ReplicaState.PENDING_MIGRATION,
33613414
]
3362-
return {
3363-
replica.actor_node_id
3364-
for replica in self._replicas.get(active_states)
3365-
if replica.actor_node_id is not None
3366-
}
3415+
return _memoized_walk(
3416+
self,
3417+
"_active_node_ids_memo",
3418+
self._membership_cache_token(),
3419+
lambda: {
3420+
replica.actor_node_id
3421+
for replica in self._replicas.get(active_states)
3422+
if replica.actor_node_id is not None
3423+
},
3424+
)
33673425

33683426
def list_replica_details(self) -> List[ReplicaDetails]:
33693427
return [replica.actor_details for replica in self._replicas.get()]
@@ -4871,24 +4929,21 @@ def _maybe_check_rank_consistency(self) -> None:
48714929
the controller loop. Rank consistency can only be violated by
48724930
membership changes, so a cheap fingerprint gates it.
48734931
"""
4874-
active_replicas = self._replicas.get()
48754932
if not (
4876-
active_replicas
4877-
and self._curr_status_info.status == DeploymentStatus.HEALTHY
4933+
self._curr_status_info.status == DeploymentStatus.HEALTHY
48784934
# Skip consistency check if there are STARTING replicas. During node
48794935
# migration, new replicas are created in STARTING state (without ranks)
48804936
# after the status is set to HEALTHY. Running the consistency check
48814937
# with STARTING replicas causes "active keys without ranks" error.
48824938
and self._replicas.count(states=[ReplicaState.STARTING]) == 0
48834939
):
48844940
return
4885-
fingerprint = reduce(
4886-
xor,
4887-
(hash(r.replica_id.unique_id) for r in active_replicas),
4888-
len(active_replicas),
4889-
)
4941+
fingerprint = self._replicas.mutation_version
48904942
if fingerprint == self._last_rank_membership_fingerprint:
48914943
return
4944+
active_replicas = self._replicas.get()
4945+
if not active_replicas:
4946+
return
48924947
replicas_to_reconfigure = (
48934948
self._rank_manager.check_rank_consistency_and_reassign_minimally(
48944949
active_replicas,
@@ -5665,6 +5720,9 @@ def __init__(
56655720
self._shutting_down = False
56665721

56675722
self._deployment_states: Dict[DeploymentID, DeploymentState] = {}
5723+
# (key, result) memos for cross-deployment id-collection walks.
5724+
self._alive_replica_actor_ids_memo = None
5725+
self._active_node_ids_memo = None
56685726
# Monotonic counter bumped whenever an ingress deployment's running-replica
56695727
# set (node/ports included) changes; the controller gates the direct-ingress port
56705728
# reconcile on it, skipping the O(replicas) pass on ticks with no change.
@@ -5991,12 +6049,30 @@ def get_deployment_statuses(
59916049
statuses.append(state.curr_status_info)
59926050
return statuses
59936051

6052+
def _membership_cache_key(self) -> Optional[tuple]:
6053+
"""Combined per-deployment token, or None if any deployment is
6054+
uncacheable. Deployment add/remove changes the key shape."""
6055+
parts = []
6056+
for deployment_id, ds in self._deployment_states.items():
6057+
token = ds._membership_cache_token()
6058+
if token is None:
6059+
return None
6060+
parts.append((deployment_id, token))
6061+
return tuple(parts)
6062+
59946063
def get_alive_replica_actor_ids(self) -> Set[str]:
5995-
alive_replica_actor_ids = set()
5996-
for ds in self._deployment_states.values():
5997-
alive_replica_actor_ids |= ds.get_alive_replica_actor_ids()
6064+
def compute():
6065+
alive_replica_actor_ids = set()
6066+
for ds in self._deployment_states.values():
6067+
alive_replica_actor_ids |= ds.get_alive_replica_actor_ids()
6068+
return alive_replica_actor_ids
59986069

5999-
return alive_replica_actor_ids
6070+
return _memoized_walk(
6071+
self,
6072+
"_alive_replica_actor_ids_memo",
6073+
self._membership_cache_key(),
6074+
compute,
6075+
)
60006076

60016077
def get_deployment_ids(self) -> List[DeploymentID]:
60026078
return list(self._deployment_states.keys())
@@ -6424,10 +6500,16 @@ def get_active_node_ids(self) -> Set[str]:
64246500
This is used to determine which node has replicas. Only nodes with replicas and
64256501
head node should have active proxies.
64266502
"""
6427-
node_ids = set()
6428-
for deployment_state in self._deployment_states.values():
6429-
node_ids.update(deployment_state.get_active_node_ids())
6430-
return node_ids
6503+
6504+
def compute():
6505+
node_ids = set()
6506+
for deployment_state in self._deployment_states.values():
6507+
node_ids.update(deployment_state.get_active_node_ids())
6508+
return node_ids
6509+
6510+
return _memoized_walk(
6511+
self, "_active_node_ids_memo", self._membership_cache_key(), compute
6512+
)
64316513

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

python/ray/serve/tests/unit/test_deployment_state.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9975,6 +9975,7 @@ def _ds(self, uids):
99759975
ds._replicas = Mock()
99769976
ds._replicas.get.return_value = replicas
99779977
ds._replicas.count.return_value = 0
9978+
ds._replicas.mutation_version = 7
99789979
ds._curr_status_info = Mock(status=DeploymentStatus.HEALTHY)
99799980
ds._rank_manager = Mock()
99809981
ds._rank_manager.check_rank_consistency_and_reassign_minimally.return_value = []
@@ -10001,6 +10002,8 @@ def test_membership_change_reruns(self):
1000110002
r.replica_id.unique_id = uid
1000210003
new.append(r)
1000310004
ds._replicas.get.return_value = new
10005+
# Membership changes always bump the container mutation version.
10006+
ds._replicas.mutation_version = 8
1000410007
ds._maybe_check_rank_consistency()
1000510008
assert (
1000610009
ds._rank_manager.check_rank_consistency_and_reassign_minimally.call_count
@@ -10015,5 +10018,81 @@ def test_starting_replicas_skip_entirely(self):
1001510018
assert ds._last_rank_membership_fingerprint is None
1001610019

1001710020

10021+
class TestMutationVersionMemo:
10022+
"""Container mutation version + memoized per-tick id-collection walks."""
10023+
10024+
def _replica(self, uid, actor_id=None, node_id=None):
10025+
r = Mock()
10026+
r.replica_id = uid
10027+
r.version = "v1"
10028+
r.actor_id = actor_id or f"actor-{uid}"
10029+
r.actor_node_id = node_id or f"node-{uid}"
10030+
return r
10031+
10032+
def test_version_bumps_only_on_real_mutation(self):
10033+
c = ds_mod.ReplicaStateContainer()
10034+
v0 = c.mutation_version
10035+
r = self._replica("a")
10036+
c.add(ReplicaState.STARTING, r)
10037+
assert c.mutation_version == v0 + 1
10038+
# Empty pops/removes (the steady-state case) do not bump.
10039+
assert c.pop(states=[ReplicaState.STOPPING]) == []
10040+
assert c.remove({"missing"}) == []
10041+
assert c.mutation_version == v0 + 1
10042+
assert c.pop(states=[ReplicaState.STARTING]) == [r]
10043+
assert c.mutation_version == v0 + 2
10044+
10045+
def _ds(self):
10046+
ds = ds_mod.DeploymentState.__new__(ds_mod.DeploymentState)
10047+
ds._replicas = ds_mod.ReplicaStateContainer()
10048+
ds._alive_replica_actor_ids_memo = None
10049+
ds._running_replica_ids_memo = None
10050+
ds._active_node_ids_memo = None
10051+
return ds
10052+
10053+
def test_getters_memoize_and_invalidate_on_mutation(self):
10054+
ds = self._ds()
10055+
ds._replicas.add(ReplicaState.RUNNING, self._replica("r1"))
10056+
ids1 = ds.get_running_replica_ids()
10057+
assert ids1 == ["r1"]
10058+
assert ds.get_running_replica_ids() is ids1 # cache hit
10059+
assert ds.get_alive_replica_actor_ids() == {"actor-r1"}
10060+
assert ds.get_active_node_ids() == {"node-r1"}
10061+
10062+
ds._replicas.add(ReplicaState.RUNNING, self._replica("r2"))
10063+
ids2 = ds.get_running_replica_ids()
10064+
assert set(ids2) == {"r1", "r2"} and ids2 is not ids1
10065+
assert ds.get_alive_replica_actor_ids() == {"actor-r1", "actor-r2"}
10066+
assert ds.get_active_node_ids() == {"node-r1", "node-r2"}
10067+
10068+
def test_starting_or_recovering_disables_caching(self):
10069+
ds = self._ds()
10070+
ds._replicas.add(ReplicaState.RUNNING, self._replica("r1"))
10071+
ds._replicas.add(ReplicaState.STARTING, self._replica("r2"))
10072+
a = ds.get_alive_replica_actor_ids()
10073+
b = ds.get_alive_replica_actor_ids()
10074+
# Recomputed each call (attrs may materialize in place), never cached.
10075+
assert a == b and a is not b
10076+
assert ds._alive_replica_actor_ids_memo is None
10077+
10078+
def test_autoscaling_update_skips_same_list_object(self):
10079+
from ray.serve._private import autoscaling_state as as_mod
10080+
10081+
das = as_mod.DeploymentAutoscalingState.__new__(
10082+
as_mod.DeploymentAutoscalingState
10083+
)
10084+
das._running_replicas = []
10085+
rid = Mock()
10086+
rid.to_full_id_str.return_value = "d#r1"
10087+
ids = [rid]
10088+
das.update_running_replica_ids(ids)
10089+
assert das._cached_running_replica_strs == {"d#r1"}
10090+
das._cached_running_replica_strs = {"sentinel"}
10091+
das.update_running_replica_ids(ids) # same object -> skipped
10092+
assert das._cached_running_replica_strs == {"sentinel"}
10093+
das.update_running_replica_ids(list(ids)) # new object -> rebuilt
10094+
assert das._cached_running_replica_strs == {"d#r1"}
10095+
10096+
1001810097
if __name__ == "__main__":
1001910098
sys.exit(pytest.main(["-v", "-s", __file__]))

0 commit comments

Comments
 (0)