Skip to content

Commit abe8f6a

Browse files
[serve] Cache per-tick replica id walks behind a container mutation version
Squashed for a clean rebase onto the A6 tip. The per-tick id walks (alive-actor-ids, active-node-ids, running-ids, the autoscaler id-set rebuild) recompute identical results every steady-state tick. Memoize each on a ReplicaStateContainer mutation version, bumped on add / non-empty pop / remove, and disarm while STARTING or RECOVERING replicas exist. Review fixes included: manager-level getters return frozensets so the memo cannot be mutated by callers (with set() at the one mutating caller), AutoscalingContext.running_replicas is copied to a list at the public boundary, annotations corrected, and an end-to-end test that 20 steady-state ticks do not bump the version and all three getters return identical objects. Signed-off-by: john.taylor <john.taylor@anyscale.com>
1 parent 7ba0799 commit abe8f6a

4 files changed

Lines changed: 168 additions & 33 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: 131 additions & 31 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,20 @@ 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+
21742198
class ReplicaStateContainer:
21752199
"""Container for mapping ReplicaStates to lists of DeploymentReplicas."""
21762200

@@ -2181,6 +2205,12 @@ def __init__(self, on_replica_state_change=None):
21812205
# Incremental (state, version) counts for O(1) version-filtered count()
21822206
# (maintained on add/pop/remove) -> replaces the per-tick O(N) version scan.
21832207
self._sv_counts: Dict[tuple, int] = defaultdict(int)
2208+
# Bumped on every mutation; derived id collections memoize on it.
2209+
self._mutation_version: int = 0
2210+
2211+
@property
2212+
def mutation_version(self) -> int:
2213+
return getattr(self, "_mutation_version", 0)
21842214

21852215
def __getstate__(self):
21862216
# Exclude the callback to keep the container picklable (the callback
@@ -2204,6 +2234,7 @@ def add(self, state: ReplicaState, replica: DeploymentReplica):
22042234
self._replicas[state].append(replica)
22052235
self._replica_id_index[replica.replica_id] = replica
22062236
self._sv_counts[(state, replica.version)] += 1
2237+
self._mutation_version = getattr(self, "_mutation_version", 0) + 1
22072238
if self._on_replica_state_change and state != old_state:
22082239
self._on_replica_state_change(old_state, state)
22092240

