chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# experimental_configs key overriding the per-node base port.
|
||||
KV_EVENTS_PORT_BASE_KEY = "KV_EVENTS_PORT_BASE"
|
||||
DEFAULT_KV_EVENTS_PORT_BASE = 5557
|
||||
|
||||
# experimental_configs key overriding the selection service's KV-indexer thread count.
|
||||
KV_INDEXER_THREADS_KEY = "KV_INDEXER_THREADS"
|
||||
DEFAULT_KV_INDEXER_THREADS = 4
|
||||
|
||||
# The engine's KV-event replay (ROUTER) socket sits this many ports above its PUB
|
||||
# port, a separate range so it never collides with the PUB ports of colocated
|
||||
# replicas (PORT_BASE + replica rank). Dynamo's selection service dials it to recover
|
||||
# events missed before its SUB connected.
|
||||
DEFAULT_KV_EVENTS_REPLAY_PORT_OFFSET = 1000
|
||||
|
||||
# TTL for a request's lifecycle tracking on the KV router actor. A live
|
||||
# replica whose completion event was lost (e.g. a batch dropped on a
|
||||
# transient actor outage) would otherwise leave its entry tracked forever.
|
||||
REQUEST_TRACKING_TTL_S = 3600
|
||||
@@ -0,0 +1,483 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Set, TypedDict
|
||||
|
||||
import ray
|
||||
from ray import serve
|
||||
from ray.llm._internal.serve.routing_policies.kv_aware.constants import (
|
||||
DEFAULT_KV_INDEXER_THREADS,
|
||||
REQUEST_TRACKING_TTL_S,
|
||||
)
|
||||
from ray.serve._private.common import DeploymentTargetInfo
|
||||
from ray.serve._private.constants import (
|
||||
SERVE_CONTROLLER_NAME,
|
||||
SERVE_LOGGER_NAME,
|
||||
SERVE_NAMESPACE,
|
||||
)
|
||||
from ray.serve._private.long_poll import LongPollClient, LongPollNamespace
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
KV_ROUTER_ACTOR_NAME = "serve_llm_kv_router"
|
||||
|
||||
# Dynamo's selection service keys all worker, indexer, and load state by
|
||||
# (model_name, tenant_id). KVRouterActor is a deployment-scoped actor that
|
||||
# instantiates a selection service and serves exactly one model, so a single
|
||||
# fixed key scopes all of its workers together.
|
||||
_MODEL_NAME = "default"
|
||||
_TENANT_ID = "default"
|
||||
|
||||
# Hooks a replica may invoke through ``KVRouterActor.on_lifecycle_events``.
|
||||
LIFECYCLE_HOOKS = frozenset(
|
||||
{
|
||||
"on_request_added",
|
||||
"on_prefill_complete",
|
||||
"on_decode_progress",
|
||||
"on_request_completed",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_worker_id(replica_unique_id: str) -> int:
|
||||
"""Deterministically derive a Dynamo worker id from a replica's unique id."""
|
||||
return int.from_bytes(
|
||||
hashlib.blake2b(replica_unique_id.encode(), digest_size=8).digest(), "big"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestLifecycle:
|
||||
"""In-flight request load state while the request is served by a replica."""
|
||||
|
||||
worker_id: int
|
||||
prompt_tokens: int = 0
|
||||
# Client-provided output-length estimate (``sampling_params.max_tokens``);
|
||||
# weights each decode block's load by how much generation remains.
|
||||
expected_output_tokens: Optional[int] = None
|
||||
prefill_completed: bool = False
|
||||
output_tokens: int = 0
|
||||
# Running count of KV blocks (prompt + output) the request occupies; the
|
||||
# cursor for booking each newly crossed decode block.
|
||||
total_blocks: int = 0
|
||||
# Monotonic admission time, for the TTL eviction sweep.
|
||||
created_at: float = field(default_factory=time.monotonic)
|
||||
|
||||
|
||||
class WorkerSelection(TypedDict):
|
||||
"""The worker chosen by ``KVRouterActor.select_worker`` for a request."""
|
||||
|
||||
# The chosen worker.
|
||||
worker_id: int
|
||||
# Data-parallel rank within the worker.
|
||||
dp_rank: int
|
||||
# Matched prompt tokens available on the selected worker.
|
||||
overlap_tokens: int
|
||||
# Prompt tokens that still need prefill on the selected worker.
|
||||
effective_prefill_tokens: int
|
||||
|
||||
|
||||
class KVRouterActor:
|
||||
"""Deployment-scoped Ray actor backing KV-aware routing.
|
||||
|
||||
Attached to the LLMServer deployment via Serve's ``DeploymentActorConfig``,
|
||||
independent of any replica's lifetime.
|
||||
|
||||
1. Created once per deployment, attached to the LLMServer deployment via
|
||||
Serve's ``DeploymentActorConfig`` (independent of any replica's lifetime).
|
||||
2. Owns an in-process Dynamo ``SelectionService``.
|
||||
3. Tracks live replicas via a ``LongPollClient`` on ``DEPLOYMENT_TARGETS``,
|
||||
mapping each running replica to a Dynamo worker id.
|
||||
4. The ``SelectionService`` maintains a global KV index radix tree, fed by
|
||||
every replica's KV events; each node records which workers hold that KV block.
|
||||
5. Scoring (``select_worker``) ranks candidate workers by KV-cache overlap
|
||||
and prefill/decode load.
|
||||
6. Books each request's lifecycle into the service's active-load tracker, so
|
||||
in-flight load feeds back into scoring for subsequent requests.
|
||||
"""
|
||||
|
||||
def __init__(self, indexer_threads: int = DEFAULT_KV_INDEXER_THREADS):
|
||||
# KV-cache block size, learned once from the first replica's reported
|
||||
# engine config and passed to the selection service, which uses it to
|
||||
# track the worker's active load and index its KV blocks for overlap.
|
||||
self._block_size: Optional[int] = None
|
||||
self._indexer_threads = indexer_threads
|
||||
# _replica_id_by_worker maps a Dynamo worker id to the running replica's full
|
||||
# id string, kept in sync with the deployment's live replicas over LongPoll.
|
||||
# NOTE (jeffreywang): _replica_id_by_worker is later used by select_worker
|
||||
# to get candidate workers to route among.
|
||||
self._replica_id_by_worker: Dict[int, str] = {}
|
||||
# Per-request state that the lifecycle hooks need, keyed by request id, serves
|
||||
# the following purposes:
|
||||
# 1. Block cursor: Turn cumulative decode tokens into add_output_block deltas.
|
||||
# 2. expected_output_tokens for decode-block decay weighting.
|
||||
# 3. In-flight request set: Free reservation exactly once.
|
||||
# Ordered oldest-first so the TTL sweep pops stale entries off the front.
|
||||
self._requests: "OrderedDict[str, RequestLifecycle]" = OrderedDict()
|
||||
# Reverse index of in-flight request ids per worker, kept in lockstep with
|
||||
# _requests, so remove_worker is O(k) in the worker's requests, not O(N).
|
||||
self._request_ids_by_worker: Dict[int, Set[str]] = {}
|
||||
# Carries the effective prefill tokens select() computed at routing time to
|
||||
# on_request_added, which books them via the explicit create_reservation.
|
||||
# TODO(jeffreywang): this map is only needed because create_reservation
|
||||
# requires the effective prefill tokens to be passed in explicitly. Once the
|
||||
# selection service caches each select() result and create_reservation can
|
||||
# look it up by request id, Ray no longer needs to forward it.
|
||||
self._effective_prefill_tokens_by_request: Dict[str, int] = {}
|
||||
self._pending_tasks: Set[asyncio.Task] = set()
|
||||
self._long_poll_client: Optional[LongPollClient] = None
|
||||
self._create_selection_service()
|
||||
self._start_replica_tracking()
|
||||
|
||||
async def ready(self) -> None:
|
||||
"""Readiness probe for KVAwareRouter to confirm KVRouterActor is initialized
|
||||
before it starts routing requests to it.
|
||||
"""
|
||||
|
||||
def get_block_size(self) -> int:
|
||||
"""Return the KV-cache block size used for decode-block accounting."""
|
||||
return self._block_size
|
||||
|
||||
def _create_selection_service(self) -> None:
|
||||
"""Create the in-process Dynamo selection service for this deployment."""
|
||||
# Imported here, not at module scope: Ray pickles this actor class by
|
||||
# value, and Dynamo's pyo3 classes cannot be pickled as its globals.
|
||||
try:
|
||||
from dynamo.llm import SelectionService
|
||||
except ImportError:
|
||||
self._svc = None
|
||||
logger.warning(
|
||||
"ai-dynamo is not installed; KV-aware routing requires ai-dynamo."
|
||||
)
|
||||
return
|
||||
|
||||
self._svc = SelectionService(indexer_threads=self._indexer_threads)
|
||||
logger.info(
|
||||
"Dynamo SelectionService created (indexer threads %d).",
|
||||
self._indexer_threads,
|
||||
)
|
||||
|
||||
def _start_replica_tracking(self) -> None:
|
||||
"""Subscribe to this deployment's running replicas via LongPollClient."""
|
||||
deployment_id = serve.get_deployment_actor_context().deployment_id
|
||||
controller = ray.get_actor(SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE)
|
||||
self._long_poll_client = LongPollClient(
|
||||
controller,
|
||||
{
|
||||
(
|
||||
LongPollNamespace.DEPLOYMENT_TARGETS,
|
||||
deployment_id,
|
||||
): self._on_deployment_targets,
|
||||
},
|
||||
# Relies on KVRouterActor being an async actor (it defines async
|
||||
# methods), so Ray runs __init__ inside the actor's event loop.
|
||||
call_in_event_loop=asyncio.get_running_loop(),
|
||||
client_id=f"{type(self).__name__}:{deployment_id}",
|
||||
)
|
||||
|
||||
def _schedule(self, coro) -> None:
|
||||
"""Run a coroutine on the actor's event loop, holding a reference until
|
||||
it completes.
|
||||
"""
|
||||
task = asyncio.ensure_future(coro)
|
||||
self._pending_tasks.add(task)
|
||||
task.add_done_callback(self._pending_tasks.discard)
|
||||
|
||||
def _register_block_size(self, block_size: int, replica_id: str) -> None:
|
||||
"""Pin the deployment's KV-cache block size from the first replica's
|
||||
reported engine config.
|
||||
"""
|
||||
if self._block_size is None:
|
||||
self._block_size = block_size
|
||||
logger.info("KV router block size set to %d.", block_size)
|
||||
elif block_size != self._block_size:
|
||||
# Replicas of a deployment are expected to resolve the same block
|
||||
# size, so a mismatch is unexpected. We still register the worker so
|
||||
# the selection service spawns its KV-event listener, but the indexer
|
||||
# only ingests blocks whose size matches the pinned block size, so a
|
||||
# genuinely mismatched replica's KV events would be dropped (its KV
|
||||
# cache never indexed).
|
||||
logger.error(
|
||||
"Replica %s reports KV block size %d but the KV router is "
|
||||
"pinned at %d; registering it at the pinned size (replicas of a "
|
||||
"deployment are expected to agree).",
|
||||
replica_id,
|
||||
block_size,
|
||||
self._block_size,
|
||||
)
|
||||
|
||||
def _on_deployment_targets(self, target_info: DeploymentTargetInfo) -> None:
|
||||
"""LongPoll listener: reconcile tracked workers against the running-replica
|
||||
snapshot.
|
||||
|
||||
Each replica advertises its KV-events endpoint via ``record_routing_stats``
|
||||
(carried in ``RunningReplicaInfo.routing_stats``); newly advertised replicas
|
||||
are registered with the selection service and departed ones evicted.
|
||||
"""
|
||||
members: Dict[int, tuple] = {}
|
||||
for replica in target_info.running_replicas:
|
||||
worker_id = get_worker_id(replica.replica_id.unique_id)
|
||||
kv_event_metadata = replica.routing_stats.get("kv_event_metadata")
|
||||
if kv_event_metadata is not None:
|
||||
members[worker_id] = (
|
||||
replica.replica_id.to_full_id_str(),
|
||||
kv_event_metadata,
|
||||
)
|
||||
|
||||
registered = set(self._replica_id_by_worker)
|
||||
added = members.keys() - registered
|
||||
removed = registered - members.keys()
|
||||
|
||||
for worker_id in removed:
|
||||
self.remove_worker(worker_id)
|
||||
self._replica_id_by_worker.pop(worker_id, None)
|
||||
for worker_id in added:
|
||||
replica_id, kv_event_metadata = members[worker_id]
|
||||
self._register_block_size(kv_event_metadata["block_size"], replica_id)
|
||||
self._replica_id_by_worker[worker_id] = replica_id
|
||||
self._schedule(
|
||||
self._upsert_worker(worker_id, replica_id, kv_event_metadata)
|
||||
)
|
||||
|
||||
if added or removed:
|
||||
logger.info(
|
||||
"KV router replica membership updated: +%d -%d, tracking %d worker(s).",
|
||||
len(added),
|
||||
len(removed),
|
||||
len(self._replica_id_by_worker),
|
||||
)
|
||||
|
||||
def remove_worker(self, worker_id: int) -> None:
|
||||
"""Evict a departed replica's worker and its KV blocks from the
|
||||
selection service.
|
||||
"""
|
||||
# Drop the departed replica's in-flight requests; their completions can
|
||||
# never arrive, so they would otherwise leak. delete_worker below frees
|
||||
# their load in the service, so no per-request free_reservation is needed.
|
||||
for request_id in self._request_ids_by_worker.pop(worker_id, set()):
|
||||
self._requests.pop(request_id, None)
|
||||
if self._svc is None:
|
||||
return
|
||||
self._schedule(self._svc.delete_worker(worker_id))
|
||||
|
||||
async def _upsert_worker(
|
||||
self, worker_id: int, replica_id: str, kv_event_metadata: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Register a replica's KV-event endpoint with the selection service.
|
||||
|
||||
The selection service spawns a connect-out ZMQ listener to the
|
||||
replica's ``endpoint`` and indexes its live KV events.
|
||||
"""
|
||||
if self._svc is None:
|
||||
return
|
||||
dp_rank = kv_event_metadata["dp_rank"]
|
||||
await self._svc.upsert_worker(
|
||||
{
|
||||
"worker_id": worker_id,
|
||||
"model_name": _MODEL_NAME,
|
||||
"tenant_id": _TENANT_ID,
|
||||
# NOTE: SelectionService requires endpoint to be non-empty although it's left
|
||||
# unused under an external runtime like Ray Serve LLM.
|
||||
# TODO (jeffreywang): Allow empty endpoints upstream.
|
||||
"endpoint": f"ray://{replica_id}",
|
||||
"block_size": self._block_size,
|
||||
# NOTE: max_num_batched_tokens is a proxy of load capacity for load-based
|
||||
# scoring in the selection service.
|
||||
"max_num_batched_tokens": kv_event_metadata["max_num_batched_tokens"],
|
||||
"data_parallel_start_rank": dp_rank,
|
||||
# TODO (jeffreywang): Support KV-aware routing for data parallel deployments.
|
||||
"data_parallel_size": 1,
|
||||
"kv_events_endpoints": {dp_rank: kv_event_metadata["endpoint"]},
|
||||
# The listener dials this on a sequence gap (slow-joiner) to replay
|
||||
# the events it missed before its SUB connected; without it those
|
||||
# events are dropped and never indexed.
|
||||
"replay_endpoint": kv_event_metadata.get("replay_endpoint"),
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
"Registered KV event worker %d for replica %s at %s.",
|
||||
worker_id,
|
||||
replica_id,
|
||||
kv_event_metadata["endpoint"],
|
||||
)
|
||||
|
||||
async def select_worker(
|
||||
self,
|
||||
request_id: str,
|
||||
token_ids: List[int],
|
||||
allowed_worker_ids: List[int],
|
||||
) -> WorkerSelection:
|
||||
"""Score the allowed workers for a request based on KV-cache overlap and
|
||||
load and pick the best one.
|
||||
|
||||
Args:
|
||||
request_id: Unique identifier for the request being routed.
|
||||
token_ids: Prompt token ids used to compute KV-cache overlap.
|
||||
allowed_worker_ids: Candidate worker ids the router may select from.
|
||||
|
||||
Returns:
|
||||
The selected worker (see ``WorkerSelection``).
|
||||
"""
|
||||
if token_ids is None or len(token_ids) == 0:
|
||||
raise ValueError("KV aware routing requires non-empty token_ids.")
|
||||
|
||||
if self._svc is None:
|
||||
# ai-dynamo is not installed, so this deployment cannot score requests.
|
||||
# Fail fast and surface RuntimeError to the client as a 503 via LLMRouter.
|
||||
raise RuntimeError(
|
||||
"KV-aware routing is unavailable because ai-dynamo is not "
|
||||
"installed in the deployment's environment."
|
||||
)
|
||||
selection = await self._svc.select(
|
||||
{
|
||||
"model_name": _MODEL_NAME,
|
||||
"tenant_id": _TENANT_ID,
|
||||
"selection_id": request_id,
|
||||
"token_ids": token_ids,
|
||||
"allowed_worker_ids": allowed_worker_ids,
|
||||
}
|
||||
)
|
||||
self._effective_prefill_tokens_by_request[request_id] = selection[
|
||||
"effective_prefill_tokens"
|
||||
]
|
||||
return {
|
||||
"worker_id": selection["worker_id"],
|
||||
"dp_rank": selection["dp_rank"],
|
||||
"overlap_tokens": selection["overlap"]["longest_matched"],
|
||||
"effective_prefill_tokens": selection["effective_prefill_tokens"],
|
||||
}
|
||||
|
||||
async def on_lifecycle_events(self, events: List[tuple]) -> None:
|
||||
"""Apply a replica's ``(hook_name, args)`` lifecycle events in order.
|
||||
|
||||
The hooks are order-sensitive (e.g. a completion arriving before its
|
||||
admission would resurrect an evicted request) so a replica sends its
|
||||
events in submission order, batched into one call.
|
||||
"""
|
||||
if self._svc is None or self._block_size is None:
|
||||
return
|
||||
for hook_name, args in events:
|
||||
if hook_name not in LIFECYCLE_HOOKS:
|
||||
logger.warning("Ignoring unknown lifecycle hook %s", hook_name)
|
||||
continue
|
||||
try:
|
||||
await getattr(self, hook_name)(*args)
|
||||
except Exception:
|
||||
# One hook raising must not abort the batch and drop other events.
|
||||
logger.exception(
|
||||
"KV lifecycle hook %s failed; skipping it and continuing.",
|
||||
hook_name,
|
||||
)
|
||||
|
||||
async def on_request_added(
|
||||
self,
|
||||
request_id: str,
|
||||
worker_id: int,
|
||||
token_ids: List[int],
|
||||
expected_output_tokens: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Admit a routed request into ``worker_id``'s active load, booking it
|
||||
into the selection service which computes the worker's KV overlap from
|
||||
``token_ids``, so the recorded prefill excludes the cached prefix."""
|
||||
await self._evict_stale_requests()
|
||||
prompt_tokens = len(token_ids)
|
||||
self._requests[request_id] = RequestLifecycle(
|
||||
worker_id=worker_id,
|
||||
prompt_tokens=prompt_tokens,
|
||||
expected_output_tokens=expected_output_tokens,
|
||||
total_blocks=math.ceil(prompt_tokens / self._block_size),
|
||||
)
|
||||
self._request_ids_by_worker.setdefault(worker_id, set()).add(request_id)
|
||||
effective_prefill_tokens = self._effective_prefill_tokens_by_request.pop(
|
||||
request_id, None
|
||||
)
|
||||
|
||||
await self._svc.create_reservation(
|
||||
{
|
||||
"model_name": _MODEL_NAME,
|
||||
"tenant_id": _TENANT_ID,
|
||||
"reservation_id": request_id,
|
||||
"worker_id": worker_id,
|
||||
"token_ids": token_ids,
|
||||
"expected_output_tokens": expected_output_tokens,
|
||||
"effective_prefill_tokens": effective_prefill_tokens,
|
||||
}
|
||||
)
|
||||
if request_id not in self._requests:
|
||||
await self._svc.free_reservation(request_id)
|
||||
|
||||
async def on_prefill_complete(self, request_id: str) -> None:
|
||||
"""Record a request's prefill -> decode transition, dropping its prefill
|
||||
load in the selection service."""
|
||||
state = self._requests.get(request_id)
|
||||
if state is None:
|
||||
return
|
||||
state.prefill_completed = True
|
||||
await self._svc.prefill_complete(request_id)
|
||||
|
||||
async def on_decode_progress(
|
||||
self, request_id: str, cumulative_output_tokens: int
|
||||
) -> None:
|
||||
"""Advance ``request_id`` to an exact cumulative output-token count,
|
||||
booking one decode block in the selection service per crossed boundary.
|
||||
"""
|
||||
state = self._requests.get(request_id)
|
||||
if state is None:
|
||||
return
|
||||
state.output_tokens = cumulative_output_tokens
|
||||
new_total_blocks = math.ceil(
|
||||
(state.prompt_tokens + cumulative_output_tokens) / self._block_size
|
||||
)
|
||||
decay_fraction = self._get_decay_fraction(state)
|
||||
while new_total_blocks > state.total_blocks:
|
||||
state.total_blocks += 1
|
||||
self._svc.add_output_block(request_id, decay_fraction=decay_fraction)
|
||||
|
||||
async def on_request_completed(self, request_id: str) -> None:
|
||||
"""Free ``request_id`` from the selection service's active load and the
|
||||
local view."""
|
||||
self._effective_prefill_tokens_by_request.pop(request_id, None)
|
||||
state = self._requests.pop(request_id, None)
|
||||
if state is not None:
|
||||
self._untrack_worker_request(request_id, state.worker_id)
|
||||
await self._svc.free_reservation(request_id)
|
||||
|
||||
def _untrack_worker_request(self, request_id: str, worker_id: int) -> None:
|
||||
"""Drop a request from the per-worker reverse index, keeping it in
|
||||
lockstep with ``_requests``."""
|
||||
request_ids = self._request_ids_by_worker.get(worker_id)
|
||||
if request_ids is not None:
|
||||
request_ids.discard(request_id)
|
||||
if not request_ids:
|
||||
del self._request_ids_by_worker[worker_id]
|
||||
|
||||
async def _evict_stale_requests(self) -> None:
|
||||
"""Backstop for a lost completion on a live replica: evict requests tracked
|
||||
past ``REQUEST_TRACKING_TTL_S``, freeing their reservations.
|
||||
"""
|
||||
cutoff = time.monotonic() - REQUEST_TRACKING_TTL_S
|
||||
while self._requests:
|
||||
request_id, state = next(iter(self._requests.items()))
|
||||
if state.created_at > cutoff:
|
||||
break
|
||||
self._requests.popitem(last=False)
|
||||
self._untrack_worker_request(request_id, state.worker_id)
|
||||
self._effective_prefill_tokens_by_request.pop(request_id, None)
|
||||
logger.warning(
|
||||
"Evicting stale KV request %s (tracked > %ds without completion); "
|
||||
"freeing its reservation.",
|
||||
request_id,
|
||||
REQUEST_TRACKING_TTL_S,
|
||||
)
|
||||
await self._svc.free_reservation(request_id)
|
||||
|
||||
def _get_decay_fraction(self, state: RequestLifecycle) -> Optional[float]:
|
||||
"""Fraction of output still expected, or ``None`` without an estimate;
|
||||
weights each decode block by how much generation remains."""
|
||||
if not state.expected_output_tokens:
|
||||
return None
|
||||
return max(0.0, 1.0 - state.output_tokens / state.expected_output_tokens)
|
||||
@@ -0,0 +1,122 @@
|
||||
import logging
|
||||
import random
|
||||
from typing import List, Optional
|
||||
|
||||
import ray
|
||||
from ray.actor import ActorHandle
|
||||
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
|
||||
from ray.llm._internal.serve.core.ingress.tokenizer import REQUEST_TOKEN_IDS_KWARG
|
||||
from ray.llm._internal.serve.routing_policies.kv_aware.kv_aware_actor import (
|
||||
KV_ROUTER_ACTOR_NAME,
|
||||
get_worker_id,
|
||||
)
|
||||
from ray.serve._private.constants import (
|
||||
SERVE_DEPLOYMENT_ACTOR_PREFIX,
|
||||
SERVE_LOGGER_NAME,
|
||||
SERVE_NAMESPACE,
|
||||
)
|
||||
from ray.serve._private.request_router.common import PendingRequest
|
||||
from ray.serve._private.request_router.replica_wrapper import RunningReplica
|
||||
from ray.serve._private.request_router.request_router import RequestRouter
|
||||
from ray.serve.config import RequestRouterConfig
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
class KVAwareRouter(RequestRouter):
|
||||
"""Routes each request to the candidate that best balances expected KV-cache
|
||||
overlap against the worker's current prefill/decode load.
|
||||
|
||||
Scoring is delegated to the deployment-scoped ``KVRouterActor`` (which owns the
|
||||
Dynamo selection service and the global KV index); this per-handle router stays
|
||||
thin and simply maps candidate replicas to/from Dynamo worker ids.
|
||||
"""
|
||||
|
||||
def initialize_state(self):
|
||||
"""Resolve the deployment's ``KVRouterActor``.
|
||||
|
||||
The actor is attached to this deployment via ``DeploymentActorConfig``
|
||||
whenever the request router is a ``KVAwareRouter``, so it exists by the time
|
||||
requests route. We resolve its Serve-generated name and block on a cheap
|
||||
call to confirm it finished initializing, so the first routed request finds
|
||||
a ready scorer.
|
||||
"""
|
||||
self._kv_router_actor = self._discover_kv_router_actor()
|
||||
# Synchronization barrier: Ray defers actor methods until __init__ completes,
|
||||
# so awaiting any method blocks until KVRouterActor is constructed.
|
||||
ray.get(self._kv_router_actor.ready.remote())
|
||||
|
||||
def _discover_kv_router_actor(self) -> ActorHandle:
|
||||
"""Handle to this deployment's ``KVRouterActor`` by its Serve-scoped name."""
|
||||
prefix = (
|
||||
f"{SERVE_DEPLOYMENT_ACTOR_PREFIX}"
|
||||
f"{self._deployment_id.app_name}::{self._deployment_id.name}::"
|
||||
)
|
||||
suffix = f"::{KV_ROUTER_ACTOR_NAME}"
|
||||
for entry in ray.util.list_named_actors(all_namespaces=True):
|
||||
name = entry.get("name") or ""
|
||||
if (
|
||||
entry.get("namespace") == SERVE_NAMESPACE
|
||||
and name.startswith(prefix)
|
||||
and name.endswith(suffix)
|
||||
):
|
||||
return ray.get_actor(name, namespace=SERVE_NAMESPACE)
|
||||
raise RuntimeError(
|
||||
f"KVRouterActor for deployment {self._deployment_id} not found; it must "
|
||||
"be attached via DeploymentActorConfig when using KVAwareRouter."
|
||||
)
|
||||
|
||||
async def choose_replicas(
|
||||
self,
|
||||
candidate_replicas: List[RunningReplica],
|
||||
pending_request: Optional[PendingRequest] = None,
|
||||
) -> List[List[RunningReplica]]:
|
||||
"""Choose the candidate replica(s) to route ``pending_request`` to.
|
||||
|
||||
Maps the candidate replicas to their Dynamo worker ids, asks the
|
||||
``KVRouterActor`` to rank them via ``select_worker``, and routes to
|
||||
the chosen worker's replica. With direct streaming enabled, HAProxy
|
||||
then forwards the original request to that replica.
|
||||
|
||||
Requests with no prompt token ids have nothing to score on, so they route
|
||||
to a random candidate. This covers the pre-routing ``/tokenize`` RPC (routed
|
||||
before token ids exist) and token-less fallbacks (batch prompts,
|
||||
truncated/unparseable bodies).
|
||||
TODO (jeffreywang): Move pre-routing tokenization to KVRouterActor while
|
||||
ensuring tokenization correctness.
|
||||
|
||||
Args:
|
||||
candidate_replicas: The replicas eligible to serve the request.
|
||||
pending_request: The request being routed.
|
||||
|
||||
Returns:
|
||||
Ranked groups of replicas.
|
||||
"""
|
||||
token_ids = (
|
||||
pending_request.kwargs.get(REQUEST_TOKEN_IDS_KWARG)
|
||||
if pending_request is not None
|
||||
else None
|
||||
)
|
||||
if not token_ids:
|
||||
return [[random.choice(candidate_replicas)]] if candidate_replicas else []
|
||||
|
||||
worker_id_to_replica = {
|
||||
get_worker_id(replica.replica_id.unique_id): replica
|
||||
for replica in candidate_replicas
|
||||
}
|
||||
selection = await self._kv_router_actor.select_worker.remote(
|
||||
pending_request.metadata.request_id,
|
||||
token_ids,
|
||||
list(worker_id_to_replica),
|
||||
)
|
||||
return [[worker_id_to_replica[selection["worker_id"]]]]
|
||||
|
||||
|
||||
def is_kv_aware(llm_config: LLMConfig) -> bool:
|
||||
"""Whether ``llm_config`` selects a ``KVAwareRouter`` for replica selection."""
|
||||
request_router_config = llm_config.deployment_config.get("request_router_config")
|
||||
if isinstance(request_router_config, dict):
|
||||
request_router_config = RequestRouterConfig(**request_router_config)
|
||||
return isinstance(request_router_config, RequestRouterConfig) and issubclass(
|
||||
request_router_config.get_request_router_class(), KVAwareRouter
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Helpers for wiring KV-aware routing into an LLM deployment."""
|
||||
|
||||
import logging
|
||||
|
||||
import ray
|
||||
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
|
||||
from ray.llm._internal.serve.routing_policies.kv_aware.constants import (
|
||||
DEFAULT_KV_INDEXER_THREADS,
|
||||
KV_INDEXER_THREADS_KEY,
|
||||
)
|
||||
from ray.llm._internal.serve.routing_policies.kv_aware.kv_aware_actor import (
|
||||
KV_ROUTER_ACTOR_NAME,
|
||||
KVRouterActor,
|
||||
)
|
||||
from ray.llm._internal.serve.routing_policies.kv_aware.kv_aware_router import (
|
||||
is_kv_aware,
|
||||
)
|
||||
from ray.llm._internal.serve.routing_policies.kv_aware.vllm.kv_events import (
|
||||
configure_kv_events_for_kv_routing,
|
||||
)
|
||||
from ray.serve._private.constants import SERVE_LOGGER_NAME
|
||||
from ray.serve.config import DeploymentActorConfig
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
def _maybe_setup_kv_aware_routing(
|
||||
deployment_options: dict, llm_config: LLMConfig
|
||||
) -> None:
|
||||
"""Set up KV-aware routing when the deployment's request router is a
|
||||
KVAwareRouter.
|
||||
|
||||
Attaches the KVRouterActor, which owns the deployment's global KV radix
|
||||
tree, and enables the engine KV events that feed it.
|
||||
"""
|
||||
if not is_kv_aware(llm_config):
|
||||
if llm_config.engine_kwargs.get("kv_events_config") is not None:
|
||||
logger.warning(
|
||||
"engine_kwargs['kv_events_config'] is set but the deployment's "
|
||||
"request router is not a KVAwareRouter, so the engine's KV events "
|
||||
"will not be consumed. To use them, configure KVAwareRouter via "
|
||||
"deployment_config.request_router_config."
|
||||
)
|
||||
return
|
||||
|
||||
# Keep the engine's token-tracking gate which reads llm_config consistent
|
||||
# with the router resolved here from the merged deployment options.
|
||||
llm_config.deployment_config["request_router_config"] = deployment_options[
|
||||
"request_router_config"
|
||||
]
|
||||
|
||||
deployment_options["deployment_actors"] = [
|
||||
*deployment_options.get("deployment_actors", []),
|
||||
DeploymentActorConfig(
|
||||
name=KV_ROUTER_ACTOR_NAME,
|
||||
actor_class=ray.remote(KVRouterActor),
|
||||
actor_options={"num_cpus": 0},
|
||||
init_kwargs={
|
||||
"indexer_threads": llm_config.experimental_configs.get(
|
||||
KV_INDEXER_THREADS_KEY, DEFAULT_KV_INDEXER_THREADS
|
||||
),
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
configure_kv_events_for_kv_routing(llm_config)
|
||||
@@ -0,0 +1 @@
|
||||
"""vLLM-specific KV-aware routing helpers."""
|
||||
@@ -0,0 +1,163 @@
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import ray
|
||||
from ray import serve
|
||||
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
|
||||
from ray.llm._internal.serve.routing_policies.kv_aware.constants import (
|
||||
DEFAULT_KV_EVENTS_PORT_BASE,
|
||||
DEFAULT_KV_EVENTS_REPLAY_PORT_OFFSET,
|
||||
KV_EVENTS_PORT_BASE_KEY,
|
||||
)
|
||||
from ray.llm._internal.serve.routing_policies.kv_aware.kv_aware_router import (
|
||||
is_kv_aware,
|
||||
)
|
||||
from ray.serve._private.constants import SERVE_LOGGER_NAME
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
def configure_kv_events_for_kv_routing(llm_config: LLMConfig) -> None:
|
||||
"""
|
||||
Enable engine KV-cache events for a KV-aware-routed deployment.
|
||||
"""
|
||||
engine_kwargs = llm_config.engine_kwargs
|
||||
if engine_kwargs.get("enable_prefix_caching") is False:
|
||||
logger.warning(
|
||||
"KV-aware routing is configured but enable_prefix_caching is False; "
|
||||
"the engine will not emit KV-cache events."
|
||||
)
|
||||
|
||||
llm_config.update_engine_kwargs(
|
||||
kv_events_config={
|
||||
"enable_kv_cache_events": True,
|
||||
"publisher": "zmq",
|
||||
"endpoint": _default_kv_events_endpoint(llm_config),
|
||||
# Bind a replay (ROUTER) socket so the selection service can recover
|
||||
# the events it missed before its SUB connected (slow-joiner gap).
|
||||
"replay_endpoint": _default_kv_events_replay_endpoint(llm_config),
|
||||
}
|
||||
)
|
||||
|
||||
_pin_block_hash_seed(llm_config)
|
||||
|
||||
|
||||
def _pin_block_hash_seed(llm_config: LLMConfig) -> None:
|
||||
"""Make engine block hashes content-deterministic across replicas.
|
||||
|
||||
The KV router's global indexer chains and dedups blocks by the engines'
|
||||
block hashes, so identical content must hash identically on every
|
||||
replica. vLLM salts its block-hash chain root per process unless
|
||||
``PYTHONHASHSEED`` is set, so pin it deployment-wide.
|
||||
"""
|
||||
runtime_env = dict(llm_config.runtime_env or {})
|
||||
env_vars = dict(runtime_env.get("env_vars") or {})
|
||||
env_vars.setdefault("PYTHONHASHSEED", "0")
|
||||
runtime_env["env_vars"] = env_vars
|
||||
llm_config.runtime_env = runtime_env
|
||||
|
||||
|
||||
def assign_replica_kv_events_endpoint(llm_config: LLMConfig) -> None:
|
||||
"""Pin the engine's KV-events endpoint to a per-replica port.
|
||||
|
||||
Replicas of a deployment share one ``engine_kwargs``, so colocated
|
||||
replicas would otherwise bind the same ZMQ port. Offsets the configured
|
||||
base port by the replica's rank.
|
||||
"""
|
||||
if not is_kv_aware(llm_config):
|
||||
return
|
||||
kv_events_config = llm_config.engine_kwargs.get("kv_events_config")
|
||||
if kv_events_config is None:
|
||||
return
|
||||
updated = dict(kv_events_config)
|
||||
endpoint = updated.pop("endpoint")
|
||||
replay_endpoint = updated.pop("replay_endpoint")
|
||||
# With data parallelism the engine offsets ports by dp_rank under ZmqEventPublisher;
|
||||
# otherwise offset by the replica's node-local rank so colocated replicas don't collide.
|
||||
if llm_config.engine_kwargs.get("data_parallel_rank") is not None:
|
||||
offset = 0
|
||||
else:
|
||||
offset = _get_replica_rank()
|
||||
updated["endpoint"] = _get_offset_endpoint_port(endpoint, offset)
|
||||
updated["replay_endpoint"] = _get_offset_endpoint_port(replay_endpoint, offset)
|
||||
llm_config.update_engine_kwargs(kv_events_config=updated)
|
||||
|
||||
|
||||
def resolve_kv_event_source_endpoint(llm_config: LLMConfig) -> Optional[str]:
|
||||
"""This replica's node-routable KV-events endpoint, for the selection
|
||||
service to connect out to.
|
||||
|
||||
The engine's KV-events endpoint at the replica's node IP; ``None`` when
|
||||
KV-cache events are not enabled.
|
||||
"""
|
||||
if not is_kv_aware(llm_config):
|
||||
return None
|
||||
kv_events_config = llm_config.engine_kwargs.get("kv_events_config")
|
||||
if kv_events_config is None:
|
||||
return None
|
||||
return _get_node_routable_endpoint(llm_config, kv_events_config["endpoint"])
|
||||
|
||||
|
||||
def get_kv_event_routing_stats(
|
||||
llm_config: LLMConfig, block_size: int, max_num_batched_tokens: int
|
||||
) -> Dict[str, Any]:
|
||||
"""Returns this replica's routing-stats payload advertising its KV-events
|
||||
endpoint and the engine's resolved KV-cache block size."""
|
||||
if not is_kv_aware(llm_config):
|
||||
return {}
|
||||
kv_events_config = llm_config.engine_kwargs.get("kv_events_config")
|
||||
if kv_events_config is None:
|
||||
return {}
|
||||
kv_event_metadata = {
|
||||
"endpoint": _get_node_routable_endpoint(
|
||||
llm_config, kv_events_config["endpoint"]
|
||||
),
|
||||
"block_size": block_size,
|
||||
"max_num_batched_tokens": max_num_batched_tokens,
|
||||
"dp_rank": llm_config.engine_kwargs.get("data_parallel_rank") or 0,
|
||||
"replay_endpoint": _get_node_routable_endpoint(
|
||||
llm_config, kv_events_config["replay_endpoint"]
|
||||
),
|
||||
}
|
||||
return {"kv_event_metadata": kv_event_metadata}
|
||||
|
||||
|
||||
def _get_node_routable_endpoint(llm_config: LLMConfig, endpoint: str) -> str:
|
||||
"""Rewrite a wildcard-bound engine endpoint to this replica's node IP.
|
||||
|
||||
Offsets by ``data_parallel_rank`` to match the engine's own per-rank offset,
|
||||
so the selection service dials the right socket.
|
||||
"""
|
||||
dp_rank = llm_config.engine_kwargs.get("data_parallel_rank")
|
||||
if dp_rank is not None:
|
||||
endpoint = _get_offset_endpoint_port(endpoint, dp_rank)
|
||||
port = endpoint.rsplit(":", 1)[1]
|
||||
return f"tcp://{ray.util.get_node_ip_address()}:{port}"
|
||||
|
||||
|
||||
def _get_replica_rank() -> int:
|
||||
return serve.get_replica_context().rank.local_rank
|
||||
|
||||
|
||||
def _default_kv_events_endpoint(llm_config: LLMConfig) -> str:
|
||||
port_base = int(
|
||||
llm_config.experimental_configs.get(
|
||||
KV_EVENTS_PORT_BASE_KEY, DEFAULT_KV_EVENTS_PORT_BASE
|
||||
)
|
||||
)
|
||||
return f"tcp://*:{port_base}"
|
||||
|
||||
|
||||
def _default_kv_events_replay_endpoint(llm_config: LLMConfig) -> str:
|
||||
port_base = int(
|
||||
llm_config.experimental_configs.get(
|
||||
KV_EVENTS_PORT_BASE_KEY, DEFAULT_KV_EVENTS_PORT_BASE
|
||||
)
|
||||
)
|
||||
return f"tcp://*:{port_base + DEFAULT_KV_EVENTS_REPLAY_PORT_OFFSET}"
|
||||
|
||||
|
||||
def _get_offset_endpoint_port(endpoint: str, offset: int) -> str:
|
||||
"""Offset a TCP endpoint's port with vLLM's ZmqEventPublisher convention."""
|
||||
base, port = endpoint.rsplit(":", 1)
|
||||
return f"{base}:{int(port) + offset}"
|
||||
@@ -0,0 +1,192 @@
|
||||
import asyncio
|
||||
from typing import Any, List, Optional, Type
|
||||
|
||||
from vllm.outputs import RequestOutput
|
||||
from vllm.sampling_params import RequestOutputKind
|
||||
from vllm.v1.engine.async_llm import AsyncLLM
|
||||
|
||||
from ray import serve
|
||||
from ray.actor import ActorHandle
|
||||
from ray.exceptions import RayActorError, RayTaskError
|
||||
from ray.llm._internal.serve.observability.logging import get_logger
|
||||
from ray.llm._internal.serve.routing_policies.kv_aware.kv_aware_actor import (
|
||||
KV_ROUTER_ACTOR_NAME,
|
||||
get_worker_id,
|
||||
)
|
||||
from ray.llm._internal.serve.utils.server_utils import get_serve_request_id
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _get_prompt_token_ids(prompt: Any) -> List[int]:
|
||||
"""The prompt's pre-tokenized token ids."""
|
||||
try:
|
||||
return list(prompt["prompt_token_ids"])
|
||||
except (KeyError, TypeError) as e:
|
||||
raise ValueError(
|
||||
"KV-aware token tracking requires a pre-tokenized prompt "
|
||||
f"(dict with 'prompt_token_ids'); got {type(prompt).__name__}"
|
||||
) from e
|
||||
|
||||
|
||||
class LifecycleEventForwarder:
|
||||
"""Ordered, non-blocking bridge from the engine to the KV router actor
|
||||
which maintains per-replica token load statistics.
|
||||
|
||||
``report`` only enqueues locally, so generation never blocks on the actor.
|
||||
A single delivery task per replica drains the queue to the actor, awaiting
|
||||
one ``on_lifecycle_events`` call at a time so events arrive in the order they
|
||||
were reported. Events that pile up during a call are sent together in the
|
||||
next one.
|
||||
"""
|
||||
|
||||
def __init__(self, actor: ActorHandle, worker_id: int):
|
||||
self.actor = actor
|
||||
self.worker_id = worker_id
|
||||
self._events: asyncio.Queue = asyncio.Queue()
|
||||
self._delivery_task: Optional[asyncio.Task] = None
|
||||
|
||||
def report(self, method_name: str, *args) -> None:
|
||||
if self._delivery_task is None or self._delivery_task.done():
|
||||
self._delivery_task = asyncio.get_running_loop().create_task(
|
||||
self._deliver()
|
||||
)
|
||||
self._events.put_nowait((method_name, args))
|
||||
|
||||
async def _deliver(self) -> None:
|
||||
while True:
|
||||
# Wait for the next event, then drain whatever queued up behind it
|
||||
# into the same batch.
|
||||
batch = [await self._events.get()]
|
||||
while not self._events.empty():
|
||||
batch.append(self._events.get_nowait())
|
||||
try:
|
||||
await self.actor.on_lifecycle_events.remote(batch)
|
||||
except (RayActorError, RayTaskError) as e:
|
||||
logger.warning("Dropping KV lifecycle events: %s", e)
|
||||
finally:
|
||||
for _ in batch:
|
||||
self._events.task_done()
|
||||
|
||||
async def flush(self) -> None:
|
||||
"""Wait until every reported event has been delivered."""
|
||||
await self._events.join()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Cancel the delivery task on engine shutdown."""
|
||||
if self._delivery_task is not None:
|
||||
self._delivery_task.cancel()
|
||||
self._delivery_task = None
|
||||
|
||||
|
||||
class RequestTokenTracker:
|
||||
"""Drives the request lifecycle hooks for one ``generate()`` stream."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
forwarder: LifecycleEventForwarder,
|
||||
request_id: str,
|
||||
prompt_token_ids: List[int],
|
||||
expected_output_tokens: Optional[int],
|
||||
):
|
||||
self._forwarder = forwarder
|
||||
self._request_id = request_id
|
||||
self._cumulative = 0
|
||||
self._prefill_marked = False
|
||||
self._finished = False
|
||||
forwarder.report(
|
||||
"on_request_added",
|
||||
request_id,
|
||||
forwarder.worker_id,
|
||||
prompt_token_ids,
|
||||
expected_output_tokens,
|
||||
)
|
||||
|
||||
def on_output(self, output: RequestOutput) -> None:
|
||||
"""Observe one engine ``RequestOutput`` (forwarded to the caller as-is).
|
||||
|
||||
vLLM streams either DELTA chunks or a single FINAL_ONLY chunk; both
|
||||
carry only new tokens, so output progress simply accumulates.
|
||||
"""
|
||||
step_tokens = sum(len(o.token_ids or []) for o in output.outputs)
|
||||
if step_tokens == 0:
|
||||
# No new tokens this step (e.g. a finish-only chunk).
|
||||
return
|
||||
self._cumulative += step_tokens
|
||||
if not self._prefill_marked:
|
||||
# The first output token signals prefill completion.
|
||||
self._prefill_marked = True
|
||||
self._forwarder.report("on_prefill_complete", self._request_id)
|
||||
self._forwarder.report("on_decode_progress", self._request_id, self._cumulative)
|
||||
|
||||
def finish(self) -> None:
|
||||
"""Report completion exactly once."""
|
||||
if not self._finished:
|
||||
self._finished = True
|
||||
self._forwarder.report("on_request_completed", self._request_id)
|
||||
|
||||
|
||||
def enable_token_tracking(engine_cls: Type[AsyncLLM]) -> Type[AsyncLLM]:
|
||||
"""Decorator adding KV-router request lifecycle tracking."""
|
||||
|
||||
class TokenTrackingEngine(engine_cls):
|
||||
_lifecycle_forwarder: Optional[LifecycleEventForwarder] = None
|
||||
_resolve_warned: bool = False
|
||||
|
||||
def _resolve_lifecycle_forwarder(self) -> Optional[LifecycleEventForwarder]:
|
||||
if self._lifecycle_forwarder is None:
|
||||
try:
|
||||
actor = serve.get_deployment_actor(KV_ROUTER_ACTOR_NAME)
|
||||
worker_id = get_worker_id(
|
||||
serve.get_replica_context().replica_id.unique_id
|
||||
)
|
||||
self._lifecycle_forwarder = LifecycleEventForwarder(
|
||||
actor, worker_id
|
||||
)
|
||||
except Exception as e:
|
||||
# Warn once: resolution is retried per request until it succeeds.
|
||||
if not self._resolve_warned:
|
||||
self._resolve_warned = True
|
||||
logger.warning("KV token tracking disabled: %s", e)
|
||||
return self._lifecycle_forwarder
|
||||
|
||||
def shutdown(self, *args, **kwargs):
|
||||
if self._lifecycle_forwarder is not None:
|
||||
self._lifecycle_forwarder.close()
|
||||
return super().shutdown(*args, **kwargs)
|
||||
|
||||
async def generate(self, prompt, sampling_params, request_id, *args, **kwargs):
|
||||
stream = super().generate(
|
||||
prompt, sampling_params, request_id, *args, **kwargs
|
||||
)
|
||||
forwarder = self._resolve_lifecycle_forwarder()
|
||||
# CUMULATIVE repeats output-so-far per chunk; our accounting sums
|
||||
# deltas, so skip it rather than over-count. vLLM's OpenAI layer only
|
||||
# uses DELTA/FINAL_ONLY (*Request.to_sampling_params):
|
||||
# https://github.com/vllm-project/vllm/tree/main/vllm/entrypoints/openai
|
||||
if forwarder is None or (
|
||||
sampling_params.output_kind == RequestOutputKind.CUMULATIVE
|
||||
):
|
||||
async for output in stream:
|
||||
yield output
|
||||
return
|
||||
|
||||
lifecycle_request_id = get_serve_request_id() or request_id
|
||||
tracker = RequestTokenTracker(
|
||||
forwarder,
|
||||
lifecycle_request_id,
|
||||
_get_prompt_token_ids(prompt),
|
||||
# The request's own output cap is its expected length; weights
|
||||
# the selection service's decode-block decay.
|
||||
# TODO(jeffreywang): Use an agent-provided expected-OSL hint for
|
||||
# more accurate decode-load estimation.
|
||||
sampling_params.max_tokens,
|
||||
)
|
||||
try:
|
||||
async for output in stream:
|
||||
tracker.on_output(output)
|
||||
yield output
|
||||
finally:
|
||||
tracker.finish()
|
||||
|
||||
return TokenTrackingEngine
|
||||
@@ -0,0 +1,420 @@
|
||||
# These imports are used for metrics tracking, will remove for PR
|
||||
import logging
|
||||
import time
|
||||
from typing import (
|
||||
Any,
|
||||
List,
|
||||
Optional,
|
||||
)
|
||||
|
||||
import ray
|
||||
from ray.actor import ActorHandle
|
||||
from ray.llm._internal.serve.routing_policies.prefix_aware.prefix_tree import (
|
||||
PrefixTreeActor,
|
||||
)
|
||||
from ray.serve._private.common import ReplicaID
|
||||
from ray.serve._private.constants import (
|
||||
SERVE_CONTROLLER_NAME,
|
||||
SERVE_LOGGER_NAME,
|
||||
SERVE_NAMESPACE,
|
||||
)
|
||||
from ray.serve._private.replica_result import ReplicaResult
|
||||
from ray.serve._private.request_router import (
|
||||
PowerOfTwoChoicesRequestRouter,
|
||||
)
|
||||
from ray.serve._private.request_router.common import (
|
||||
PendingRequest,
|
||||
)
|
||||
from ray.serve._private.request_router.replica_wrapper import (
|
||||
RunningReplica,
|
||||
)
|
||||
from ray.serve._private.request_router.request_router import (
|
||||
LocalityMixin,
|
||||
MultiplexMixin,
|
||||
RequestRouter,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
class PrefixCacheAffinityRouter(LocalityMixin, MultiplexMixin, RequestRouter):
|
||||
"""Extends the PowerOfTwoChoicesRequestRouter with prefix-matching capabilities.
|
||||
|
||||
This request router optimizes replica selection by considering input text prefixes:
|
||||
|
||||
1. Mixes between three strategies to balance prefix cache hit rate and load balancing:
|
||||
- When load is balanced (queue length difference < threshold), it selects replicas
|
||||
with the highest prefix match rate for the input text
|
||||
- When load is balanced but match rate is below 10%, it falls back to the smallest tenants
|
||||
- When load is imbalanced, it uses the default Power of Two selection
|
||||
|
||||
2. Maintains a prefix tree to track which replicas have processed similar inputs:
|
||||
- Inserts prompt text into the prefix tree after routing
|
||||
- Uses this history to inform future routing decisions
|
||||
|
||||
This approach improves performance by routing related requests to the same replicas,
|
||||
increasing cache locality and reducing overhead for language model inference.
|
||||
"""
|
||||
|
||||
def initialize_state(
|
||||
self,
|
||||
imbalanced_threshold: Optional[float] = float("inf"),
|
||||
match_rate_threshold: Optional[float] = 0.1,
|
||||
do_eviction: Optional[bool] = False,
|
||||
eviction_threshold_chars: Optional[int] = 400_000,
|
||||
eviction_target_chars: Optional[int] = 360_000,
|
||||
eviction_interval_secs: Optional[int] = 10,
|
||||
tree_actor: Optional[ActorHandle] = None,
|
||||
):
|
||||
"""Initialize the prefix-aware routing state and configuration.
|
||||
|
||||
Args:
|
||||
imbalanced_threshold: Threshold for queue length difference to consider
|
||||
load balanced. When the difference between replica queue lengths is
|
||||
less than this value, prefix-aware routing is used.
|
||||
match_rate_threshold: Minimum prefix match rate (0.0-1.0) required to
|
||||
use prefix-aware routing. If match rate is below this threshold,
|
||||
falls back to smallest tenant selection.
|
||||
do_eviction: Whether to enable automatic eviction of old prefix tree
|
||||
entries to manage memory usage.
|
||||
eviction_threshold_chars: Maximum number of characters in the prefix
|
||||
tree before eviction is triggered.
|
||||
eviction_target_chars: Target number of characters to reduce the
|
||||
prefix tree to during eviction.
|
||||
eviction_interval_secs: Interval in seconds between eviction checks
|
||||
when eviction is enabled.
|
||||
tree_actor: The actor to use for the prefix tree in a test environment.
|
||||
If None, a detached actor will be created/retrieved.
|
||||
"""
|
||||
# === Prefix-aware routing logic hyperparameters ===
|
||||
self._imbalanced_threshold = imbalanced_threshold
|
||||
self._match_rate_threshold = match_rate_threshold
|
||||
|
||||
# === Eviction policy ===
|
||||
self._do_eviction = do_eviction
|
||||
self._eviction_loop_running = False
|
||||
self._eviction_threshold_chars = eviction_threshold_chars
|
||||
# Default eviction_target_chars to eviction_threshold_chars if not specified
|
||||
self._eviction_target_chars = (
|
||||
eviction_target_chars
|
||||
if eviction_target_chars is not None
|
||||
else eviction_threshold_chars
|
||||
)
|
||||
self._eviction_interval_secs = eviction_interval_secs
|
||||
|
||||
if tree_actor is None:
|
||||
# Create deployment-specific detached actor to avoid replica ID conflicts
|
||||
# in multi-deployment scenarios (e.g., PD disaggregation with DP)
|
||||
deployment_name = self._deployment_id.name if self._deployment_id else None
|
||||
app_name = self._deployment_id.app_name if self._deployment_id else None
|
||||
actor_name = "LlmPrefixTreeActor"
|
||||
actor_namespace_components = [SERVE_NAMESPACE]
|
||||
|
||||
if app_name:
|
||||
actor_namespace_components.append(app_name)
|
||||
if deployment_name:
|
||||
actor_namespace_components.append(deployment_name)
|
||||
actor_namespace = "::".join(actor_namespace_components)
|
||||
|
||||
self._tree_actor = PrefixTreeActor.options(
|
||||
name=actor_name,
|
||||
namespace=actor_namespace,
|
||||
get_if_exists=True,
|
||||
lifetime="detached",
|
||||
).remote()
|
||||
|
||||
# Register the actor with the controller for cleanup on serve.shutdown()
|
||||
controller = ray.get_actor(SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE)
|
||||
ray.get(
|
||||
controller._register_shutdown_cleanup_actor.remote(self._tree_actor)
|
||||
)
|
||||
else:
|
||||
self._tree_actor = tree_actor
|
||||
|
||||
def _extract_text_from_request(self, pending_request: PendingRequest) -> str:
|
||||
"""Extracts the text content from a pending request for prefix matching.
|
||||
|
||||
Searches through request arguments for either 'messages' or 'prompt' attributes,
|
||||
then normalizes the content to a single string representation that can be used
|
||||
for prefix tree operations.
|
||||
|
||||
Args:
|
||||
pending_request: The request to extract text from
|
||||
|
||||
Returns:
|
||||
A string containing the prompt text or concatenated message contents
|
||||
|
||||
Raises:
|
||||
ValueError: If no prompt or messages attribute is found in the request
|
||||
"""
|
||||
prompt = None
|
||||
for arg in pending_request.args:
|
||||
valid_input_types = ["messages", "prompt"]
|
||||
for valid_input_type in valid_input_types:
|
||||
if hasattr(arg, valid_input_type):
|
||||
prompt = (
|
||||
arg.prompt if valid_input_type == "prompt" else arg.messages
|
||||
)
|
||||
break
|
||||
if prompt is not None:
|
||||
break
|
||||
if prompt is None:
|
||||
raise ValueError(
|
||||
"No request with message or prompt attribute found in pending_request.args"
|
||||
)
|
||||
|
||||
return self._normalize_prompt_to_string(prompt)
|
||||
|
||||
def _coerce_to_text(self, value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
return "".join(self._coerce_to_text(item) for item in value)
|
||||
if isinstance(value, dict):
|
||||
text_value = value.get("text")
|
||||
if isinstance(text_value, str):
|
||||
return text_value
|
||||
if "content" in value:
|
||||
return self._coerce_to_text(value["content"])
|
||||
|
||||
return ""
|
||||
|
||||
def _normalize_prompt_to_string(self, prompt: Any) -> str:
|
||||
"""Normalize prompt/messages a single string of characters.
|
||||
This is not exhaustive (e.g. thinking parts, multimodal are not supported).
|
||||
TODO(seiji): find a more maintainable way to normalize the prompt/messages.
|
||||
|
||||
Supported:
|
||||
- string → return as-is
|
||||
- list of strings → concat
|
||||
- list of message dicts with 'content' as string → concat
|
||||
- list of message dicts with 'content' as list of dicts → concat the 'text' fields from those parts
|
||||
"""
|
||||
if isinstance(prompt, str):
|
||||
return prompt
|
||||
|
||||
if isinstance(prompt, list):
|
||||
return "".join(
|
||||
self._coerce_to_text(
|
||||
message.get("content") if isinstance(message, dict) else message
|
||||
)
|
||||
for message in prompt
|
||||
)
|
||||
|
||||
return ""
|
||||
|
||||
async def _prefix_match_best_replicas(
|
||||
self,
|
||||
pending_request: Optional[PendingRequest],
|
||||
candidate_replicas: List[RunningReplica],
|
||||
) -> List[RunningReplica]:
|
||||
"""
|
||||
Returns a set of candidate replicas, of which the one with the smallest replica queue will be chosen.
|
||||
0. Default: same as pow 2 request router, return 2 replicas at random.
|
||||
1. If load is balanced, choose replica(s) with highest prefix match rate. If highest hit rate is below 10% or no match found, use replicas with smallest KV cache usage.
|
||||
2. If load is imbalanced, use default.
|
||||
"""
|
||||
chosen_replica_id_strings = []
|
||||
if (
|
||||
pending_request is not None
|
||||
and pending_request.args is not None
|
||||
and len(pending_request.args) > 0
|
||||
):
|
||||
input_text = self._extract_text_from_request(pending_request)
|
||||
if input_text is not None:
|
||||
# Start Sphinx tag: __begin_load_balance_component__
|
||||
# Check for imbalanced load.
|
||||
highest_queue_len = 0
|
||||
lowest_queue_len = float("inf")
|
||||
not_in_cache: List[ReplicaID] = []
|
||||
if self._use_replica_queue_len_cache:
|
||||
# Populate available queue lens from the cache.
|
||||
for r in candidate_replicas:
|
||||
queue_len = self._replica_queue_len_cache.get(r.replica_id)
|
||||
if queue_len is None or queue_len >= r.max_ongoing_requests:
|
||||
not_in_cache.append(r)
|
||||
else:
|
||||
highest_queue_len = max(highest_queue_len, queue_len)
|
||||
lowest_queue_len = min(lowest_queue_len, queue_len)
|
||||
else:
|
||||
not_in_cache = candidate_replicas
|
||||
if len(not_in_cache) > 0:
|
||||
for r, queue_len in await self._probe_queue_lens(
|
||||
not_in_cache,
|
||||
0,
|
||||
):
|
||||
if queue_len is None:
|
||||
continue
|
||||
highest_queue_len = max(highest_queue_len, queue_len)
|
||||
lowest_queue_len = min(lowest_queue_len, queue_len)
|
||||
|
||||
is_imbalanced = (
|
||||
highest_queue_len - lowest_queue_len > self._imbalanced_threshold
|
||||
)
|
||||
# End Sphinx tag: __end_load_balance_component__
|
||||
# Start Sphinx tag: __begin_prefix_match_component__
|
||||
if not is_imbalanced:
|
||||
# Convert candidate replica IDs to strings for prefix matching.
|
||||
candidate_replica_ids_strings = [
|
||||
r.replica_id.to_full_id_str() for r in candidate_replicas
|
||||
]
|
||||
(matched_text, matched_tenant_id_strings,) = ray.get(
|
||||
self._tree_actor.prefix_match.remote(
|
||||
input_text, candidate_replica_ids_strings
|
||||
)
|
||||
)
|
||||
match_rate = len(matched_text) / len(input_text)
|
||||
if match_rate < self._match_rate_threshold:
|
||||
smallest_tenants_id_strings = ray.get(
|
||||
self._tree_actor.get_smallest_tenants.remote()
|
||||
)
|
||||
if (
|
||||
smallest_tenants_id_strings is not None
|
||||
and len(smallest_tenants_id_strings) > 0
|
||||
):
|
||||
chosen_replica_id_strings = smallest_tenants_id_strings
|
||||
else:
|
||||
if (
|
||||
matched_tenant_id_strings is not None
|
||||
and len(matched_tenant_id_strings) > 0
|
||||
):
|
||||
chosen_replica_id_strings = matched_tenant_id_strings
|
||||
# End Sphinx tag: __end_prefix_match_component__
|
||||
return [
|
||||
[
|
||||
self._replicas[ReplicaID.from_full_id_str(chosen_id_string)]
|
||||
for chosen_id_string in chosen_replica_id_strings
|
||||
]
|
||||
]
|
||||
|
||||
# Start Sphinx tag: __begin_on_replica_actor_died__
|
||||
def on_replica_actor_died(self, replica_id: ReplicaID):
|
||||
"""Drop replica from replica set so it's not considered for future requests."""
|
||||
super().on_replica_actor_died(replica_id)
|
||||
ray.get(self._tree_actor.remove_tenants.remote([replica_id.to_full_id_str()]))
|
||||
|
||||
# End Sphinx tag: __end_on_replica_actor_died__
|
||||
|
||||
def update_replicas(self, replicas: List[RunningReplica]):
|
||||
"""Update the set of available replicas to be considered for routing.
|
||||
|
||||
When the set of replicas changes, we may spawn additional routing tasks
|
||||
if there are pending requests.
|
||||
"""
|
||||
# 1) Record the old replica IDs
|
||||
old_ids = set(self._replica_id_set)
|
||||
|
||||
# 2) Run the default update_replicas logic
|
||||
super().update_replicas(replicas)
|
||||
|
||||
# 3) Figure out which replicas were added / removed
|
||||
new_ids = set(self._replica_id_set)
|
||||
added = new_ids - old_ids
|
||||
removed = old_ids - new_ids
|
||||
|
||||
# 4) Update the prefix tree with the changes
|
||||
if added:
|
||||
added_strings = [rid.to_full_id_str() for rid in added]
|
||||
ray.get(self._tree_actor.add_tenants.remote(added_strings, time.time()))
|
||||
|
||||
if removed:
|
||||
removed_strings = [rid.to_full_id_str() for rid in removed]
|
||||
ray.get(self._tree_actor.remove_tenants.remote(removed_strings))
|
||||
|
||||
# === Start tasks (if enabled and not already running) ===
|
||||
if self._do_eviction and not self._eviction_loop_running:
|
||||
ray.get(
|
||||
self._tree_actor.start_eviction_loop.remote(
|
||||
self._eviction_threshold_chars,
|
||||
self._eviction_target_chars,
|
||||
self._eviction_interval_secs,
|
||||
)
|
||||
)
|
||||
self._eviction_loop_running = True
|
||||
|
||||
async def choose_replicas(
|
||||
self,
|
||||
candidate_replicas: List[RunningReplica],
|
||||
pending_request: Optional[PendingRequest] = None,
|
||||
) -> List[RunningReplica]:
|
||||
"""One iteration of the power of two choices procedure that chooses
|
||||
(at most) two random available replicas.
|
||||
|
||||
For multiplexing, this will first attempt to choose replicas that have the
|
||||
requested model ID for a configured timeout. If no replicas with the matching
|
||||
model ID are available after that timeout, it will fall back to the regular
|
||||
procedure.
|
||||
"""
|
||||
# Start Sphinx tag: __begin_pow2_router_base__
|
||||
# Get fallback replicas from PowerOfTwoChoicesRequestRouter
|
||||
fallback_replicas = await PowerOfTwoChoicesRequestRouter.choose_replicas(
|
||||
self,
|
||||
candidate_replicas=candidate_replicas,
|
||||
pending_request=pending_request,
|
||||
)
|
||||
if pending_request is None or not fallback_replicas:
|
||||
return fallback_replicas
|
||||
# End Sphinx tag: __end_pow2_router_base__
|
||||
|
||||
if (
|
||||
pending_request is not None
|
||||
and pending_request.metadata.multiplexed_model_id
|
||||
):
|
||||
# Get candidates for multiplexed model ID.
|
||||
candidate_replica_ids = self.apply_multiplex_routing(
|
||||
pending_request=pending_request,
|
||||
)
|
||||
else:
|
||||
# Get candidates for locality preference.
|
||||
candidate_replica_ids = self.apply_locality_routing(
|
||||
pending_request=pending_request,
|
||||
)
|
||||
if not candidate_replica_ids:
|
||||
return fallback_replicas
|
||||
|
||||
# Convert candidate replica IDs to RunningReplica objects.
|
||||
replica_id_to_replica_map = {
|
||||
replica.replica_id: replica for replica in candidate_replicas
|
||||
}
|
||||
candidate_replicas = [
|
||||
replica_id_to_replica_map[candidate_replica_id]
|
||||
for candidate_replica_id in candidate_replica_ids
|
||||
]
|
||||
chosen_replicas = await self._prefix_match_best_replicas(
|
||||
pending_request, candidate_replicas
|
||||
)
|
||||
if chosen_replicas[0]:
|
||||
return chosen_replicas
|
||||
|
||||
return fallback_replicas
|
||||
|
||||
# Start Sphinx tag: __begin_on_request_routed__
|
||||
def on_request_routed(
|
||||
self,
|
||||
pending_request: PendingRequest,
|
||||
replica_id: ReplicaID,
|
||||
result: ReplicaResult,
|
||||
):
|
||||
"""Called when a request is routed to a replica.
|
||||
|
||||
This is used as a callback to update the state of the request router
|
||||
after a response is generated.
|
||||
"""
|
||||
# Right now this only inserts the prompt into the prefix tree, not the response (streaming response makes things complicated)
|
||||
if (
|
||||
pending_request is not None
|
||||
and pending_request.args is not None
|
||||
and len(pending_request.args) > 0
|
||||
):
|
||||
input_text = self._extract_text_from_request(pending_request)
|
||||
if input_text is not None:
|
||||
# Insert into prefix tree
|
||||
ray.get(
|
||||
self._tree_actor.insert.remote(
|
||||
input_text, replica_id.to_full_id_str(), time.time()
|
||||
)
|
||||
)
|
||||
|
||||
# End Sphinx tag: __end_on_request_routed__
|
||||
@@ -0,0 +1,620 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from threading import RLock
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import ray
|
||||
from ray.serve._private.constants import (
|
||||
SERVE_LOGGER_NAME,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
class Node:
|
||||
"""
|
||||
Node in a prefix tree that represents a segment of text and can belong to multiple tenants.
|
||||
Each node also tracks the last access time for each tenant.
|
||||
Simple example of root node connected to two children Nodes:
|
||||
root = Node(text="", parent=None, edge_label_to_child={"f": fooNode, "b": barNode}, tenant_to_last_access_time={"tenant_1": 2})
|
||||
fooNode = Node(text="foo", parent=root, edge_label_to_child={}, tenant_to_last_access_time={"tenant_1": 1})
|
||||
barNode = Node(text="bar", parent=root, edge_label_to_child={}, tenant_to_last_access_time={"tenant_1": 2})
|
||||
|
||||
In the above example, "foo" was inserted at time 1, and "bar" was inserted at time 2.
|
||||
It follows that root was last accessed at time 2.
|
||||
"""
|
||||
|
||||
def __init__(self, text: str = "", parent: Optional[Node] = None) -> None:
|
||||
"""
|
||||
Initialize a node in the prefix tree.
|
||||
|
||||
Args:
|
||||
text: The text segment this node represents
|
||||
parent: The parent node of this node
|
||||
"""
|
||||
self.text: str = text
|
||||
self.parent: Optional[Node] = parent
|
||||
|
||||
# Maps first character to child node
|
||||
self.edge_label_to_child: Dict[str, Node] = {}
|
||||
# For each tenant that has inserted text matching this node, track its last access timestamp (in seconds)
|
||||
self.tenant_to_last_access_time: Dict[str, float] = {}
|
||||
# Doubly linked list pointers for LRU tracking per tenant
|
||||
# Points to the less recently used node (toward tail)
|
||||
self.tenant_to_older_node: Dict[str, Optional[Node]] = {}
|
||||
# Points to the more recently used node (toward head)
|
||||
self.tenant_to_newer_node: Dict[str, Optional[Node]] = {}
|
||||
|
||||
|
||||
class PrefixTree:
|
||||
"""
|
||||
Thread-safe multi-tenant prefix tree (approximate radix tree).
|
||||
|
||||
Features:
|
||||
1. Stores data for multiple tenants in the same tree structure
|
||||
2. Thread-safe with node-level locking for concurrent access
|
||||
3. LRU eviction based on tenant access time
|
||||
4. Efficient prefix matching across multiple tenants
|
||||
|
||||
|
||||
Example tree structure:
|
||||
Representing the strings inserted in order:
|
||||
- "helloworld" at time 1 by tenant_1
|
||||
- "hellothere" at time 2 by tenant_2
|
||||
- "hellothomas" at time 3 by tenant_2
|
||||
|
||||
root: [] {tenant_1: 1, tenant_2: 3}
|
||||
(h) → [hello] {tenant_1: 1, tenant_2: 3}
|
||||
(w) → [world] {tenant_1: 1}
|
||||
(t) → [th] {tenant_2: 3}
|
||||
(e) → [ere] {tenant_2: 2}
|
||||
(o) → [omas] {tenant_2: 3}
|
||||
|
||||
Legend for each node:
|
||||
- [text] = Node.text
|
||||
- {tenant, timestamp} = Node.tenant_to_last_access_time
|
||||
- (x) = edge label (first character used as key for parent's children)
|
||||
|
||||
PrefixTree instance variables:
|
||||
self.tenant_to_char_count = {"tenant_1": 10, "tenant_2": 14}
|
||||
self.tenant_to_lru_tail = {"tenant_1": Node("world"), "tenant_2": Node("ere")}
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an empty prefix tree."""
|
||||
self.lock: RLock = RLock()
|
||||
|
||||
# Root is always the head of the LRU list for each tenant.
|
||||
self.root: Node = Node()
|
||||
|
||||
# Tracks total character count per tenant. Can be used by the replica request router to determine which tenant to evict, and by how much.
|
||||
# Also uses the keys to track the active tenants in the tree.
|
||||
self.tenant_to_char_count: Dict[str, int] = {}
|
||||
|
||||
# LRU tracking - root is always the head, tail is the least recently used.
|
||||
self.tenant_to_lru_tail: Dict[str, Optional[Node]] = {}
|
||||
self._eviction_thread: Optional[threading.Thread] = None
|
||||
self._eviction_stop_event: threading.Event = threading.Event()
|
||||
|
||||
@staticmethod
|
||||
def _shared_prefix_count(a: str, b: str) -> int:
|
||||
"""
|
||||
Count the number of shared characters at the beginning of two strings.
|
||||
|
||||
Args:
|
||||
a: First string
|
||||
b: Second string
|
||||
|
||||
Returns:
|
||||
Number of matching characters at the beginning
|
||||
"""
|
||||
return len(os.path.commonprefix([a, b]))
|
||||
|
||||
def _get_lru_chain(self, tenant: str) -> List[Node]:
|
||||
"""
|
||||
Get the LRU chain for a given tenant by traversing from the root to the oldest node.
|
||||
Note: This method is intended to be used only in tests.
|
||||
"""
|
||||
with self.lock:
|
||||
if tenant not in self.tenant_to_char_count:
|
||||
return []
|
||||
nodes = []
|
||||
current_node = self.root
|
||||
while current_node:
|
||||
nodes.append(current_node)
|
||||
current_node = current_node.tenant_to_older_node.get(tenant)
|
||||
return nodes
|
||||
|
||||
def _insert_node_into_linked_list(
|
||||
self,
|
||||
node: Node,
|
||||
newer_neighbor: Optional[Node],
|
||||
older_neighbor: Optional[Node],
|
||||
tenant: str,
|
||||
) -> None:
|
||||
"""
|
||||
Insert a node into the LRU list between two neighbors. Updates the neighbors' pointers and the tail pointer, if that changes.
|
||||
"""
|
||||
with self.lock:
|
||||
if tenant not in self.tenant_to_char_count:
|
||||
logger.debug(f"Tenant '{tenant}' does not exist. No action taken.")
|
||||
return
|
||||
|
||||
# Skip if node is the root
|
||||
if node == self.root:
|
||||
return
|
||||
|
||||
node.tenant_to_newer_node[tenant] = newer_neighbor
|
||||
node.tenant_to_older_node[tenant] = older_neighbor
|
||||
|
||||
if newer_neighbor:
|
||||
newer_neighbor.tenant_to_older_node[tenant] = node
|
||||
|
||||
if older_neighbor:
|
||||
older_neighbor.tenant_to_newer_node[tenant] = node
|
||||
|
||||
if self.tenant_to_lru_tail[tenant] == newer_neighbor:
|
||||
self.tenant_to_lru_tail[tenant] = node
|
||||
|
||||
def _remove_node_from_linked_list(self, node: Node, tenant: str) -> None:
|
||||
"""
|
||||
Remove a node from the LRU list. Updates the neighbors' pointers and the tail pointer, if that changes.
|
||||
"""
|
||||
with self.lock:
|
||||
if tenant not in self.tenant_to_char_count:
|
||||
logger.debug(f"Tenant '{tenant}' does not exist. No action taken.")
|
||||
return
|
||||
|
||||
# Skip if node is the root
|
||||
if node == self.root:
|
||||
return
|
||||
|
||||
# Connect older and newer neighbors
|
||||
older_neighbor = node.tenant_to_older_node.get(tenant)
|
||||
newer_neighbor = node.tenant_to_newer_node.get(tenant)
|
||||
|
||||
if older_neighbor:
|
||||
older_neighbor.tenant_to_newer_node[tenant] = newer_neighbor
|
||||
|
||||
if newer_neighbor:
|
||||
newer_neighbor.tenant_to_older_node[tenant] = older_neighbor
|
||||
|
||||
# Update tail pointer if necessary
|
||||
if self.tenant_to_lru_tail[tenant] == node:
|
||||
self.tenant_to_lru_tail[tenant] = newer_neighbor
|
||||
|
||||
# Remove node from list
|
||||
node.tenant_to_newer_node.pop(tenant, None)
|
||||
node.tenant_to_older_node.pop(tenant, None)
|
||||
|
||||
def _remove_tenant_single_node(self, tenant: str, node: Node) -> int:
|
||||
"""
|
||||
Remove a tenant from a single node.
|
||||
|
||||
Args:
|
||||
tenant: Tenant to remove
|
||||
node: Node to remove tenant from
|
||||
|
||||
Returns:
|
||||
Number of characters removed (0 if preconditions not met)
|
||||
"""
|
||||
with self.lock:
|
||||
if tenant not in self.tenant_to_char_count:
|
||||
logger.debug(f"Tenant '{tenant}' does not exist. No action taken.")
|
||||
return 0
|
||||
if tenant not in node.tenant_to_last_access_time:
|
||||
logger.debug(
|
||||
f"Tenant '{tenant}' does not have node '{node.text}'. No action taken."
|
||||
)
|
||||
return 0
|
||||
|
||||
removed_chars_len: int = len(node.text)
|
||||
self.tenant_to_char_count[tenant] -= removed_chars_len
|
||||
node.tenant_to_last_access_time.pop(tenant, None)
|
||||
|
||||
self._remove_node_from_linked_list(node, tenant)
|
||||
|
||||
# Clean up empty nodes
|
||||
if not node.tenant_to_last_access_time and node.parent:
|
||||
if (
|
||||
node.text and node.text[0] in node.parent.edge_label_to_child
|
||||
): # Defensive check
|
||||
node.parent.edge_label_to_child.pop(node.text[0], None)
|
||||
|
||||
return removed_chars_len
|
||||
|
||||
def add_tenants(self, tenants: List[str], time_s: float) -> None:
|
||||
"""
|
||||
Add multiple new tenants to the tree. Also inserts an empty string for each tenant into the tree.
|
||||
|
||||
For each tenant that already exists, a warning is logged and that tenant is skipped.
|
||||
|
||||
Args:
|
||||
tenants: List of tenants to add
|
||||
time_s: Current timestamp in seconds
|
||||
"""
|
||||
with self.lock:
|
||||
for tenant in tenants:
|
||||
if tenant in self.tenant_to_char_count:
|
||||
logger.debug(f"Tenant '{tenant}' already exists. Skipping.")
|
||||
continue
|
||||
|
||||
self.tenant_to_char_count[tenant] = 0
|
||||
self.tenant_to_lru_tail[tenant] = self.root
|
||||
|
||||
# Initialize the root node as the head of the LRU list for this tenant
|
||||
self.root.tenant_to_newer_node[tenant] = None
|
||||
self.root.tenant_to_older_node[tenant] = None
|
||||
self.insert("", tenant, time_s)
|
||||
|
||||
def insert(self, text: str, tenant: str, time_s: float) -> None:
|
||||
"""
|
||||
Insert text into tree for a specific tenant, but only if the tenant already exists.
|
||||
|
||||
If the tenant doesn't exist in the tree, this will log a warning and return without
|
||||
inserting anything. Use add_tenants() first to add a new tenant.
|
||||
|
||||
Args:
|
||||
text: Text to insert
|
||||
tenant: Tenant
|
||||
time_s: Current timestamp in seconds
|
||||
|
||||
Loop structure:
|
||||
1. We update the current node at the start of each iteration of the while loop.
|
||||
This includes updating tenant_to_char_count and tenant_to_last_access_time, and moving the node to the front of the LRU list.
|
||||
2. Each iteration then either:
|
||||
a. Breaks (if we've processed the entire string).
|
||||
b. Processes the next segment of text by:
|
||||
1. If no child exists for the first character, create a new leaf node that matches the current text.
|
||||
2. Then, match the current text with the child's text:
|
||||
a. If they share a prefix (partial match), split the node and traverse into the new parent.
|
||||
b. If they fully match, traverse into the child node.
|
||||
"""
|
||||
with self.lock:
|
||||
if tenant not in self.tenant_to_char_count:
|
||||
logger.debug(
|
||||
f"Tenant '{tenant}' does not exist. Use add_tenants() first."
|
||||
)
|
||||
return
|
||||
|
||||
curr_node: Node = self.root
|
||||
i: int = 0
|
||||
while i <= len(text):
|
||||
# Invariant at beginning of each iteration: assume curr_node has not been visited by tenant yet.
|
||||
# Update tenant info for current node.
|
||||
if tenant not in curr_node.tenant_to_last_access_time:
|
||||
self.tenant_to_char_count[tenant] += len(curr_node.text)
|
||||
|
||||
curr_node.tenant_to_last_access_time[tenant] = time_s
|
||||
if curr_node != self.root:
|
||||
self._remove_node_from_linked_list(curr_node, tenant)
|
||||
self._insert_node_into_linked_list(
|
||||
curr_node,
|
||||
self.root,
|
||||
self.root.tenant_to_older_node.get(tenant),
|
||||
tenant,
|
||||
)
|
||||
if i == len(text):
|
||||
break
|
||||
|
||||
first_char: str = text[i]
|
||||
curr_text: str = text[i:]
|
||||
|
||||
if first_char not in curr_node.edge_label_to_child:
|
||||
# No match, create new node. Don't update new node as "visited" by tenant yet; it will be done at the beginning of the next iteration.
|
||||
# e.g. curr_node.edge_label_to_child = {}, curr_text = "hello" -> curr_node.edge_label_to_child = {"h": Node("hello")}
|
||||
new_node: Node = Node(text=curr_text, parent=curr_node)
|
||||
curr_node.edge_label_to_child[first_char] = new_node
|
||||
# Add the node to the back of the LRU list; it will be moved to the front in the next iteration.
|
||||
self._insert_node_into_linked_list(
|
||||
new_node, self.tenant_to_lru_tail[tenant], None, tenant
|
||||
)
|
||||
|
||||
# Match found, check if we need to split
|
||||
matched_node: Node = curr_node.edge_label_to_child[first_char]
|
||||
shared_count: int = self._shared_prefix_count(
|
||||
matched_node.text, curr_text
|
||||
)
|
||||
if shared_count < len(matched_node.text):
|
||||
# Partial match, split node at matched point
|
||||
# Example:
|
||||
## Before update:
|
||||
### curr_node.edge_label_to_child = {"h": Node("helloworld")}, curr_text = "hellothere" -> shared_count = 5
|
||||
### matched_node = Node("helloworld")
|
||||
|
||||
## After update:
|
||||
### curr_node.edge_label_to_child = {"h": Node("hello", edge_label_to_child = {"w": Node("world")})}
|
||||
### parent_node = Node("hello"), matched_node = Node("world")
|
||||
### Copy matched_node.tenant_to_last_access_time to parent_node.tenant_to_last_access_time
|
||||
### Insert parent_node into the back of the LRU list; it will be moved to the front in the next iteration. (for the current tenant)
|
||||
### Insert parent_node between matched_node and matched_node's newer neighbor (for all other tenants)
|
||||
### (new) curr_text = "there", (new) curr_node = parent_node
|
||||
### Continue adding "there" to tree in next iteration
|
||||
|
||||
matched_text: str = matched_node.text[:shared_count]
|
||||
remaining_text: str = matched_node.text[shared_count:]
|
||||
|
||||
# Create new intermediate node
|
||||
# Note that we don't update new_parent.tenant_to_last_access_time yet; it will be done at the beginning of the next iteration.
|
||||
new_parent: Node = Node(text=matched_text, parent=curr_node)
|
||||
new_parent.tenant_to_last_access_time = (
|
||||
matched_node.tenant_to_last_access_time.copy()
|
||||
)
|
||||
# Insert new_parent into the back of the LRU list; it will be moved to the front in the next iteration. (for the current tenant)
|
||||
self._insert_node_into_linked_list(
|
||||
new_parent, self.tenant_to_lru_tail[tenant], None, tenant
|
||||
)
|
||||
# Insert new_parent between matched_node and matched_node's newer neighbor (for all other tenants)
|
||||
for existing_tenant in new_parent.tenant_to_last_access_time:
|
||||
if existing_tenant != tenant:
|
||||
self._insert_node_into_linked_list(
|
||||
new_parent,
|
||||
matched_node.tenant_to_newer_node.get(existing_tenant),
|
||||
matched_node,
|
||||
existing_tenant,
|
||||
)
|
||||
|
||||
# Update existing matched node
|
||||
matched_node.text = remaining_text
|
||||
matched_node.parent = new_parent
|
||||
|
||||
# Connect nodes
|
||||
new_parent.edge_label_to_child[remaining_text[0]] = matched_node
|
||||
curr_node.edge_label_to_child[first_char] = new_parent
|
||||
|
||||
# Continue traversal
|
||||
curr_node = new_parent
|
||||
i += shared_count
|
||||
else:
|
||||
# Full match, continue down the tree
|
||||
curr_node = matched_node
|
||||
i += shared_count
|
||||
|
||||
def prefix_match(
|
||||
self, text: str, available_tenants: Optional[List[str]] = None
|
||||
) -> Tuple[str, Optional[List[str]]]:
|
||||
"""
|
||||
Match text against tree and return matched text and matching tenants.
|
||||
|
||||
Args:
|
||||
text: Text to match
|
||||
available_tenants: List of tenants to match against (or None for all)
|
||||
|
||||
Returns:
|
||||
Tuple of (matched_text, matched_tenants):
|
||||
If the list of available tenants doesn't match any tenants in the tree: returns ("", None)
|
||||
When no prefix match is found (does not traverse further than the root node): returns ("", list of available tenants)
|
||||
When a prefix match is found: returns (matched_prefix, list of tenants that own the matched node)
|
||||
"""
|
||||
with self.lock:
|
||||
if available_tenants:
|
||||
# Filter available_tenants to only include those in the tree
|
||||
available_tenants = [
|
||||
tenant
|
||||
for tenant in available_tenants
|
||||
if tenant in self.tenant_to_char_count
|
||||
]
|
||||
if not available_tenants:
|
||||
return "", None
|
||||
else:
|
||||
available_tenants = list(self.tenant_to_char_count.keys())
|
||||
|
||||
curr_node: Node = self.root
|
||||
i: int = 0
|
||||
text_len: int = len(text)
|
||||
|
||||
while i < text_len:
|
||||
first_char: str = text[i]
|
||||
curr_text: str = text[i:]
|
||||
|
||||
if first_char in curr_node.edge_label_to_child:
|
||||
matched_node: Node = curr_node.edge_label_to_child[first_char]
|
||||
|
||||
# Check if any available tenants match this node
|
||||
if not any(
|
||||
tenant in matched_node.tenant_to_last_access_time
|
||||
for tenant in available_tenants
|
||||
):
|
||||
break
|
||||
|
||||
shared_count: int = self._shared_prefix_count(
|
||||
matched_node.text, curr_text
|
||||
)
|
||||
i += shared_count
|
||||
curr_node = matched_node
|
||||
|
||||
if shared_count < len(matched_node.text):
|
||||
# Partial match, stop here
|
||||
break
|
||||
else:
|
||||
# No match found, stop here
|
||||
break
|
||||
|
||||
# Find tenants in current node that match available tenants
|
||||
matched_tenants = [
|
||||
tenant
|
||||
for tenant in available_tenants
|
||||
if tenant in curr_node.tenant_to_last_access_time
|
||||
] or None
|
||||
|
||||
matched_text: str = text[:i]
|
||||
|
||||
return matched_text, matched_tenants
|
||||
|
||||
def remove_tenants(self, tenants: List[str]) -> Dict[str, int]:
|
||||
"""
|
||||
Remove multiple tenants and all their nodes from the tree.
|
||||
Time complexity: O(n) where n is the total number of nodes owned by all tenants.
|
||||
|
||||
Args:
|
||||
tenants: List of tenants to remove
|
||||
|
||||
Returns:
|
||||
Dictionary mapping each tenant to the number of characters removed
|
||||
(0 if tenant doesn't exist)
|
||||
"""
|
||||
chars_removed: Dict[str, int] = {}
|
||||
|
||||
with self.lock:
|
||||
for tenant in tenants:
|
||||
if tenant not in self.tenant_to_char_count:
|
||||
logger.debug(f"Tenant '{tenant}' does not exist. Skipping.")
|
||||
chars_removed[tenant] = 0
|
||||
continue
|
||||
|
||||
tenant_chars_removed: int = 0
|
||||
|
||||
# Start from the tail and remove all nodes
|
||||
current_tail = self.tenant_to_lru_tail.get(tenant)
|
||||
while current_tail:
|
||||
newer_neighbor = current_tail.tenant_to_newer_node.get(tenant)
|
||||
tenant_chars_removed += self._remove_tenant_single_node(
|
||||
tenant, current_tail
|
||||
)
|
||||
current_tail = newer_neighbor
|
||||
|
||||
# Clean up tenant references
|
||||
self.tenant_to_char_count.pop(tenant, None)
|
||||
self.tenant_to_lru_tail.pop(tenant, None)
|
||||
|
||||
chars_removed[tenant] = tenant_chars_removed
|
||||
|
||||
return chars_removed
|
||||
|
||||
def evict_tenant_by_lru(self, tenant: str, min_remove_size: int) -> int:
|
||||
"""
|
||||
Evict least recently used nodes for a tenant until minimum size is freed.
|
||||
Time complexity: O(m) where m is the number of nodes removed.
|
||||
|
||||
Args:
|
||||
tenant: The tenant to evict nodes from
|
||||
min_remove_size: Minimum number of characters to remove
|
||||
|
||||
Returns:
|
||||
Actual number of characters removed (0 if tenant doesn't exist)
|
||||
|
||||
Note:
|
||||
- All nodes with the same oldest access time are removed together to maintain tree integrity, even if only removing a subset of them satisfies the min_remove_size.
|
||||
- For more predictable eviction, use unique timestamps for each insertion.
|
||||
- The root node is never evicted as it serves as the permanent head of the LRU list.
|
||||
"""
|
||||
with self.lock:
|
||||
if tenant not in self.tenant_to_char_count:
|
||||
logger.debug(
|
||||
f"Cannot evict tenant '{tenant}': tenant does not exist. No action taken."
|
||||
)
|
||||
return 0
|
||||
|
||||
if self.tenant_to_char_count[tenant] < min_remove_size:
|
||||
logger.debug(
|
||||
f"Cannot evict {min_remove_size} characters from tenant '{tenant}', which has only "
|
||||
f"{self.tenant_to_char_count[tenant]} characters. Will remove all available characters."
|
||||
)
|
||||
min_remove_size = self.tenant_to_char_count[tenant]
|
||||
|
||||
total_chars_removed: int = 0
|
||||
|
||||
# Start removing from the tail (least recently used)
|
||||
current_tail = self.tenant_to_lru_tail.get(tenant)
|
||||
|
||||
# Continue until we've freed enough space or run out of nodes
|
||||
while total_chars_removed < min_remove_size and current_tail:
|
||||
# Stop if we've reached the root - the root is never evicted
|
||||
if current_tail == self.root:
|
||||
break
|
||||
|
||||
# Get the current timestamp to remove all nodes with this timestamp
|
||||
current_timestamp = current_tail.tenant_to_last_access_time[tenant]
|
||||
|
||||
# Collect all nodes with the same timestamp (guaranteed to be contiguous in our LRU list)
|
||||
while (
|
||||
current_tail != self.root # Never include the root
|
||||
and current_tail.tenant_to_last_access_time[tenant]
|
||||
== current_timestamp
|
||||
):
|
||||
newer_neighbor = current_tail.tenant_to_newer_node.get(tenant)
|
||||
total_chars_removed += self._remove_tenant_single_node(
|
||||
tenant, current_tail
|
||||
)
|
||||
current_tail = newer_neighbor
|
||||
|
||||
return total_chars_removed
|
||||
|
||||
def get_smallest_tenants(self) -> Optional[List[str]]:
|
||||
"""
|
||||
Get the tenants with the smallest total character count.
|
||||
|
||||
Returns:
|
||||
Tenants with smallest character count, or None if no tenants
|
||||
"""
|
||||
with self.lock:
|
||||
if not self.tenant_to_char_count:
|
||||
return None
|
||||
|
||||
min_count = min(self.tenant_to_char_count.values())
|
||||
return [
|
||||
tenant
|
||||
for tenant, count in self.tenant_to_char_count.items()
|
||||
if count == min_count
|
||||
]
|
||||
|
||||
def start_eviction_loop(
|
||||
self, eviction_threshold: int, eviction_target: int, interval_secs: float
|
||||
) -> bool:
|
||||
"""Start a single eviction loop within the actor itself.
|
||||
|
||||
Args:
|
||||
eviction_threshold: Minimum number of characters a tenant must have to be evicted
|
||||
eviction_target: The maximum number of characters a tenant should have after eviction
|
||||
interval_secs: Number of seconds between eviction checks
|
||||
|
||||
Returns:
|
||||
True if the loop was started, False if it was already running
|
||||
"""
|
||||
self._eviction_stop_event.clear()
|
||||
with self.lock:
|
||||
if self._eviction_thread is None:
|
||||
self._eviction_thread = threading.Thread(
|
||||
target=self._run_eviction_loop,
|
||||
args=(eviction_threshold, eviction_target, interval_secs),
|
||||
daemon=True,
|
||||
)
|
||||
self._eviction_thread.start()
|
||||
return True
|
||||
else:
|
||||
logger.debug("Eviction loop already running")
|
||||
return False
|
||||
|
||||
def _run_eviction_loop(self, eviction_threshold, eviction_target, interval_secs):
|
||||
while not self._eviction_stop_event.is_set():
|
||||
if self._eviction_stop_event.wait(interval_secs):
|
||||
# Stop event was set, exit loop asap
|
||||
break
|
||||
|
||||
with self.lock:
|
||||
for tenant, char_count in self.tenant_to_char_count.items():
|
||||
if char_count > eviction_threshold:
|
||||
excess = char_count - eviction_target
|
||||
self.evict_tenant_by_lru(tenant, excess)
|
||||
|
||||
def stop_eviction_loop(self):
|
||||
self._eviction_stop_event.set()
|
||||
if self._eviction_thread:
|
||||
self._eviction_thread.join()
|
||||
self._eviction_thread = None
|
||||
|
||||
|
||||
@ray.remote
|
||||
class PrefixTreeActor(PrefixTree):
|
||||
def getattr(self, attribute: str) -> Any:
|
||||
"""
|
||||
Get an attribute of the PrefixTree.
|
||||
Note: This method is intended to be used only in tests.
|
||||
"""
|
||||
return getattr(self, attribute)
|
||||
|
||||
def setattr(self, attribute: str, value: Any) -> None:
|
||||
setattr(self, attribute, value)
|
||||
Reference in New Issue
Block a user