Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
537ac24
feat(runtime): add endpoint-scoped event transport
PeaBrane Jul 17, 2026
7e5b376
fix(llm): satisfy recovery clippy lint
PeaBrane Jul 17, 2026
5c6e965
fix(discovery): fence load and model ownership
PeaBrane Jul 17, 2026
88c1c9e
fix(fpm): stop rescanning model cards
PeaBrane Jul 17, 2026
9ad8e78
feat(kv-router): separate KV state endpoints
PeaBrane Jul 17, 2026
6f955fc
fix(kv-router): fence endpoint source lifecycles
PeaBrane Jul 17, 2026
2017fd9
test(kv-router): cover lower-tier prefix extension
PeaBrane Jul 18, 2026
1f73291
test(sglang): include KV endpoint fixture field
PeaBrane Jul 18, 2026
eac42b3
fix(kv-router): fence cursors behind index admission
PeaBrane Jul 18, 2026
f17ac5e
refactor(llm): derive P/D and load state without replay
PeaBrane Jul 18, 2026
19c7318
fix(ci): align KV reset and publisher fixtures
PeaBrane Jul 18, 2026
2a41f78
test(runtime): expect revised memory-store values
PeaBrane Jul 18, 2026
3e66e51
fix(router): reactivate prefill after handshake
PeaBrane Jul 18, 2026
d580fc8
test(router): cover live disagg startup orders
PeaBrane Jul 18, 2026
448fd6f
test(router): share SSE attribution parsing
PeaBrane Jul 18, 2026
7bec2aa
test(kv-router): fence multi-node rank replacement
PeaBrane Jul 18, 2026
020f5c1
test(events): prune redundant endpoint coverage
PeaBrane Jul 18, 2026
88d5700
test(planner): keep runtime threads out of later forks
PeaBrane Jul 18, 2026
5551348
test(planner): start asyncio after the fork
PeaBrane Jul 18, 2026
7a64a30
fix(runtime): keep concurrent file buckets alive
PeaBrane Jul 18, 2026
7926625
refactor(kv-router): narrow recovery acknowledgements
PeaBrane Jul 18, 2026
4b589b6
test(planner): isolate FPM integration subprocess
PeaBrane Jul 18, 2026
77d253a
fix(kv-router): track admitted recovery cursors
PeaBrane Jul 18, 2026
3a355ee
fix(kv-router): keep cache clears in their rank lane
PeaBrane Jul 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions components/src/dynamo/common/backend/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ class WorkerConfig:
route_to_encoder: bool = False
media_decoder: Optional[MediaDecoder] = None
media_fetcher: Optional[MediaFetcher] = None
# KV event/recovery ownership endpoint. None uses this worker's serving endpoint.
kv_state_endpoint: Optional[str] = None

