1010from copy import copy
1111from dataclasses import dataclass
1212from enum import Enum
13- from functools import reduce
14- from operator import xor
1513from typing import Any , Callable , Deque , Dict , List , Optional , Set , Tuple
1614
1715import 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+
21622174class 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
0 commit comments