@@ -2301,6 +2332,8 @@ def pop(
23012332
for replica in replicas:
23022333
self._replica_id_index.pop(replica.replica_id, None)
23032334

2335+
if replicas:
2336+
self._mutation_version = getattr(self, "_mutation_version", 0) + 1
23042337
return replicas
23052338

23062339
def count(
@@ -2374,6 +2407,8 @@ def remove(self, replica_ids: Set[ReplicaID]) -> List[DeploymentReplica]:
23742407
remaining.append(replica)
23752408
if found_any:
23762409
self._replicas[state] = remaining
2410+
if removed:
2411+
self._mutation_version = getattr(self, "_mutation_version", 0) + 1
23772412
return removed
23782413

23792414
def __str__(self):
@@ -2950,7 +2985,11 @@ def __init__(
29502985
self._rank_manager = DeploymentRankManager(
29512986
fail_on_rank_error=RAY_SERVE_FAIL_ON_RANK_ERROR
29522987
)
2953-
self._last_rank_membership_ids: Optional[Set[str]] = None
2988+
self._last_rank_membership_version: Optional[int] = None
2989+
# (token, result) memos for per-tick id-collection walks.
2990+
self._alive_replica_actor_ids_memo = None
2991+
self._running_replica_ids_memo = None
2992+
self._active_node_ids_memo = None
29542993

29552994
self.replica_average_ongoing_requests: Dict[str, float] = {}
29562995

@@ -3327,16 +3366,42 @@ def _terminally_failed(self) -> bool:
33273366
)
33283367
return replica_failed or self.deployment_actor_terminally_failed()
33293368

3330-
def get_alive_replica_actor_ids(self) -> Set[str]:
3331-
return {replica.actor_id for replica in self._replicas.get()}
3369+
def _membership_cache_token(self) -> Optional[int]:
3370+
"""Cache key for derived id collections, or None while uncacheable.
33323371
3333-
def get_running_replica_ids(self) -> List[ReplicaID]:
3334-
return [
3335-
replica.replica_id
3336-
for replica in self._replicas.get(
3337-
[ReplicaState.RUNNING, ReplicaState.PENDING_MIGRATION]
3372+
The container version tracks add/pop/remove, but STARTING/RECOVERING
3373+
replicas can have actor_id/actor_node_id materialize in place without
3374+
a container mutation -- disable caching while any exist.
3375+
"""
3376+
if (
3377+
self._replicas.count(
3378+
states=[ReplicaState.STARTING, ReplicaState.RECOVERING]
33383379
)
3339-
]
3380+
> 0
3381+
):
3382+
return None
3383+
return self._replicas.mutation_version
3384+
3385+
def get_alive_replica_actor_ids(self) -> FrozenSet[str]:
3386+
return _memoized_walk(
3387+
self,
3388+
"_alive_replica_actor_ids_memo",
3389+
self._membership_cache_token(),
3390+
lambda: frozenset(replica.actor_id for replica in self._replicas.get()),
3391+
)
3392+
3393+
def get_running_replica_ids(self) -> Tuple[ReplicaID, ...]:
3394+
return _memoized_walk(
3395+
self,
3396+
"_running_replica_ids_memo",
3397+
self._membership_cache_token(),
3398+
lambda: tuple(
3399+
replica.replica_id
3400+
for replica in self._replicas.get(
3401+
[ReplicaState.RUNNING, ReplicaState.PENDING_MIGRATION]
3402+
)
3403+
),
3404+
)
33403405

33413406
def get_running_replica_infos(self) -> List[RunningReplicaInfo]:
33423407
return [
@@ -3388,11 +3453,16 @@ def get_active_node_ids(self) -> Set[str]:
33883453
# node before all the replicas are migrated.
33893454
ReplicaState.PENDING_MIGRATION,
33903455
]
3391-
return {
3392-
replica.actor_node_id
3393-
for replica in self._replicas.get(active_states)
3394-
if replica.actor_node_id is not None
3395-
}
3456+
return _memoized_walk(
3457+
self,
3458+
"_active_node_ids_memo",
3459+
self._membership_cache_token(),
3460+
lambda: frozenset(
3461+
replica.actor_node_id
3462+
for replica in self._replicas.get(active_states)
3463+
if replica.actor_node_id is not None
3464+
),
3465+
)
33963466

33973467
def list_replica_details(self) -> List[ReplicaDetails]:
33983468
return [replica.actor_details for replica in self._replicas.get()]
@@ -4997,7 +5067,7 @@ def _maybe_check_rank_consistency(self) -> None:
49975067
The pass is O(N) with heavy constants and used to run on every
49985068
control-loop tick in steady state -- at 10K+ replicas it monopolizes
49995069
the controller loop. Rank consistency can only be violated by
5000-
membership changes, so the active replica-id set gates it.
5070+
membership changes, so the container mutation version gates it.
50015071
"""
50025072
# O(1) guards first -- `get()` below copies the whole replica list, and these
50035073
# two rule out the busiest ticks (rollouts, migrations) without paying for it.
@@ -5010,11 +5080,12 @@ def _maybe_check_rank_consistency(self) -> None:
50105080
or self._replicas.count(states=[ReplicaState.STARTING]) != 0
50115081
):
50125082
return
5083+
version = self._replicas.mutation_version
5084+
if version == getattr(self, "_last_rank_membership_version", None):
5085+
return
50135086
active_replicas = self._replicas.get()
50145087
if not active_replicas:
5015-
return
5016-
active_replica_ids = {r.replica_id.unique_id for r in active_replicas}
5017-
if active_replica_ids == self._last_rank_membership_ids:
5088+
self._last_rank_membership_version = version
50185089
return
50195090
replicas_to_reconfigure = (
50205091
self._rank_manager.check_rank_consistency_and_reassign_minimally(
@@ -5030,7 +5101,7 @@ def _maybe_check_rank_consistency(self) -> None:
50305101
if checked_cleanly:
50315102
# Deliberate: a deployment that keeps erroring never caches and is
50325103
# rechecked every tick, rather than latching an unvalidated membership.
5033-
self._last_rank_membership_ids = active_replica_ids
5104+
self._last_rank_membership_version = version
50345105

50355106
def _handle_deployment_actor_failed_health_check(
50365107
self,
@@ -5798,6 +5869,9 @@ def __init__(
57985869
self._shutting_down = False
57995870

58005871
self._deployment_states: Dict[DeploymentID, DeploymentState] = {}
5872+
# (key, result) memos for cross-deployment id-collection walks.
5873+
self._alive_replica_actor_ids_memo = None
5874+
self._active_node_ids_memo = None
58015875
# Monotonic counter bumped whenever an ingress deployment's running-replica
58025876
# set (node/ports included) changes; the controller gates the direct-ingress port
58035877
# reconcile on it, skipping the O(replicas) pass on ticks with no change.
@@ -6124,12 +6198,31 @@ def get_deployment_statuses(
61246198
statuses.append(state.curr_status_info)
61256199
return statuses
61266200

6127-
def get_alive_replica_actor_ids(self) -> Set[str]:
6128-
alive_replica_actor_ids = set()
6129-
for ds in self._deployment_states.values():
6130-
alive_replica_actor_ids |= ds.get_alive_replica_actor_ids()
6131-
6132-
return alive_replica_actor_ids
6201+
def _membership_cache_key(self) -> Optional[tuple]:
6202+
"""Combined per-deployment token, or None if any deployment is
6203+
uncacheable. Deployment add/remove changes the key shape."""
6204+
parts = []
6205+
for deployment_id, ds in self._deployment_states.items():
6206+
token = ds._membership_cache_token()
6207+
if token is None:
6208+
return None
6209+
parts.append((deployment_id, token))
6210+
return tuple(parts)
6211+
6212+
def get_alive_replica_actor_ids(self) -> FrozenSet[str]:
6213+
def compute():
6214+
alive_replica_actor_ids = set()
6215+
for ds in self._deployment_states.values():
6216+
alive_replica_actor_ids |= ds.get_alive_replica_actor_ids()
6217+
# Frozen: the memo is handed to callers and must not be mutable.
6218+
return frozenset(alive_replica_actor_ids)
6219+
6220+
return _memoized_walk(
6221+
self,
6222+
"_alive_replica_actor_ids_memo",
6223+
self._membership_cache_key(),
6224+
compute,
6225+
)
61336226

61346227
def get_deployment_ids(self) -> List[DeploymentID]:
61356228
return list(self._deployment_states.keys())
@@ -6556,16 +6649,23 @@ def record_request_routing_info(self, info: RequestRoutingInfo) -> None:
65566649
return
65576650
self._deployment_states[deployment_id].record_request_routing_info(info)
65586651

6559-
def get_active_node_ids(self) -> Set[str]:
6652+
def get_active_node_ids(self) -> FrozenSet[str]:
65606653
"""Return set of node ids with running replicas of any deployment.
65616654
65626655
This is used to determine which node has replicas. Only nodes with replicas and
65636656
head node should have active proxies.
65646657
"""
6565-
node_ids = set()
6566-
for deployment_state in self._deployment_states.values():
6567-
node_ids.update(deployment_state.get_active_node_ids())
6568-
return node_ids
6658+
6659+
def compute():
6660+
node_ids = set()
6661+
for deployment_state in self._deployment_states.values():
6662+
node_ids.update(deployment_state.get_active_node_ids())
6663+
# Frozen: the memo is handed to callers and must not be mutable.
6664+
return frozenset(node_ids)
6665+
6666+
return _memoized_walk(
6667+
self, "_active_node_ids_memo", self._membership_cache_key(), compute
6668+
)
65696669

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

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10281,5 +10281,34 @@ def test_starting_replicas_skip_the_pass(
1028110281
assert ds._rank_manager.consistency_calls == 0
1028210282

1028310283

10284+
def test_steady_state_ticks_do_not_bump_mutation_version(mock_deployment_state_manager):
10285+
"""The memo's whole value: an idle tick must not mutate the container."""
10286+
create_dsm, _, _, _ = mock_deployment_state_manager
10287+
dsm = create_dsm()
10288+
info, v = deployment_info(num_replicas=8, version="1")
10289+
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
10290+
dsm.save_checkpoint()
10291+
ds = dsm._get_deployment_state_for_testing(TEST_DEPLOYMENT_ID)
10292+
dsm.update()
10293+
for replica in ds._replicas.get([ReplicaState.STARTING]):
10294+
replica._actor.set_ready()
10295+
dsm.update()
10296+
check_counts(ds, total=8, by_state=[(ReplicaState.RUNNING, 8, v)])
10297+
for _ in range(3):
10298+
dsm.update()
10299+
v0 = ds._replicas.mutation_version
10300+
alive, running, nodes = (
10301+
ds.get_alive_replica_actor_ids(),
10302+
ds.get_running_replica_ids(),
10303+
ds.get_active_node_ids(),
10304+
)
10305+
for _ in range(20):
10306+
dsm.update()
10307+
assert ds._replicas.mutation_version == v0
10308+
assert ds.get_alive_replica_actor_ids() is alive
10309+
assert ds.get_running_replica_ids() is running
10310+
assert ds.get_active_node_ids() is nodes
10311+
10312+
1028410313
if __name__ == "__main__":
1028510314
sys.exit(pytest.main(["-v", "-s", __file__]))

0 commit comments

Comments
 (0)