@classmethod
def from_runtime_config(
Expand All @@ -163,6 +165,7 @@ def from_runtime_config(
"namespace": runtime_cfg.namespace,
"component": getattr(runtime_cfg, "component", None) or "backend",
"endpoint": getattr(runtime_cfg, "endpoint", None) or "generate",
"kv_state_endpoint": getattr(runtime_cfg, "kv_state_endpoint", None),
"model_name": model_name,
"served_model_name": served_model_name,
"endpoint_types": getattr(
Expand Down Expand Up @@ -255,6 +258,7 @@ async def run(self) -> None:
namespace=self.config.namespace,
component=self.config.component,
endpoint=self.config.endpoint,
kv_state_endpoint=self.config.kv_state_endpoint,
model_name=self.config.model_name,
served_model_name=self.config.served_model_name,
model_input=self.config.model_input,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ class DynamoRuntimeConfig(ConfigBase):

namespace: str
endpoint: Optional[str] = None
# Exact endpoint that owns this worker's KV event and recovery state. None
# maps KV state to the serving endpoint and does not change request routing.
kv_state_endpoint: Optional[str] = None
discovery_backend: str
request_plane: str
event_plane: Optional[str] = None
Expand Down Expand Up @@ -128,6 +131,13 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
default=None,
help="Dynamo endpoint string in 'dyn://namespace.component.endpoint' format. Example: dyn://dynamo.backend.generate.",
)
add_argument(
g,
flag_name="--kv-state-endpoint",
env_var="DYN_KV_STATE_ENDPOINT",
default=None,
help="Endpoint identity that owns this worker's KV event and recovery state. Defaults to the serving endpoint.",
)
add_argument(
g,
flag_name="--discovery-backend",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ def test_fpm_trace_defaults_disabled(monkeypatch):
assert "DYN_FPM_TRACE" not in os.environ


def test_kv_state_endpoint_supports_cli_and_env(monkeypatch):
monkeypatch.setenv("DYN_KV_STATE_ENDPOINT", "dynamo/kv/events")
env_config, help_text = _parse_runtime_args([])
cli_config, _ = _parse_runtime_args(["--kv-state-endpoint", "other/cache/updates"])

assert env_config.kv_state_endpoint == "dynamo/kv/events"
assert cli_config.kv_state_endpoint == "other/cache/updates"
assert "--kv-state-endpoint" in help_text
assert "DYN_KV_STATE_ENDPOINT" in help_text


def test_fpm_trace_env_enables_and_is_canonicalized(monkeypatch):
monkeypatch.setenv("DYN_FPM_TRACE", "on")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

import asyncio
from contextlib import AsyncExitStack

import pytest

from dynamo.llm import (
FpmDirectPublisher,
ModelInput,
ModelType,
WorkerType,
register_model,
)
from dynamo.planner.config.planner_config import PlannerConfig
from dynamo.planner.core.base import NativePlannerBase
from dynamo.planner.core.state_machine import PlannerScalingState
from dynamo.planner.core.types import (
EngineCapabilities,
ScheduledTick,
WorkerCapabilities,
WorkerCounts,
)
from dynamo.planner.environment.metrics_provider.runtime_provider import (
RuntimeFpmProvider,
)
from dynamo.planner.environment.state import DeploymentState
from dynamo.planner.monitoring.worker_info import WorkerInfo
from dynamo.runtime import DistributedRuntime

pytestmark = [
pytest.mark.gpu_0,
pytest.mark.pre_merge,
pytest.mark.integration,
pytest.mark.planner,
]


class _PlannerSources:
def __init__(self, namespace: str, state: DeploymentState) -> None:
self._namespace = namespace
self._state = state

def deployment_state(self) -> DeploymentState:
return self._state

def runtime_namespace(self) -> str:
return self._namespace


class _PlannerEnvironment(_PlannerSources):
def __init__(self, namespace, state, provider):
super().__init__(namespace, state)
self._provider = provider

def collect_fpm(self):
return self._provider.collect_fpm()


class _DecodePlanner(NativePlannerBase):
require_decode = True


@pytest.mark.asyncio
async def test_endpoint_fpm_reaches_planner_and_drives_scale_up(tmp_path, monkeypatch):
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
monkeypatch.setenv("DYN_FILE_KV", str(tmp_path))
namespace = "fpm-test"
endpoint_path = f"{namespace}.worker.generate"
async with AsyncExitStack() as stack:
worker_runtime = DistributedRuntime(
asyncio.get_running_loop(), "file", "tcp", event_plane="zmq"
)
stack.callback(worker_runtime.shutdown)
planner_runtime = DistributedRuntime(
asyncio.get_running_loop(), "file", "tcp", event_plane="zmq"
)
stack.callback(planner_runtime.shutdown)
decoy_runtime = DistributedRuntime(
asyncio.get_running_loop(), "file", "tcp", event_plane="zmq"
)
stack.callback(decoy_runtime.shutdown)
worker_endpoint = worker_runtime.endpoint(endpoint_path)
worker_id = str(worker_endpoint.connection_id())
decoy_endpoint = decoy_runtime.endpoint(f"{namespace}.worker.generate-decoy")
# Deliberately reuse the serving worker ID. If endpoint scoping regresses, identity-based
# assertions cannot distinguish this decoy from the configured serving pool.
decoy_publisher = FpmDirectPublisher(decoy_endpoint, worker_id, dp_size=1)
stack.callback(decoy_publisher.shutdown)
await register_model(
ModelInput.Tensor,
ModelType.TensorBased,
worker_endpoint,
"test-model",
worker_type=WorkerType.Aggregated,
)
state = DeploymentState()
state.decode.info = WorkerInfo(
component_name="worker",
endpoint="generate",
model_name="test-model",
)
state.decode.replicas.active = 1
state.decode.replicas.expected = 1
sources = _PlannerSources(namespace, state)
config = PlannerConfig(
mode="agg",
enable_load_scaling=True,
enable_throughput_scaling=False,
optimization_target="load",
served_model_name="test-model",
decode_scale_up_kv_rate=90.0,
decode_scale_down_kv_rate=50.0,
min_endpoint=1,
max_gpu_budget=-1,
metric_reporting_prometheus_port=0,
)
# Establish a decoy-only quiet phase before the serving publisher emits anything. A
# component-scoped subscription would observe this as the real worker because the IDs are
# intentionally identical; the endpoint-scoped subscriber must remain empty.
async with AsyncExitStack() as isolation_stack:
isolation_provider = RuntimeFpmProvider(
require_prefill=False,
require_decode=True,
backend="vllm",
model_name="test-model",
runtime=planner_runtime,
state_source=sources,
namespace_source=sources,
)
await isolation_provider.async_init()
isolation_stack.push_async_callback(isolation_provider.shutdown)
for _ in range(20):
decoy_publisher.publish(
dp_rank=0,
scheduled_num_prefill_requests=0,
scheduled_sum_prefill_tokens=0,
scheduled_sum_prefill_kv_tokens=0,
scheduled_num_decode_requests=1,
scheduled_sum_decode_kv_tokens=999,
queued_num_prefill_requests=0,
queued_sum_prefill_tokens=0,
queued_num_decode_requests=1,
queued_sum_decode_kv_tokens=999,
wall_time_secs=0.01,
)
await asyncio.sleep(0.05)
assert not isolation_provider.collect_fpm().decode

publisher = FpmDirectPublisher(worker_endpoint, worker_id, dp_size=1)
stack.callback(publisher.shutdown)
provider = RuntimeFpmProvider(
require_prefill=False,
require_decode=True,
backend="vllm",
model_name="test-model",
runtime=planner_runtime,
state_source=sources,
namespace_source=sources,
)
await provider.async_init()
stack.push_async_callback(provider.shutdown)
planner = _DecodePlanner(
None,
config,
_PlannerEnvironment(namespace, state, provider),
)

tick_input = None
for _ in range(100):
publisher.publish(
dp_rank=0,
scheduled_num_prefill_requests=0,
scheduled_sum_prefill_tokens=0,
scheduled_sum_prefill_kv_tokens=0,
scheduled_num_decode_requests=1,
scheduled_sum_decode_kv_tokens=200,
queued_num_prefill_requests=0,
queued_sum_prefill_tokens=0,
queued_num_decode_requests=1,
queued_sum_decode_kv_tokens=100,
wall_time_secs=0.01,
)
await asyncio.sleep(0.05)
tick_input = await planner._gather_tick_input(
ScheduledTick(
at_s=0.0,
need_worker_states=True,
need_worker_fpm=True,
run_load_scaling=True,
)
)
if tick_input.fpm_observations.decode:
break

assert tick_input is not None
observations = tick_input.fpm_observations
assert observations.decode is not None
assert (worker_id, 0) in observations.decode

scaling = PlannerScalingState(
config,
WorkerCapabilities(decode=EngineCapabilities(num_gpu=1, max_kv_tokens=100)),
)
assert tick_input.worker_counts == WorkerCounts(
ready_num_decode=1,
expected_num_decode=1,
)
scaling.observe_worker_counts(tick_input.worker_counts)

decision = scaling.advance_load(observations)

assert decision is not None
assert decision.num_decode is not None
assert decision.num_decode > 1
1 change: 1 addition & 0 deletions components/src/dynamo/sglang/publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ def init_kv_event_publish(self) -> List[KvEventPublisher]:
zmq_topic="",
enable_local_indexer=self.dynamo_args.enable_local_indexer,
dp_rank=dp_rank,
kv_state_endpoint=self.dynamo_args.kv_state_endpoint,
)
self.kv_publishers.append(publisher)

Expand Down
1 change: 1 addition & 0 deletions components/src/dynamo/sglang/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ async def _get_runtime_config(
ModelRuntimeConfig with extracted values, or None if extraction fails.
"""
runtime_config = ModelRuntimeConfig()
runtime_config.kv_state_endpoint = dynamo_args.kv_state_endpoint
runtime_config.context_length = server_args.context_length
# set reasoning parser and tool call parser
runtime_config.reasoning_parser = dynamo_args.dyn_reasoning_parser
Expand Down
4 changes: 2 additions & 2 deletions components/src/dynamo/sglang/tests/test_sglang_publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ def __init__(self, **kwargs):
)
config = SimpleNamespace(
server_args=server_args,
dynamo_args=SimpleNamespace(enable_local_indexer=True),
dynamo_args=SimpleNamespace(enable_local_indexer=True, kv_state_endpoint=None),
)
publisher = DynamoSglangPublisher(
engine=SimpleNamespace(),
Expand Down Expand Up @@ -461,7 +461,7 @@ def shutdown(self):
)
config = SimpleNamespace(
server_args=server_args,
dynamo_args=SimpleNamespace(enable_local_indexer=True),
dynamo_args=SimpleNamespace(enable_local_indexer=True, kv_state_endpoint=None),
)
publisher = DynamoSglangPublisher(
engine=SimpleNamespace(
Expand Down
9 changes: 9 additions & 0 deletions components/src/dynamo/trtllm/publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,10 @@ class Publisher:
Note: The ZmqKvEventPublisher used here is the pure Python ZMQ publisher defined
in this module, not the Rust-based KvEventPublisher from dynamo.llm (which is
used in main.py as the worker-side subscriber from consolidator to NATS).

``kv_state_endpoint`` selects the exact Dynamo endpoint that owns the published
KV event and recovery state. ``None`` maps KV state to the serving endpoint; it
does not change the endpoint used for request routing.
"""

def __init__(
Expand All @@ -390,6 +394,7 @@ def __init__(
zmq_endpoint: Optional[str] = None,
enable_local_indexer: bool = False,
metrics_collector: Any = None,
kv_state_endpoint: Optional[str] = None,
) -> None:
self.endpoint = endpoint
self.engine = engine
Expand All @@ -403,6 +408,7 @@ def __init__(
self.additional_metrics.set_kv_event_buffer_capacity(event_buffer_max_size)
self.enable_local_indexer = enable_local_indexer
self.metrics_collector = metrics_collector
self.kv_state_endpoint = kv_state_endpoint
self.attention_dp_size = engine.get_attention_dp_size()

# The first few kv events from the model engine are always "created" type events.
Expand Down Expand Up @@ -510,6 +516,7 @@ def initialize(self) -> None:
kv_block_size=self.kv_block_size,
dp_rank=rank,
enable_local_indexer=self.enable_local_indexer,
kv_state_endpoint=self.kv_state_endpoint,
)
logging.info(
f"Created {self.attention_dp_size} KV event publisher(s) for attention DP ranks"
Expand Down Expand Up @@ -1082,6 +1089,7 @@ async def get_publisher(
zmq_endpoint: Optional[str] = None,
enable_local_indexer: bool = False,
metrics_collector: Any = None,
kv_state_endpoint: Optional[str] = None,
) -> AsyncGenerator[Publisher, None]:
publisher = Publisher(
endpoint,
Expand All @@ -1095,6 +1103,7 @@ async def get_publisher(
zmq_endpoint=zmq_endpoint,
enable_local_indexer=enable_local_indexer,
metrics_collector=metrics_collector,
kv_state_endpoint=kv_state_endpoint,
)
try:
publisher.initialize()
Expand Down
3 changes: 3 additions & 0 deletions components/src/dynamo/trtllm/workers/llm_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,7 @@ async def init_llm_worker(
# So for now, we just set the parsers from the config
# TODO: fix this once we have a better way to get total_kv_blocks
runtime_config = ModelRuntimeConfig()
runtime_config.kv_state_endpoint = config.kv_state_endpoint
runtime_config.context_length = config.max_seq_len

# Set values from config that are available immediately
Expand Down Expand Up @@ -771,6 +772,7 @@ async def init_llm_worker(
zmq_endpoint=consolidator_output_connect_endpoint,
zmq_topic="",
enable_local_indexer=config.enable_local_indexer,
kv_state_endpoint=config.kv_state_endpoint,
)
logging.info(
f"Created worker-side publisher for consolidated events: "
Expand All @@ -789,6 +791,7 @@ async def init_llm_worker(
zmq_endpoint=trtllm_zmq_bind_endpoint,
enable_local_indexer=config.enable_local_indexer,
metrics_collector=metrics_collector,
kv_state_endpoint=config.kv_state_endpoint,
) as publisher:
handler_config.publisher = publisher
handler = RequestHandlerFactory().get_request_handler(handler_config)
Expand Down
Loading
Loading