Skip to content

feat(sidecar): direct-gRPC dispatch from router to stock engine containers (v2) - #13292

Draft
tanmayv25 wants to merge 15 commits into
mainfrom
direct-disagg
Draft

feat(sidecar): direct-gRPC dispatch from router to stock engine containers (v2)#13292
tanmayv25 wants to merge 15 commits into
mainfrom
direct-disagg

Conversation

@tanmayv25

Copy link
Copy Markdown
Contributor

Draft / RFC — not intended to merge as one PR. This is the full v2 direct-dispatch stack, opened as a draft so the design and end-to-end validation are reviewable in one place. It will be split into small reviewable PRs (the StreamingDispatch seam already merged as #12447). It is currently ~271 commits behind main and will be rebased before any merge-ready split.

Summary

Lets the Dynamo frontend/router dispatch the final request hop directly over gRPC to stock inference-engine containers (vLLM, SGLang, TensorRT-LLM), removing the per-worker request-plane forwarding hop while preserving router richness (selection, fault detection, migration). The sidecar shrinks to a thin registrar (discovery + health); the engine runs as its stock upstream container instead of a custom Dynamo worker image.

Built on the StreamingDispatch transport seam (#12447): PushRouter's final hop is a trait object, so a per-engine GrpcDispatch swaps only the transport below the seam and inherits report-down / overload / migration by mapping tonic::Status → top-level ErrorType.

v2 architecture

  • Permanent core footprint = the seam + TransportType::Grpc only. All engine-specific code lives in lib/sidecar/* composition crates, deliberately out of the ai-dynamo wheel (no tonic/engine deps in the wheel).
  • run_direct thin registrar shim (dynamo-direct-register) — standalone on runtime+llm, not an LLMEngine; backend-common is clean of the old --direct path.
  • One unified dynamo-direct-frontend registers per-engine providers keyed by the model card's runtime_data["direct_backend"].

Before → after

flowchart LR
    subgraph BEFORE["Today — engine embedded in the Dynamo Python worker"]
        FE1["Frontend / Router"] -->|"request plane (NATS/TCP)"| W1["Dynamo Python worker<br/>engine in-process · custom image"]
    end
    subgraph AFTER["This PR — direct gRPC to the stock container"]
        FE2["dynamo-direct-frontend<br/>Router + StreamingDispatch seam"] -->|"direct gRPC — final hop"| ENG["Stock engine container<br/>vLLM · SGLang · TRT-LLM"]
        REG["run_direct registrar<br/>discovery + health"] -.->|"health probe"| ENG
        REG -.->|"MDC + TransportType::Grpc"| DISC[("discovery")]
        DISC -.->|"discovers Grpc endpoint"| FE2
    end
Loading

Disaggregated (two-phase) direct route

flowchart LR
    FE["Frontend<br/>PrefillRouter"] -->|"1 — prefill hop (direct gRPC)"| P["Prefill engine<br/>component: prefill"]
    P -->|"handoff: bootstrap / kv_transfer_params"| FE
    FE -->|"2 — decode hop (direct gRPC)"| D["Decode engine<br/>component: backend"]
    P -.->|"KV over NIXL (cuda_ipc / RDMA)"| D
    D -->|"stream tokens"| FE
Loading

What's included

  • Direct aggregated dispatch for all 3 engines (chat + streaming + fault-detection/migration + health recovery).
  • Direct disaggregated dispatch for SGLang + vLLM — two-phase prefill→decode over direct gRPC with engine-to-engine NIXL KV transfer.
  • Per-endpoint watcher fan-out so heterogeneous dispatches on one endpoint each receive discovery lifecycle events.
  • Typed vLLM kv_transfer_params handoff (removes the protobuf-Struct float-port bug class).

Known gaps (called out, not in this PR)

  • KV-aware routing is OFF for stock gRPC — engines don't stream KV events the frontend can subscribe to. This is the blocker for retiring the request-plane path.
  • trtllm direct-disagg not implemented — TRT-LLM's gRPC is missing fields Dynamo needs (GetModelInfo returns 0 for max seq len; no disaggregation params in the response path).
  • Metrics come from the stock container /metrics (trtllm --grpc defaults metrics off).
  • Container / distribution packaging deferred.

Validation

  • cargo build green across dynamo-runtime, dynamo-backend-common, dynamo-llm, all lib/sidecar/* crates, and dynamo-direct-frontend.
  • Rust tests green — runtime push_router (incl. the fan-out + fake-dispatch lifecycle tests), vLLM sidecar (incl. the typed-handoff round-trip + float-port tests), sglang/trtllm sidecar suites.
  • Direct-AGG validated E2E on all 3 engines; direct-DISAGG validated E2E on SGLang + vLLM — real generations with NIXL KV transfer proven (decode ran with 0 prefill batches → KV moved, not recomputed).

Follow-ups

Split into reviewable PRs · re-add KV-aware routing over gRPC · trtllm disagg (pending upstream SMG/proto additions) · container packaging.

Introduce a StreamingDispatch transport seam beneath PushRouter so the
frontend can dispatch the final hop straight to a TensorRT-LLM container's
gRPC server, removing the per-worker sidecar forwarding hop while keeping
instance selection, fault detection, overload, and migration.

- runtime: TransportType::Grpc, StreamingDispatch trait (AddressedPushRouter
  is the default request-plane impl), PushRouter::from_client_with_dispatch,
  and direct-gRPC endpoint register/unregister discovery helpers.
- backend-common: --direct registrar mode (register_direct_orchestrator)
  with a hysteresis + timeout health/liveness loop; LLMEngine::health_check;
  shared register_model prologue; --advertise-grpc-endpoint for multi-node
  (advertise a routable address distinct from the sidecar's local endpoint).
- llm: DirectDispatchProvider registry + ModelWatcher selection.
- trtllm sidecar: GrpcDispatch (tonic::Status -> top-level ErrorType fault
  map, one shared dispatch per model so channel pools don't leak across
  routing rebuilds), TrtllmDirectDispatchProvider, and the
  dynamo-trtllm-frontend composition-root binary.

The concrete gRPC dispatch lives in the trtllm crate and registers at a
composition root, keeping dynamo-llm engine-agnostic (dependency inversion).
KV-aware routing stays dormant on the direct path: a stock trtllm --grpc
container does not expose KV events (the SubscribeKvEvents RPC is absent from
the vendored proto), so direct workers route round-robin/least-loaded.

Validation: cargo build (all sidecars + affected crates) + unit tests green
(runtime component serde, trtllm fault map + advertised-endpoint, backend-
common lifecycle).

Signed-off-by: tanmayv25 <tanmay2592@gmail.com>
GrpcDispatch + tonic::Status->top-level ErrorType fault map + VllmDirectDispatchProvider,
dynamo-vllm-frontend composition root, and --direct wiring reusing the engine-agnostic
register_direct_orchestrator. vLLM gRPC has no health RPC; health_check uses a fresh-channel
liveness probe. KV routing off (stock vLLM gRPC emits no KV events). Validated e2e locally
(chat + streaming + CannotConnect fault/migration + health-loop recovery).

Signed-off-by: tanmayv25 <tanmay2592@gmail.com>
…vice gRPC)

GrpcDispatch + fault map + SglangDirectDispatchProvider, dynamo-sglang-frontend composition
root, --direct wiring. Handles SGLang v0.5.16 CUMULATIVE output_ids via token_offset delta
emission (naive delta reuse reproduced a repetition/token-overcount bug). health_check via
SglangService HealthCheck; abort-on-cancel via Abort RPC. KV routing off. Validated e2e
locally (chat + clean streaming deltas + max_tokens honored + fault/migration + recovery).

Note: request-plane engine.rs has the same latent cumulative bug for v0.5.16 (out of scope).
Signed-off-by: tanmayv25 <tanmay2592@gmail.com>
Part 1 (one frontend, many providers): a single `dynamo-direct-frontend`
(lib/sidecar/frontend) registers all three direct-dispatch providers
(trtllm / vLLM / SGLang, keyed by the model card's `direct_backend`) then runs
the standard `Input::Http` frontend. Removes the three per-engine
`dynamo-<engine>-frontend` binaries + their `[[bin]]` entries.

Part 2 (thin discoverability shim): new `dynamo-direct-register`
(lib/sidecar/register) on `dynamo-runtime` + `dynamo-llm` only (NOT
`backend-common`, NOT `LLMEngine`/`Worker`) defines the committed API — the
`DirectBackend` trait (connect / health_check / cleanup) + `DirectRegistration`.
This is the contract for the registrar that replaces `register_direct_orchestrator`.

Deferred (reported): `run_direct` (the lifecycle driver) + per-engine migration
to it + the `backend-common --direct` revert. `build_local_model` is ~100 lines
of intricate `WorkerConfig`/`EngineConfig`-coupled logic (runtime-config assembly,
HF path resolution, disagg endpoint) that can't be safely reimplemented in one
pass without risking the validated build, so engines stay on the v1 `--direct`
registrar until `run_direct` lands.

Builds green: dynamo-direct-frontend, dynamo-direct-register, and the trtllm/
vllm/sglang sidecars compile; backend-common unchanged.

Signed-off-by: tanmayv25 <tanmay2592@gmail.com>
Implement the standalone run_direct lifecycle driver in
dynamo-direct-register (DRT + signals, model-card build,
register_direct_endpoint_instance, hysteresis health loop, graceful
teardown) on dynamo-runtime + dynamo-llm only. Add DirectConfig and
extend DirectRegistration with model_name / parser fields.

Migrate the TensorRT-LLM sidecar onto it: add TrtllmDirectBackend
(connect / health / cleanup), branch main.rs on --direct via
launch_from_env, and drop the engine's is_direct runtime_data surfacing
and health_check override (that logic now lives in the DirectBackend).
backend-common still owns the legacy --direct path for vLLM/SGLang until
they migrate.

Signed-off-by: tanmayv25 <tanmay2592@gmail.com>
Add VllmDirectBackend (connect / fresh-channel liveness health / cleanup)
and branch vLLM's main.rs on --direct via launch_from_env. Drop the
engine's is_direct runtime_data surfacing and health_check override; the
liveness probe now lives in the DirectBackend. Extract the shared clap
parse helper so the direct and request-plane paths reuse it.

Signed-off-by: tanmayv25 <tanmay2592@gmail.com>
Add SglangDirectBackend, which connects + bootstraps SGLang's native
gRPC discovery (model / context / parsers), rejects non-aggregated
engines, and health-gates via the HealthCheck RPC. Split the engine's
from_args into parse + from_parsed so the direct path skips the
request-plane bootstrap, and branch main.rs on --direct via
launch_from_env. Drop the engine's is_direct runtime_data surfacing and
health_check override.

Signed-off-by: tanmayv25 <tanmay2592@gmail.com>
All three sidecars now drive the direct-gRPC path through the standalone
dynamo-direct-register shim, so backend-common's engine-agnostic --direct
registrar is dead. Remove CommonArgs.is_direct + advertise_grpc_endpoint,
WorkerConfig.is_direct, register_direct_orchestrator, the run_inner
is_direct branch, the LLMEngine::health_check default trait method (+ its
EngineKind forwarder), and DIRECT_GRPC_ENDPOINT_KEY (now owned by the
register crate).

Move --direct / --advertise-grpc-endpoint onto the TensorRT-LLM and vLLM
sidecars' own Args (SGLang already had its own), and drop the now-unused
is_direct field from the Python bindings' WorkerConfig construction.

Signed-off-by: tanmayv25 <tanmay2592@gmail.com>
Teach the direct-gRPC discoverability shim about disaggregation. DirectConfig
gains a disaggregation_mode and DirectRegistration gains bootstrap_host /
bootstrap_port / data_parallel_size, so the shim registers each worker with the
ModelType / WorkerType / topology-needs implied by its role (mirroring
backend-common's Worker) and publishes a prefill worker's bootstrap endpoint on
the model card's disaggregated_endpoint.

The SGLang adapter resolves its role from native gRPC discovery at arg-parse
time (component prefill vs backend), re-verifies it on connect, and resolves the
bootstrap host/port + DP size for prefill workers by reusing the request-plane
helpers. The vLLM adapter derives the role from --disaggregation-mode and leaves
the bootstrap endpoint unset (it hands off KV via prefill_result, not a
bootstrap port). TensorRT-LLM stays aggregated-only.

Scope: vLLM and SGLang only; TensorRT-LLM direct disagg remains unsupported.
Signed-off-by: tanmayv25 <tanmay2592@gmail.com>
Give each GrpcDispatch a DisaggregationMode (and, for SGLang prefill, the
discovery-resolved bootstrap host/port) instead of hardcoding Aggregated, so
build_generate_request / build_kv_parameters emit the right KV-transfer handoff
per role. The frontend still stamps bootstrap_info / prefill_result upfront,
which the request translation prefers; the dispatch mode is the fallback and the
switch that turns the handoff on.

Each DirectDispatchProvider now caches one dispatch per (model name,
worker_type) rather than per model name, because a prefill card and a decode
card share a name but need different modes and bootstrap endpoints. The role is
read off the card's worker_type.

Signed-off-by: tanmayv25 <tanmay2592@gmail.com>
When the prefill model card advertises a direct_backend, build its transport
dispatch from the composition-root-registered provider and swap the prefill
PushRouter onto from_client_with_dispatch, keeping all of PushRouter's
selection / fault-detection / migration behavior and changing only the final-hop
transport. This mirrors the decode hop in the discovery watcher.

The prefill card is fetched once and reused for both the EAGLE mode and the
direct dispatch, across the KV and simple router paths. A request-plane prefill
card (no direct_backend) keeps the existing from_client_with_monitor path
unchanged.

Signed-off-by: tanmayv25 <tanmay2592@gmail.com>
…patch

The frontend-side SGLang direct GrpcDispatch drove the prefill hop like the
decode hop: it streamed intermediate tokens and never set
disaggregated_params on the terminal output. On the bootstrap disaggregation
path the frontend already stamps request.bootstrap_info upfront, so the decode
handoff worked, but its background prefill task inspects the prefill output's
disaggregated_params and logged NoDisaggregatedParams on every request.

Mirror the request-plane engine path: for the prefill role suppress
intermediate tokens and emit a single terminal carrying the bootstrap
host/port/room handoff. The background prefill task now completes cleanly and
the direct prefill dispatch matches the sidecar engine behavior.

Signed-off-by: tanmayv25 <tanmay2592@gmail.com>
…ruct

The direct-disagg decode hop forwards the prefill NixlConnector handoff
(kv_transfer_params) across the vLLM gRPC contract as a
google.protobuf.Struct, whose numbers are all IEEE-754 doubles. vLLM's
gRPC frontend hands those to Python as floats, so an integer remote_port
(e.g. 7100) reaches the decode engine as 7100.0. NixlConnector builds its
handshake socket path with make_zmq_path -> f"tcp://{host}:{port}", so
the path becomes tcp://host:7100.0, which urllib3 rejects with
LocationParseError and the KV load fails (decode returns 0 tokens).

Stringify remote_port on the decode handoff so it survives the round-trip
as an exact, dot-free token. Other numeric fields (block_ids, tp_size)
cross as floats too but are only used in numeric contexts, which tolerate
floats; only the port feeds a strict URL parse. Verified end-to-end on a
single GPU with two vllm-rs NixlConnector engines: the decode engine now
completes 'Finished recving KV transfer' and generates coherent output.

Signed-off-by: tanmayv25 <tanmay2592@gmail.com>
…ibers

The discovery-removal watcher deduped by EndpointId and let a single
dispatch own the endpoint's on_instance_removed / on_instance_added
callbacks. That was safe while AddressedPushRouter was the only
StreamingDispatch (its hooks act on shared per-DRT state), but once a
heterogeneous dispatch (e.g. direct gRPC) shares an endpoint, whichever
router registered second received no lifecycle events and leaked its
per-instance state.

Keep one list_and_watch per endpoint but fan each event out to a set of
per-endpoint Weak subscribers. Each router holds the sole strong ref to
its InstanceWatcherSubscription (RAII), so dropping the router prunes it
on the watcher's next event. Regression test: two dispatches on one
endpoint each receive add/remove.

Signed-off-by: tanmayv25 <tanmay2592@gmail.com>
…doff

Replace the ad-hoc stringify_remote_port map-poking with a typed
KvTransferParams at the vLLM engine boundary. remote_port is pinned to a
string via a custom deserializer that accepts a number or string, and
#[serde(flatten)] extra round-trips every other field losslessly. This
kills the float-port bug CLASS at the type level: the kv_transfer_params
handoff crosses the gRPC contract as a google.protobuf.Struct whose
numbers decode to IEEE-754 doubles, so any string-context field must be
pinned to its wire type in one typed place. The framework's
PrefillResult.disaggregated_params stays deliberately opaque passthrough.

Signed-off-by: tanmayv25 <tanmay2592@gmail.com>
@github-actions github-actions Bot added feat router Relates to routing, KV-aware routing, etc. labels Aug 15, 2026
@datadog-official

datadog-official Bot commented Aug 15, 2026

Copy link
Copy Markdown

Tests

🔄 Datadog auto-retried 13 jobs - 13 passed on retry View in Datadog

🎯 Code Coverage (details)
Patch Coverage: 100.00%
Overall Coverage: 48.67% (-4.10%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: db1a68f | Docs | Datadog PR Page | Give us feedback!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat router Relates to routing, KV-aware routing, etc. size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant