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,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+
21742203class 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