1010from copy import copy
1111from dataclasses import dataclass
1212from 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
1525import ray
1626from 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+
21742198class 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
0 commit comments