Files
lmcache--lmcache/lmcache/v1/multiprocess/affinity_pool.py
T
2026-07-13 12:24:33 +08:00

155 lines
5.2 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
Thread pool with affinity routing.
Tasks submitted with the same ``affinity_key`` always execute on the same
worker thread. Within each worker, tasks execute sequentially in FIFO order.
This is used for GPU-bound request handlers (STORE / RETRIEVE) so that all
operations for a given vLLM instance land on one thread, eliminating the need
for per-instance locks on the shared temporary GPU buffer.
"""
# Standard
from concurrent.futures import Future
import queue
import threading
# First Party
from lmcache.logging import init_logger
logger = init_logger(__name__)
# Sentinel object to signal worker shutdown
_SHUTDOWN = object()
class AffinityThreadPool:
"""Thread pool that routes tasks to workers by affinity key.
Not thread-safe: ``submit()`` must be called from a single thread (the
``MessageQueueServer`` main loop).
Args:
max_workers: Number of worker threads.
thread_name_prefix: Prefix for worker thread names.
"""
def __init__(
self,
max_workers: int,
thread_name_prefix: str = "affinity",
) -> None:
self._num_workers = max_workers
self._queues: list[queue.Queue] = [queue.Queue() for _ in range(max_workers)]
self._threads: list[threading.Thread] = []
# Maps an affinity_key -> the worker slot (thread index) bound to it.
self._key_to_slot: dict[int, int] = {}
self._next_slot = 0
self._overflow_warned = False
for i in range(max_workers):
t = threading.Thread(
target=self._worker,
args=(self._queues[i],),
daemon=True,
name=f"{thread_name_prefix}-{i}",
)
t.start()
self._threads.append(t)
logger.info(
"Created AffinityThreadPool '%s' with %d worker slots: up to %d "
"distinct affinity keys each bind to their own thread before slots "
"are shared. Compare this against the number of clients expected to "
"connect to confirm routing.",
thread_name_prefix,
max_workers,
max_workers,
)
# ------------------------------------------------------------------
# Worker loop
# ------------------------------------------------------------------
@staticmethod
def _worker(q: queue.Queue) -> None:
while True:
item = q.get()
if item is _SHUTDOWN:
break
future, fn, args, kwargs = item
if future.set_running_or_notify_cancel():
try:
result = fn(*args, **kwargs)
future.set_result(result)
except BaseException as exc:
future.set_exception(exc)
# ------------------------------------------------------------------
# Routing
# ------------------------------------------------------------------
def _slot_for_key(self, affinity_key: int) -> int:
"""Return the worker slot bound to ``affinity_key``, assigning on first use.
Returns:
The worker slot (an index in ``[0, _num_workers)``) for the key.
"""
slot = self._key_to_slot.get(affinity_key)
if slot is not None:
return slot
# First time we see this key -- bind it to the next free slot.
slot = self._next_slot % self._num_workers
is_overflow = self._next_slot >= self._num_workers
self._key_to_slot[affinity_key] = slot
self._next_slot += 1
logger.info(
"AffinityThreadPool: affinity_key=%d assigned to worker "
"slot %d of %d (thread %s); %d distinct key(s) now bound",
affinity_key,
slot,
self._num_workers,
self._threads[slot].name,
len(self._key_to_slot),
)
if is_overflow and not self._overflow_warned:
self._overflow_warned = True
logger.warning(
"AffinityThreadPool: affinity_key=%d wrapped onto worker slot "
"%d (only %d workers), so it shares a thread with an earlier "
"key and their tasks are serialized. Increase the worker count "
"to give each client its own thread.",
affinity_key,
slot,
self._num_workers,
)
return slot
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def submit(self, fn, *args, affinity_key: int = 0, **kwargs) -> Future:
"""Submit *fn* for execution on the worker bound to *affinity_key*.
Returns a :class:`concurrent.futures.Future`.
"""
future: Future = Future()
slot = self._slot_for_key(affinity_key)
self._queues[slot].put((future, fn, args, kwargs))
return future
def shutdown(self, wait: bool = True) -> None:
"""Shut down the pool.
Sends a shutdown sentinel to every worker. If *wait* is true, blocks
until all workers have exited.
"""
for q in self._queues:
q.put(_SHUTDOWN)
if wait:
for t in self._threads:
t.join()