llm-d router¶
frontend.type: llm-d routes requests with the llm-d router: the
Endpoint Picker (EPP) behind Envoy, in front of direct vllm serve workers. The EPP
scores the workers with the plugins in frontend.epp_config (prefix-cache affinity,
queue depth, load, ...) and, for prefill/decode, picks a prefill and a decode worker for
every request. No Dynamo, NATS, or etcd is involved.
Upstream references are pinned to llm-d-router v0.10.0 and the llm-d no-Kubernetes guide.
What runs where¶
| Process | Where | Launched as | Port |
|---|---|---|---|
| Envoy | router node | the frontend (llm-d-envoy_0) |
the public port (8000); admin /ready on 9901 |
| EPP | router node | the frontend (llm-d-epp_0) |
ext_proc gRPC 9002, gRPC health 9003, Prometheus 9090 |
| P/D sidecar | every routable decode worker (prefill/decode jobs only) | the implied llm-d-sidecar service |
Process.proxy_port, from the allocator |
| vLLM | every worker | the backend, as for the other static routers | Process.http_port |
Envoy owns the public port. For every request it asks the EPP over ext_proc
(FULL_DUPLEX_STREAMED) which endpoint to use; the EPP sets the
x-gateway-destination-endpoint header and Envoy's ORIGINAL_DST cluster forwards the
request there. For prefill/decode the EPP picks a decode endpoint and names the chosen
prefill worker in x-prefiller-host-port
(disagg_profile_handler.go).
The decode endpoint is llm-d's P/D sidecar: it sends the request to that prefill worker
first, then hands the returned kv_transfer_params to its own vLLM, whose KV connector
pulls the cache from the prefill worker
(connector_nixlv2.go).
Everything else, /metrics included, the sidecar proxies to its vLLM.
Why the EPP and Envoy are the frontend and the sidecar is a service¶
The frontend is the process that owns the public port and decides when the job is
ready, so Envoy and the EPP are the frontend. They also have to start at the frontend's
moment: llm-d discovers workers through Kubernetes or a file, and the workers here are
plain engines that register nowhere, so srtctl is the registrar. It writes the endpoints
file once every worker answers /health (the static routers' pre-start gate), then starts
the EPP and Envoy; no service phase sits between the workers and the frontend.
The sidecar is a different process with its own image, placement, and health check, one
per decode worker, on the worker's node; that is what services with
placement.per: worker is for. Its GET /health answers 200 on its own
(proxy.go),
so it starts in the before_workers phase and gates on that probe. It is critical: a
dead sidecar takes its decode worker out of the routing pool. The frontend implies it,
as Dynamo implies etcd; declare a service named llm-d-sidecar to change it:
services:
- name: llm-d-sidecar
type: llm-d-sidecar
container: my-llm-d-image # default: frontend.container_image, else the job container
args:
- --enable-prefiller-sampling # appended after the flags srtctl sets
srtctl sets --port (the worker's Process.proxy_port), --model-server-port (its
Process.http_port), --kv-connector, and --secure-proxy=false; overriding these
flags in command or args is rejected at config load. P/D jobs require exactly one
enabled sidecar service definition; customize the implied entry by name.
The allocator hands out the proxy port (PROXY_PORTS) for every routable worker of a
mode the frontend proxies (Frontend.proxied_worker_modes), so two decode workers on
one node never collide. The --kv-connector protocol comes from the decode workers' vLLM
connector class. Engine commands and sidecar selection use the same payload:
a role's explicit kv-transfer-config takes precedence over its connector,
which takes precedence over engine.connector:
| Decode connector | Sidecar protocol |
|---|---|
NixlConnector |
nixlv2 |
A MultiConnector maps through the first connector it wraps that has a protocol, so
NIXL next to vLLM's OffloadingConnector (CPU KV offloading, as in llm-d's tiered
wide-EP guide) runs nixlv2. Any other decode connector is rejected at load time.
Endpoint discovery¶
The EPP runs without Kubernetes when its configuration names a discovery plugin
(runner.go,
runWithFileDiscovery).
srtctl writes three files into the log directory (every container sees it at /logs):
llm-d-endpoints.yaml: one endpoint per routable worker process (the one with an HTTP port: a multi-node worker's leader), at its IPv4 address on the cluster's network interface, labelledllm-d.ai/role: prefill,decode, orboth(aggregate). A decode endpoint is its sidecar's port.llm-d-epp-config.yaml:frontend.epp_configwith thefile-discoveryplugin (watchFile: false) anddataLayer.discoveryadded.discovery.pluginRefis the spelling v0.10 and v0.11 both accept. A recipe that configures discovery itself is rejected.llm-d-envoy.yaml: the guide's Envoy configuration with the public port, an admin listener srtctl can reach, no request timeout,/metricsanswered with 404 (routed, it would return a different worker's counters on every scrape), and an access log (llm-d-envoy-access.log) recording, for every request, the endpoint the EPP picked (upstream=) and the prefill worker it named (prefiller=).
Readiness and metrics¶
The job is ready when Envoy's admin /ready answers 200 and the EPP's
llm_d_epp_ready_endpoints gauge, the endpoints whose metrics it scraped within its
staleness window
(llm_d_router_metrics.go,
logger.go),
counts every endpoint in the file. The EPP scrapes a worker on the address it routes
to, so a decode worker is scraped through its sidecar. Tachometer scrapes the EPP's
Prometheus listener as the frontend; worker metrics stay on vLLM's own port.
Images¶
The EPP, Envoy, and the sidecar run from frontend.container_image (the sidecar unless
its service sets container), or the job container when it is unset, as the executables
epp, envoy, and pd-sidecar on PATH. llm-d publishes the EPP and the sidecar as
distroless images (/app/epp, /app/pd-sidecar), which pyxis cannot start: it enters a
container through sh
(pyxis_slurmstepd.c). Build an image with a shell that carries the three executables,
for example on top of the vLLM image the workers run:
FROM vllm/vllm-openai:<tag>
COPY --from=ghcr.io/llm-d/llm-d-router-endpoint-picker:v0.10.0 /app/epp /usr/local/bin/epp
COPY --from=ghcr.io/llm-d/llm-d-router-disagg-sidecar:v0.10.0 /app/pd-sidecar /usr/local/bin/pd-sidecar
COPY --from=envoyproxy/envoy:distroless-v1.33.2 /usr/local/bin/envoy /usr/local/bin/envoy
Recipe¶
frontend:
type: llm-d
enable_multiple_frontends: false
container_image: llm-d-vllm # optional: the router image
epp_config: # EndpointPickerConfig without discovery
plugins: [...]
schedulingProfiles: [...]
args: # extra EPP flags, e.g. log verbosity
v: 2
engine:
type: vllm
connector: nixl
frontend.epp_config is accepted only with frontend.type: llm-d.
frontend.args are passed to the EPP; overriding the pool, configuration, port, or
secure-serving flags is rejected at config load. A prefill/decode epp_config needs
prefill and decode scheduling profiles and the disagg-profile-handler.
Without epp_config, srtctl runs the scorers of llm-d's
no-Kubernetes guide
(config.yaml):
queue depth (2), KV-cache utilization (2), approximate prefix cache (3), and
no-hit LRU (2). A prefill/decode job gets them as a prefill profile, a decode
profile that scores by queue depth and KV-cache utilization alone, and the
disagg-profile-handler with always-disagg-pd-decider. (The EPP's own default applies
only to a configuration file without plugins, and srtctl's always carries file
discovery.) See
examples/vllm/llm-d-disagg.yaml and
examples/vllm/llm-d-agg.yaml.
Routing to DP ranks¶
A vLLM worker with data-parallel-size is one vllm serve that balances its DP ranks
itself (the leader serves the API, other nodes run headless ranks), so it is one EPP
endpoint, as it is one SMG worker. Set data-parallel-external-lb: true in the role's
args to have the EPP route to the ranks themselves, as llm-d's wide-EP guide does: srtctl
then launches every DP rank as its own vllm serve --data-parallel-rank <r>
--data-parallel-address <rank 0's node> (vLLM's external load balancing) on its own
allocated HTTP port, on one node or across several, and lists each rank in the endpoints
file. Each rank's step and log carry _dp<r> (agg_0_<node>_dp3, <node>_agg_w0_dp3.out),
and in a prefill/decode job every decode rank gets its own P/D sidecar. vLLM accepts
external load balancing for MoE models only. See
examples/vllm/llm-d-dp-ranks.yaml.
roles:
agg:
gpus: 16
args:
data-parallel-size: 16
data-parallel-external-lb: true
Precise prefix-cache routing¶
The approximate prefix scorers guess where a prefix is cached from the requests the EPP
routed. With roles.<role>.kv_events the workers report it instead: every vLLM worker of
the role publishes its KV-cache events (blocks stored and evicted) to the EPP's
precise-prefix-cache-producer,
which indexes them per endpoint; prefix-cache-scorer with
prefixMatchInfoProducerName: precise-prefix-cache-producer scores the endpoints from
that index. The producer hashes the prompt's real token IDs, so the configuration also
needs a token-producer, which tokenizes through a worker's /v1/*/render endpoints:
frontend:
epp_config:
plugins:
- type: token-producer
- type: precise-prefix-cache-producer
parameters:
indexerConfig:
kvBlockIndexConfig:
enableMetrics: true # index admissions and lookup hits on /metrics
- type: prefix-cache-scorer
parameters:
prefixMatchInfoProducerName: precise-prefix-cache-producer
- type: max-score-picker
schedulingProfiles: [...]
roles:
agg:
kv_events: true
srtctl wires the rest:
- The producer's
kvEventsConfig.zmqEndpointistcp://*:5557(discoverPods: false): the EPP binds one ZMQ socket on the router node and every worker connects to it. The producer's per-pod mode dials the same port on every endpoint address, which workers sharing a node cannot all bind (llm-d-router v0.10's file discovery gives every endpoint rank 0). - Every publishing worker gets
--kv-events-configwithendpoint: tcp://<router>:5557andtopic: kv@<address>:<port>@<served model>, naming the endpoint the EPP routes to (a decode worker's sidecar port) and the model requests name; the producer files the events under that endpoint (vllm_adapter.go). vLLM adds the publisher's DP rank to its port, so an external-LB rankris given5557 - r. The engine setsdata_parallel_indexto the global rank, which the scheduler passes to the publisher. An explicit TCP address connects rather than binds, so all ranks reach the EPP's one subscriber socket. - The token producer's
vllm.urlis the first prefill worker's (else the first worker's) own vLLM API, andmodelNamedefaults to the served model name.
These vLLM references are pinned to ac7509e2b1db40fec2f03dde1ed4e9dfdc2338c9,
shipped in the tested quay.io/rh-ee-imarkov/llm-d-nokube-vllm:dspark-0814-nightly image.
The run artifact
records VLLM_IMAGE_TAG=vllm/vllm-openai:nightly-ac7509e2b1db40fec2f03dde1ed4e9dfdc2338c9
and installed version 0.27.2rc1.dev77+gac7509e2b in fingerprint_decode_w0.json.
With vLLM's OffloadingConnector and its self_describing_kv_events: true, a worker also
reports the blocks it offloads to host memory; the producer indexes them as the cpu tier
and weights tiers by indexerConfig.kvCacheBackendConfigs (gpu 1.0, cpu 0.8 by
default).
The roles that publish and the producer go together: either without the other is
rejected, as are a producer kvEventsConfig that sets the socket or discovery, a
token-producer vllm.url, worker kv_events.endpoint or kv_events.topic, and a
router not on the head node. The tokenizer URL is managed even without precise routing.
A role with data-parallel-size publishes only with data-parallel-external-lb: true: each DP rank
publishes its own events, and the EPP can place a prefix only on an endpoint that is
that rank.
Limitations¶
- vLLM workers over HTTP only; gRPC roles are rejected.
- One router replica: the EPP keeps its scheduling state (the approximate prefix index,
in-flight load) in memory. Multi-node jobs require
enable_multiple_frontends: false: nginx would put the router on a different node from the public endpoint, where readiness checks expect Envoy's admin and the EPP's metrics listeners. Settingnum_additional_frontends: 0still enables nginx and is rejected too. Single-node jobs never launch nginx, regardless ofenable_multiple_frontends. - The endpoints file is written once (
watchFile: false) and the EPP does not eject a worker that fails; a crashed worker or sidecar fails the job instead. - vLLM's
data-parallel-multi-port-external-lb(one supervisor per node serving a port per rank) is not used: its ranks publish KV events on per-rank ports that file discovery can only reach with llm-d-router's per-endpointrankIndex(after v0.11.0). - The precise prefix index lives in the one EPP; KV events go to that EPP alone.