59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
480 lines
16 KiB
Python
Executable File
480 lines
16 KiB
Python
Executable File
# SPDX-License-Identifier: MIT AND Apache-2.0
|
||
# SPDX-FileCopyrightText: Copyright (c) 2026 LightSeek Foundation
|
||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||
#
|
||
# Copyright (c) 2026 LightSeek Foundation
|
||
#
|
||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||
# of this software and associated documentation files (the "Software"), to deal
|
||
# in the Software without restriction, including without limitation the rights
|
||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||
# copies of the Software, and to permit persons to whom the Software is
|
||
# furnished to do so, subject to the following conditions:
|
||
#
|
||
# The above copyright notice and this permission notice shall be included in
|
||
# all copies or substantial portions of the Software.
|
||
#
|
||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||
# SOFTWARE.
|
||
|
||
"""KV cache event definitions and publisher implementations."""
|
||
|
||
import atexit
|
||
import logging
|
||
import queue
|
||
import threading
|
||
import time
|
||
from abc import ABC, abstractmethod
|
||
from collections import deque
|
||
from collections.abc import Callable, Iterable
|
||
from itertools import count
|
||
from queue import Queue
|
||
from typing import Any
|
||
|
||
import msgspec
|
||
import zmq
|
||
from pydantic import BaseModel
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class EventBatch(
|
||
msgspec.Struct,
|
||
array_like=True, # type: ignore[call-arg]
|
||
omit_defaults=True, # type: ignore[call-arg]
|
||
gc=False, # type: ignore[call-arg]
|
||
):
|
||
ts: float
|
||
events: list[Any]
|
||
attn_dp_rank: int | None = None
|
||
|
||
|
||
class KVCacheEvent(
|
||
msgspec.Struct,
|
||
array_like=True, # type: ignore[call-arg]
|
||
omit_defaults=True, # type: ignore[call-arg]
|
||
gc=False, # type: ignore[call-arg]
|
||
tag=True,
|
||
):
|
||
"""Base class for all KV cache-related events."""
|
||
|
||
|
||
class BlockStored(KVCacheEvent):
|
||
block_hashes: list[int]
|
||
parent_block_hash: int | None
|
||
token_ids: list[int]
|
||
block_size: int
|
||
|
||
|
||
class BlockRemoved(KVCacheEvent):
|
||
block_hashes: list[int]
|
||
|
||
|
||
class AllBlocksCleared(KVCacheEvent):
|
||
pass
|
||
|
||
|
||
class KVEventBatch(EventBatch):
|
||
events: list[BlockStored | BlockRemoved | AllBlocksCleared]
|
||
|
||
|
||
def scheduler_kv_event_to_wire_event(
|
||
event: Any,
|
||
) -> BlockStored | BlockRemoved:
|
||
"""Translate a scheduler-native KV event into the ZMQ wire struct."""
|
||
kind = event.kind
|
||
if kind == "BlockStored":
|
||
return BlockStored(
|
||
block_hashes=[int(block_hash) for block_hash in event.block_hashes],
|
||
parent_block_hash=(
|
||
None
|
||
if event.parent_block_hash is None
|
||
else int(event.parent_block_hash)
|
||
),
|
||
token_ids=[int(token_id) for token_id in event.token_ids],
|
||
block_size=int(event.block_size),
|
||
)
|
||
if kind == "BlockRemoved":
|
||
return BlockRemoved(
|
||
block_hashes=[int(block_hash) for block_hash in event.block_hashes]
|
||
)
|
||
raise TypeError(f"Unsupported scheduler KV event kind: {kind}")
|
||
|
||
|
||
def scheduler_kv_events_to_wire_events(
|
||
events: Iterable[Any],
|
||
) -> list[BlockStored | BlockRemoved]:
|
||
return [scheduler_kv_event_to_wire_event(event) for event in events]
|
||
|
||
|
||
def drain_scheduler_kv_events(scheduler: Any, enabled: bool) -> list[Any]:
|
||
if not enabled:
|
||
return []
|
||
|
||
drain_kv_events = getattr(scheduler, "drain_kv_events", None)
|
||
if drain_kv_events is None:
|
||
raise RuntimeError(
|
||
"KV cache events require a tokenspeed_scheduler extension with "
|
||
"Scheduler.drain_kv_events() support."
|
||
)
|
||
|
||
return list(drain_kv_events())
|
||
|
||
|
||
class EventPublisher(ABC):
|
||
"""Lightweight publisher for ``EventBatch`` batches with DP attention.
|
||
|
||
In DP attention, each rank has its own scheduler and KV cache instance in
|
||
order to avoid duplicate events and ensure proper event attribution. In
|
||
this implementation:
|
||
|
||
- Each DP rank has its own ``EventPublisher``.
|
||
- Publishers annotate events with the DP rank.
|
||
- Consumers can distinguish events from different DP ranks.
|
||
"""
|
||
|
||
def __init__(self, attn_dp_rank: int = 0):
|
||
self._attn_dp_rank = attn_dp_rank
|
||
|
||
@abstractmethod
|
||
def publish(self, events: EventBatch) -> None:
|
||
"""Emit events in order.
|
||
|
||
Implementations should guarantee at-least-once delivery and
|
||
monotonic ordering (e.g., via sequence numbers).
|
||
"""
|
||
|
||
@abstractmethod
|
||
def shutdown(self) -> None:
|
||
"""Shutdown the publisher."""
|
||
|
||
|
||
class NullEventPublisher(EventPublisher):
|
||
"""No-op implementation (default when disabled)."""
|
||
|
||
def publish(self, events: EventBatch) -> None:
|
||
return
|
||
|
||
def shutdown(self) -> None:
|
||
return
|
||
|
||
|
||
class ZmqEventPublisher(EventPublisher):
|
||
"""Reliable PUB/ROUTER publisher with an in-memory replay buffer.
|
||
|
||
Spawns a separate thread to handle publishing from a queue.
|
||
|
||
Parameters
|
||
----------
|
||
endpoint:
|
||
PUB address. Use ``tcp://*:5557`` to bind or ``tcp://host:5557`` to
|
||
connect.
|
||
replay_endpoint:
|
||
Optional ROUTER address for replay requests. When given, subscribers can
|
||
request missed batches by sending the starting sequence number as an
|
||
8-byte big-endian integer.
|
||
buffer_steps:
|
||
Number of past batches to keep for replay.
|
||
hwm:
|
||
ZeroMQ high-water-mark for PUB socket.
|
||
max_queue_size:
|
||
Maximum number of events to buffer in memory.
|
||
topic:
|
||
Topic to publish events to.
|
||
"""
|
||
|
||
SHUTDOWN_TIMEOUT: float = 1.0
|
||
END_SEQ = (-1).to_bytes(8, "big", signed=True)
|
||
|
||
def __init__(
|
||
self,
|
||
attn_dp_rank: int,
|
||
endpoint: str = "tcp://*:5557",
|
||
replay_endpoint: str | None = None,
|
||
buffer_steps: int = 10_000,
|
||
hwm: int = 100_000,
|
||
max_queue_size: int = 100_000,
|
||
topic: str = "",
|
||
) -> None:
|
||
# Storage
|
||
super().__init__(attn_dp_rank)
|
||
self._event_queue = Queue[EventBatch | None](maxsize=max_queue_size)
|
||
self._buffer = deque[tuple[int, bytes]](maxlen=buffer_steps)
|
||
|
||
# ZMQ sockets
|
||
self._ctx = zmq.Context.instance()
|
||
self._pub: zmq.Socket | None = None
|
||
self._replay: zmq.Socket | None = None
|
||
self._dp_rank = attn_dp_rank
|
||
self._endpoint = self.offset_endpoint_port(endpoint, self._dp_rank)
|
||
self._replay_endpoint = self.offset_endpoint_port(
|
||
replay_endpoint, self._dp_rank
|
||
)
|
||
self._hwm = hwm
|
||
self._socket_setup()
|
||
|
||
# Payload
|
||
self._seq_gen = count()
|
||
self._topic_bytes = topic.encode("utf-8")
|
||
|
||
# Thread
|
||
self._running = True
|
||
logger.info("Starting ZMQ publisher thread")
|
||
|
||
self._thread = threading.Thread(
|
||
target=self._publisher_thread, daemon=True, name="zmq-publisher"
|
||
)
|
||
self._thread.start()
|
||
|
||
atexit.register(self.shutdown)
|
||
|
||
def publish(self, events: EventBatch) -> None:
|
||
if not self._running:
|
||
raise RuntimeError("Publisher is closed")
|
||
if events.attn_dp_rank is None:
|
||
events.attn_dp_rank = self._dp_rank
|
||
self._event_queue.put(events)
|
||
|
||
def shutdown(self) -> None:
|
||
"""Stop the publisher thread and clean up resources."""
|
||
self._running = False
|
||
self._event_queue.put_nowait(None)
|
||
|
||
start = time.time()
|
||
pending_items = True
|
||
while pending_items and (time.time() - start < self.SHUTDOWN_TIMEOUT):
|
||
pending_items = not self._event_queue.empty()
|
||
if pending_items:
|
||
time.sleep(0.1)
|
||
|
||
if pending_items:
|
||
logger.warning(
|
||
"Warning: Queue still has %s items after %s seconds timeout",
|
||
self._event_queue.qsize(),
|
||
self.SHUTDOWN_TIMEOUT,
|
||
)
|
||
|
||
if self._thread.is_alive():
|
||
self._thread.join(timeout=self.SHUTDOWN_TIMEOUT)
|
||
|
||
# Clean up ZMQ resources
|
||
try:
|
||
if self._pub is not None:
|
||
self._pub.close(linger=0)
|
||
if self._replay is not None:
|
||
self._replay.close(linger=0)
|
||
finally:
|
||
pass # Do not terminate context; other sockets may use it
|
||
|
||
def _socket_setup(self) -> None:
|
||
"""Initialize sockets.
|
||
|
||
https://pyzmq.readthedocs.io/en/v19.0.0/morethanbindings.html#thread-safety
|
||
"""
|
||
if self._pub is None:
|
||
self._pub = self._ctx.socket(zmq.PUB)
|
||
self._pub.set_hwm(self._hwm)
|
||
# Heuristic: bind if wildcard / * present, else connect.
|
||
# bind stable, connect volatile convention
|
||
if (
|
||
"*" in self._endpoint
|
||
or "::" in self._endpoint
|
||
or self._endpoint.startswith("ipc://")
|
||
or self._endpoint.startswith("inproc://")
|
||
):
|
||
self._pub.bind(self._endpoint)
|
||
else:
|
||
self._pub.connect(self._endpoint)
|
||
|
||
# Set up replay socket: use ROUTER
|
||
# 1) handles multiple REQ clients (identities)
|
||
# 2) lets us send back one request → many replies (streamed events)
|
||
# 3) works in our non‑blocking poll loop alongside PUB
|
||
if self._replay_endpoint is not None:
|
||
self._replay = self._ctx.socket(zmq.ROUTER)
|
||
self._replay.bind(self._replay_endpoint)
|
||
|
||
def _publisher_thread(self) -> None:
|
||
"""Background thread that processes the event queue."""
|
||
self._pack = msgspec.msgpack.Encoder()
|
||
|
||
if self._pub is None:
|
||
raise RuntimeError("Publisher socket is not initialized.")
|
||
|
||
while self._running or self._event_queue.qsize() > 0:
|
||
# --- replay (non-critical) ---------------------------------
|
||
if self._replay is not None and self._replay.poll(0):
|
||
try:
|
||
self._service_replay()
|
||
except Exception as exc:
|
||
logger.exception("Error in replay: %s", exc)
|
||
|
||
# --- main queue (critical) ---------------------------------
|
||
try:
|
||
event = self._event_queue.get(timeout=0.1)
|
||
if event is None:
|
||
break # Sentinel received, exit thread
|
||
except queue.Empty:
|
||
continue
|
||
|
||
try:
|
||
seq = next(self._seq_gen)
|
||
|
||
payload = self._pack.encode(event)
|
||
seq_bytes = seq.to_bytes(8, "big")
|
||
self._pub.send_multipart((self._topic_bytes, seq_bytes, payload))
|
||
|
||
self._buffer.append((seq, payload))
|
||
self._event_queue.task_done()
|
||
|
||
except Exception as exc:
|
||
# Publishing failed; back-off a bit to avoid a tight error loop
|
||
logger.exception("Error in publisher thread: %s", exc)
|
||
time.sleep(0.1)
|
||
|
||
def _service_replay(self) -> None:
|
||
"""If a replay request is waiting, send buffered batches."""
|
||
if self._replay is None:
|
||
raise RuntimeError("Replay socket is not initialized.")
|
||
|
||
frame = self._replay.recv_multipart()
|
||
if len(frame) != 3:
|
||
logger.warning("Invalid replay request: %s", frame)
|
||
return
|
||
client_id, _, start_seq_bytes = frame
|
||
start_seq = int.from_bytes(start_seq_bytes, "big")
|
||
|
||
for seq, buf in self._buffer:
|
||
if seq >= start_seq:
|
||
# [identity, empty_delim, seq_bytes, payload]
|
||
# (identity, empty_delim) are stripped off by the router
|
||
# receiving payload is (seq_bytes, payload)
|
||
self._replay.send_multipart(
|
||
(client_id, b"", seq.to_bytes(8, "big"), buf)
|
||
)
|
||
# Send end of sequence marker
|
||
# receiving payload is (-1, b""")
|
||
self._replay.send_multipart((client_id, b"", self.END_SEQ, b""))
|
||
|
||
@staticmethod
|
||
def offset_endpoint_port(
|
||
endpoint: str | None, data_parallel_rank: int
|
||
) -> str | None:
|
||
"""Offset the port in an endpoint by the data parallel rank.
|
||
|
||
Args:
|
||
endpoint: The endpoint string, for example ``tcp://*:5557`` or
|
||
``inproc://cache``.
|
||
data_parallel_rank: The data parallel rank to offset by.
|
||
|
||
Returns:
|
||
The endpoint with the port offset by ``data_parallel_rank`` or a
|
||
suffix appended.
|
||
"""
|
||
# Do nothing if input is None or data_parallel_rank is 0
|
||
if not endpoint or data_parallel_rank == 0:
|
||
return endpoint
|
||
|
||
if "inproc" in endpoint:
|
||
return f"{endpoint}_dp{data_parallel_rank}"
|
||
if "tcp" in endpoint:
|
||
if endpoint and ":" in endpoint:
|
||
# Get everything after the last colon (the port)
|
||
last_colon_idx = endpoint.rfind(":")
|
||
base_addr = endpoint[:last_colon_idx]
|
||
base_port = int(endpoint[last_colon_idx + 1 :])
|
||
new_port = base_port + data_parallel_rank
|
||
return f"{base_addr}:{new_port}"
|
||
return endpoint
|
||
raise ValueError("Invalid endpoint: must contain 'inproc' or 'tcp'")
|
||
|
||
|
||
class KVEventsConfig(BaseModel):
|
||
"""Configuration for KV event publishing."""
|
||
|
||
publisher: str | None = None
|
||
"""The publisher to use for publishing kv events. Can be "null", "zmq".
|
||
Defaults to "zmq" when events are enabled and "null" otherwise.
|
||
"""
|
||
|
||
endpoint: str = "tcp://*:5557"
|
||
"""The zmq endpoint to use for publishing kv events.
|
||
"""
|
||
|
||
replay_endpoint: str | None = None
|
||
"""The zmq endpoint to use for replaying kv events.
|
||
"""
|
||
|
||
buffer_steps: int = 10_000
|
||
"""The number of steps to cache for replay endpoint. Will only save
|
||
events from the last N steps for the replay endpoint.
|
||
"""
|
||
|
||
hwm: int = 100_000
|
||
"""The zmq high water mark for the event publisher. After queueing N events,
|
||
events will start dropping if the consumer is not keeping up.
|
||
"""
|
||
|
||
max_queue_size: int = 100_000
|
||
"""The maximum number of events to queue while waiting for publishing.
|
||
"""
|
||
|
||
topic: str = ""
|
||
"""The topic to use for the event publisher. Consumers can subscribe to
|
||
this topic to receive events.
|
||
"""
|
||
|
||
enable_kv_cache_events: bool = False
|
||
"""Whether to publish scheduler KV cache mutation events."""
|
||
|
||
@classmethod
|
||
def from_cli(cls, cli_value: str) -> "KVEventsConfig":
|
||
"""Parse the CLI value for the event publisher config."""
|
||
return KVEventsConfig.model_validate_json(cli_value)
|
||
|
||
|
||
class EventPublisherFactory:
|
||
_registry: dict[str, Callable[..., EventPublisher]] = {
|
||
"null": NullEventPublisher,
|
||
"zmq": ZmqEventPublisher,
|
||
}
|
||
|
||
@classmethod
|
||
def register_publisher(cls, name: str, ctor: Callable[..., EventPublisher]) -> None:
|
||
if name in cls._registry:
|
||
raise KeyError(f"publisher '{name}' already registered")
|
||
cls._registry[name] = ctor
|
||
|
||
@classmethod
|
||
def create(cls, config: str | None, attn_dp_rank: int = 0) -> EventPublisher:
|
||
"""Create publisher from a config mapping."""
|
||
if not config:
|
||
return NullEventPublisher(attn_dp_rank=attn_dp_rank)
|
||
config = KVEventsConfig.from_cli(config)
|
||
config_dict = config.model_dump()
|
||
enabled = bool(config_dict.pop("enable_kv_cache_events", False))
|
||
if not enabled:
|
||
return NullEventPublisher(attn_dp_rank=attn_dp_rank)
|
||
|
||
kind = config_dict.pop("publisher", None)
|
||
if kind is None:
|
||
kind = "zmq" if enabled else "null"
|
||
if kind == "null":
|
||
return NullEventPublisher(attn_dp_rank=attn_dp_rank)
|
||
try:
|
||
constructor = cls._registry[kind]
|
||
except KeyError as exc:
|
||
raise ValueError(f"Unknown event publisher '{kind}'") from exc
|
||
return constructor(attn_dp_rank=attn_dp_rank, **config_dict)
|
||
|
||
@classmethod
|
||
def is_enabled(cls, config: str | None) -> bool:
|
||
if not config:
|
||
return False
|
||
return KVEventsConfig.from_cli(config).enable_kv_cache_events
|