Routing with the llm-d Router¶
Every cluster simulation routes with the llm-d router, the Endpoint Picker (EPP) of llm-d-router. BLIS has no routing, admission or flow-control logic of its own: it runs the router's real code in-process, configured by the same files a real deployment gives it, and feeds it signals produced by the simulated engines. Two consequences follow.
- Configuration parity. A deployment's
EndpointPickerConfig, itsInferencePool,InferenceObjectiveandInferenceModelRewritemanifests, and its EPP flags run in BLIS unchanged, and the other way around. There is no BLIS-specific routing configuration. - Behavioral fidelity. BLIS makes the routing, admission and flow-control decisions the router would make given the same signals. The remaining differences between a simulation and a real deployment come from engine modeling, not from routing.
For instance-level request ordering (the engine's own scheduler), see Scheduling & Priority.
# The router's built-in configuration over four instances (the scenario states no router)
./blis run --scenario llama-3.1-70b-instruct-h200-tp4-4node.yaml --scenarios testdata/scenarios \
--registry $PWD/blis-registry --num-instances 4 --rate 100 --num-requests 500
# A real deployment's router configuration, unchanged, named by the scenario's router block
./blis run --scenario llama-3.1-70b-instruct-h200-tp4-4node-inferencepool.yaml \
--scenarios examples/llm-d/scenarios \
--registry $PWD/blis-registry --num-instances 4 --rate 100 --num-requests 500
The router's inputs¶
The router's inputs are a deployment property, so the scenario states them, as parallel:
states the tensor-parallel width: the deployment's router: block and each pool's labels
(blis-schemas spec/deployment). There are no router flags for them; --epp-flags,
--epp-config, --epp-manifests and --pod-labels were removed, as --tp was, and are
refused as unknown flags.
kind: Deployment
name: my-deployment
pools:
- role: colocated
nodes: 4
labels: {app: vllm} # labels on this pool's model-server pods
parallel: {tp: 4, pp: 1, dp: 1, enable_expert_parallel: false}
engine: {...}
router:
epp_flags: [--pool-name=vllm, --refresh-metrics-interval=20ms]
endpoint_picker_config: epp-config.yaml # relative to this scenario file
manifests: [inferencepool.yaml]
| Input | Scenario field | What it is |
|---|---|---|
| EPP flags | router.epp_flags |
The router's command-line flags, verbatim, one entry per flag (a Helm chart's epp.flags rendered as --name=value). They select the pool (--pool-name or --endpoint-selector) and set the router's own knobs (--refresh-metrics-interval, --metrics-staleness-threshold, --feature-gates, ...). A --config-file flag is read relative to the scenario file. |
| EndpointPickerConfig | router.endpoint_picker_config |
The router configuration file, as the EPP reads it with --config-file. Naming it here and with an EPP --config-file/--config-text flag is refused. |
| Manifests | router.manifests |
Kubernetes YAML files. The router reads InferencePool, InferenceObjective and InferenceModelRewrite objects through its own reconcilers; other kinds (the model servers' Deployments, Services, ...) are ignored. |
| Pod labels | pools[].labels |
Labels on the pool's simulated model-server pods, so an InferencePool selector or a label filter from a real deployment matches. A label every pool states with one value is on every pod; any other is on its own pool's pods only. BLIS sets llm-d.ai/role from each prefill and decode pool's role (blis-schemas refuses a pool label that contradicts it) and llm-d.ai/inference-serving=true on every pod. |
Paths are relative to the scenario file's directory. A file that cannot be read is refused at
startup, naming the scenario and the field. Each input is parsed by the router's own code: the
flags by the EPP's flag set, the configuration by its config loader, the manifests by its
reconcilers. A value the real router rejects is rejected here with the router's message.
examples/llm-d/scenarios/ holds scenarios that route with each configuration in
examples/llm-d/.
blis run --trace-output records the resolved inputs, inline (the configuration's contents, not
its path), in the trace header, with the seed the router ran with, and blis replay routes with
them, so a replay reproduces the run's routing whatever --seed it is given (INV-13). A replay
whose scenario states no router replays the recorded one. A replay whose scenario does state one
(a router block or pool labels) must state the recorded router, and a router environment flag
(below) given to replay must match the header's; otherwise the replay is refused, never
silently preferred over the header.
Defaults. With no EPP flags, BLIS runs a standalone router whose pool is every simulated
pod: --endpoint-selector=llm-d.ai/inference-serving=true --endpoint-target-ports=8000. With
no configuration, the router uses its built-in default configuration (queue, KV-cache
utilization and prefix-cache scorers). BLIS installs no InferenceObjective of its own: request
priorities come only from the deployment's objectives, which the router reconciles for an
InferencePool (--pool-name). A request whose objective has no InferenceObjective, and every
request in standalone mode, gets priority 0, as in a deployment.
examples/llm-d/inferencepool.yaml defines one objective per BLIS SLO class.
As an API server would. The manifests are admitted the way a cluster admits them: custom
resources get their CRD defaults (an InferenceObjective poolRef without a group gets
inference.networking.k8s.io), both served InferenceObjective versions (llm-d.ai/v1 and
llm-d.ai/v1alpha2) are reconciled, unknown or duplicate fields are errors (strict field
validation), and a router resource at a version no CRD serves is an error. The CRDs are those of the llm-d-router and Gateway API Inference Extension versions BLIS
builds against.
The environment around the router¶
Three BLIS flags model the environment, not routing:
| Flag | Default | Meaning |
|---|---|---|
--gateway-latency |
0 µs | Time from a request's arrival to the router's first look at it: the network and proxy hops before the Endpoint Picker. |
--epp-scrape-jitter |
0 µs | Upper bound of a seeded phase offset for each endpoint's metrics collection. At 0 every endpoint is scraped in phase. |
--kv-event-delay |
0 µs | Time from an engine publishing its KV-cache events (at the end of a step) to the router's precise-prefix-cache-producer ingesting them: the event transport and the router's processing. At 0 the router has every event of the steps that have ended. |
The router itself takes zero simulated time to decide.
EPP flags with no effect. Flags that change how a deployed router is served or observed,
never what it decides (--ha-*, --drain-timeout, --tracing, --health-checking, TLS,
certificate, gRPC and metrics-endpoint flags, --emit-endpoint-scores), are accepted and have no
effect in a simulation; a run that sets one says so on stderr. The legacy metric-name flags
(--total-queued-requests-metric, ...) must name a series the simulated engines publish.
Router logs. The router's errors go to stderr as BLIS errors, so they show at every --log
level except fatal and panic; its informational messages appear with --log debug or
--log trace.
One instance. A single-instance cluster also runs the router: it can only pick that instance, but its admission and flow control still apply. With the default configuration it admits every request at once, and results are identical to a run without a router.
What the router sees¶
Everything the router reads comes from the simulated engines, with real engine semantics.
Inside a DP replica (#1909): a scenario with dp > 1 on an MoE model shows the router one endpoint per replica. The cluster itself then picks the rank with vLLM v0.31.0's own balancer formula, its 100 ms coordinator period and the per-assignment waiting increment (data_parallel_load_balancing internal, the default, or hybrid; under external the router sees every rank). See Latency Models.
- Requests. Each arrival is the request Envoy would hand the router: an OpenAI
/v1/completionsbody whosepromptis the request's tokens, withmax_tokensandstream. The model is the deployment's model, or the request's LoRA adapter. The headers carryx-llm-d-inference-objective(the request's SLO class),x-llm-d-inference-fairness-id(its tenant), when the request has a TTFT targetx-llm-d-slo-ttft-ms, and when it has a client timeoutx-llm-d-inference-ttl: its lifetime (deadline minus arrival) as a Go duration. Flow control takes the shorter of that and the configured queue budget (60 s by default), and anedf-ordering-policyorders by it; the client still gives up at its deadline, counted as timed out. The router's request handler, parser and Director process it exactly as in a deployment. - Multimodal requests. A request carrying image, audio or video tokens is sent as a client
sends media: a chat completion (
/v1/chat/completions) whose message has animage_url,input_audioorvideo_urlpart per modality. The router's token producer sees the media items (its estimate backend from the parts; a vLLM render backend from BLIS's simulated renderer, which renders the request's tokens with each modality's placeholder span, after the text, and a hash of that span). With an encode pool,always-disagg-multimodal-deciderthen sends such a request to the encode pods the router picks (every one inx-llm-d-encoder-hosts-ports, in order); as in BLIS's own encode stage, encoding takes no simulated time, and the choice is recorded in the decision trace and on the request. - Prompt content. BLIS token values are placeholders: a workload models prompt lengths and which prompts share a prefix, nothing more, and a replayed trace draws new placeholder values. The router, which hashes prompt content, is therefore shown canonical tokens: each KV block of a prompt (the last one possibly partial) is identified by its prefix chain, and every chain has fixed canonical tokens. Two prompts are equal exactly where they share a chain of whole blocks, as the engines' prefix caches see them, so a router block size that is a multiple of the engine's block size hashes the canonical prompt as it would the original, and a run and its replay show the router identical prompts.
- Metrics. Each engine publishes vLLM's Prometheus series at the end of every step, with
vLLM's semantics:
vllm:num_requests_waiting,vllm:num_requests_running,vllm:kv_cache_usage_perc,vllm:num_preemptions_total,vllm:cache_config_infoand, when LoRA is active,vllm:lora_requests_infowith the running and waiting adapter sets. The router scrapes them on its own schedule (--refresh-metrics-interval) through its own data source and extractor, so a scrape that lands mid-step sees the previous step's values, as in vLLM. Endpoints present at the start of a run have been scraped before the first request arrives; their periodic scrapes then keep their--epp-scrape-jitterphase. Endpoints that appear later (autoscaling, lifecycle) start cold, as on discovery. - KV-cache events. With a
precise-prefix-cache-producerin the configuration, each engine publishes vLLM's KV events: a stored block when a block hash enters its prefix cache (a full prefill or decode block, or one reloaded from a CPU tier) and a removed block when one leaves, at the end of the step, as vLLM does. The router receives them--kv-event-delaylater, in order per engine, and ingests them synchronously (no ZMQ subscriber). The events carry the blocks' tokens as the router saw them, so the router's index holds, per pod, exactly the blocks the engine can serve from its cache. A terminated instance's blocks are cleared. Speculative indexing (speculativeIndexing) expires its entries on simulated time. With a CPU KV tier (--kv-cpu-blocks, or the--kv-offload-configchain), a block that becomes ready on the CPU tier, or leaves it, is published as a location event on tiercpu, in the same ordered stream, so the router indexes each block on every tier that holds it. A file-system tier behind the CPU tier publishes only when itsenable_kv_eventsis set (vLLM's default is off): a block written to it is a location event on tiershared_storage, the medium llm-d's file-system backend publishes, which the prefix scorer weighs 0.4 by default (kvCacheBackendConfigs). With it off, the router cannot follow a block whose only copy is on that tier: when the block is promoted back to the CPU tier, its location event names a block the router has forgotten and is skipped, as llm-d skips it. A terminated instance's clear travels the same delayed stream, after every batch the instance published. A chain's canonical tokens stay fixed until the router has received the last removal of its block. - Prompt rendering. The
token-producerplugin's vLLM backend calls a render endpoint; the simulated model server answers it with the request's token IDs. - Responses. The router sees the response headers when the engine accepts a streaming
request, the first chunk when the first token is delivered, and the final chunk, with
usage(prompt tokens and the tokens actually generated), when the response completes. A non-streaming response arrives whole at completion. A request that times out or is dropped ends with the termination cause the router's stream handler would report. - Endpoints. Instances are pods of the pool, reconciled by the router's pod reconciler: ready while the instance is routable, not ready while it loads, drains or terminates.
Decisions¶
The router's decision becomes a BLIS dispatch:
- the endpoint it picks serves the request;
- under disaggregation, the prefill endpoint the
disagg-profile-handlernames inx-llm-d-prefiller-host-portruns the prefill, and the KV moves to the picked endpoint, which serves the response. KV transfer is priced by the latency kernel, as before; - a request the router does not dispatch lands in exactly one INV-1 bucket:
| Router outcome | BLIS bucket |
|---|---|
| Admission denial: an admitter plugin, or the legacy admission controller shedding a sheddable request (priority < 0) from a saturated pool | rejected_requests (admission) |
| No endpoint can serve it (no candidates, every filter or screener eliminated all) | routing_rejections |
| Flow control rejects it: queues at capacity | gateway_queue_rejected |
| It waits in flow control past its queue-wait budget (TTL) | gateway_expired |
| Its client deadline passes while the router holds it (the client disconnects; the router cancels it) | timed_out_requests |
| It is still waiting in flow control when the run ends | gateway_queue_depth |
The outcomes are an explicit allow-list keyed on the router's status code and its
x-llm-d-request-dropped-reason. A rejection that matches none of them (an internal error, a
malformed request) is not a routing outcome: it ends the run with an error rather than being
counted as shedding. So does a run in which the router holds requests that nothing can release.
With --trace-level decisions, each routing record carries the scores the router's primary
scheduling profile gave every endpoint; --counterfactual-k ranks them.
Fidelity, and BLIS's former router¶
The acceptance criterion is behavioral fidelity to llm-d: given the same configuration and the same signals, BLIS makes the decisions the router makes. It is not agreement with BLIS's former native router, which ranked instances on instantaneous engine state that no real router has. The two agree where routing cannot matter and differ, as they should, where signal freshness does.
Measured on llama-3.1-70b-instruct-h200-tp4-4node, seed 42 (BLIS main at 0d092d4e vs this
branch, the same kernel and schemas; mean TTFT / p99 TTFT in ms):
| Workload | Former native router | llm-d default configuration | llm-d with examples/llm-d/inflight-load.yaml |
|---|---|---|---|
| 4 instances, 10 req/s, 800 requests | 87.7 / 119.4 | 87.5 / 118.6 | 87.7 / 119.4 |
| 4 instances, 40 req/s, 2000 requests | 96.8 / 129.6 | 184.6 / 385.1 | 105.5 / 189.7 |
| 8 instances, 80 req/s, 3000 requests | 95.5 / 129.5 | 401.8 / 964.5 | 105.4 / 186.0 |
Completed requests agree exactly, and E2E latency and throughput within 1.2%, in every cell. The difference is placement under load:
- The default configuration ranks endpoints only on scraped metrics. llm-d scrapes at
--refresh-metrics-interval(50 ms, which is also the minimum it accepts), and a vLLM engine updates its gauges only at the end of a step, so between scrapes every request sees the same ranking and goes to the same endpoint. That herding is what the real router does with this configuration; it is the effect a capacity plan should see. - The router's own in-flight bookkeeping is current at every decision. A configuration that
scores on it, as
examples/llm-d/inflight-load.yamldoes with llm-d'sinflight-load-producerandactive-request-scorer, comes within 10% of the former router's mean TTFT. --epp-scrape-jitter(here 50 ms) de-phases the endpoints' scrapes and moves the default configuration's mean TTFT by 6-7%; staleness, not phase, dominates.
What is not yet measured: a comparison against traces of a real llm-d deployment.
Supported plugins¶
A configuration may use these plugin types. Each was audited in the router fork: every clock read, background loop, blocking wait and random draw it makes goes through the hosting runtime, and every signal it consumes is produced by the simulated engines.
| Kind | Types |
|---|---|
| Profile handlers and pickers | single-profile-handler, disagg-profile-handler, max-score-picker, random-picker, weighted-random-picker |
| Filters | decode-filter, prefill-filter, encode-filter, label-selector-filter |
| Scorers | queue-scorer, kv-cache-utilization-scorer, prefix-cache-scorer, load-aware-scorer, running-requests-size-scorer, lora-affinity-scorer, token-load-scorer, no-hit-lru-scorer, active-request-scorer, context-length-aware |
| P/D deciders | always-disagg-pd-decider, prefix-based-pd-decider |
| Data producers | approx-prefix-cache-producer, precise-prefix-cache-producer (fed by the engines' KV events), inflight-load-producer, token-producer (estimate backend, or vLLM backend with a fixed URL) |
| Admission | probabilistic-admitter |
| Saturation detectors | utilization-detector, concurrency-detector, max-saturation-detector |
| Flow control | global-strict-fairness-policy, round-robin-fairness-policy, fcfs-ordering-policy, edf-ordering-policy, slo-deadline-ordering-policy, static-usage-limit-policy, strict-band-selection-policy, priority-holdback-policy, soft-reflective-ceiling-policy |
| Data layer | metrics-data-source, core-metrics-extractor, endpoint-notification-source |
| Request parsers | openai-parser, vllmhttp-parser, sglanghttp-parser, anthropic-parser, passthrough-parser, vertexai-parser |
The flow-control layer (featureGates: [flowControl]) runs in full: priority bands, capacity
limits, queue-wait budgets, the 1 ms dispatch tick and the 1 s expiry sweep.
Refused at startup¶
Anything that cannot be simulated faithfully is refused before the simulation starts, with the
reason. Nothing degrades silently. Plugins are checked after the configuration is loaded and
before any of them does work, wherever the configuration references them: under plugins:, or
only through a reference such as a flow-control fairnessPolicyRef.
| What | Why |
|---|---|
burst-prefix-cache-producer |
It blocks a request on a sealing timer the hosting runtime does not model. |
predicted-latency-producer, latency-scorer, latency-slo-admitter, slo-headroom-tier-filter, prefix-cache-affinity-filter |
They call or consume an external latency-predictor service. |
latency-observer-producer-hub, latency-observation-scorer-hub, session-state-producer, program-aware-fairness, disaggregatedset-rollout-screener, utilization-filter, request-attribute-reporter |
Not yet audited for, or not yet moved onto, the hosting runtime. |
session-affinity-*, session-id-producer, agent-identity, outlen-bucket, header-profile-handler, header-label-affinity-scorer, endpoint-pin-screener |
The simulated workload does not send the headers they read. |
mm-embeddings-* |
The simulated media have no content of their own, so there is no embedding to cache. |
p2p-source-producer, selective-kv-policy |
Peer KV pulls are not modeled by the simulated engines. |
topology-*, endpoint-attribute-*, label-producer, dcgm-* |
The simulated pods carry no topology labels, custom attributes or GPU telemetry. |
models-data-source, models-data-extractor, models-responder |
The simulated engines do not serve /v1/models yet; the workload sends only completions. |
redis-syncer, thunder-agent, k8s-notification-source, k8s-peer-discovery, file-discovery, multicluster-* |
They need external services or another discovery source than the simulated deployment. |
More than one InferencePool; objective or rewrite manifests with --endpoint-selector |
A run has one router for one pool; in standalone mode the router does not reconcile objectives or rewrites. |
| A pool with more than one target port | Several ports make each pod several endpoints (data-parallel ranks behind one pod); BLIS simulates each rank as its own pod. |
Prefill and decode pools with a configuration that has no disagg-profile-handler |
The router would serve every request from one flat pool; pass a disaggregating configuration (for example examples/llm-d/pd.yaml). |
An encode pool on the command line (--encode-instances) |
No scenario describes the engine an encode pod runs (a blis-schemas deployment's roles are colocated, prefill and decode), as on BLIS main. Encode pools are available through the Go API (DeploymentConfig.EncodeInstances). |
A token-producer whose vLLM backend discovers endpoints (vllm.endpointDiscovery) |
It probes pods for model limits on its own schedule, outside the simulation. Use a fixed url, or the estimate backend. |
A precise-prefix-cache-producer whose tokenProcessorConfig.blockSizeTokens (default 16) is not the engines' KV block size |
Its keys would never match the engines' blocks. |
A precise-prefix-cache-producer with a Redis or cost-aware index, or non-vLLM kvEventsConfig.engineType; with LoRA adapters in the deployment |
Redis is an external service; the cost-aware index's lookups have no fixed order; the engines publish vLLM's events and their prefix caches do not key blocks by adapter. |
A core-metrics-extractor that, for some pod, reads an engine other than vLLM (by defaultEngine or the pod's engine label, llm-d.ai/engine-type by default), or a vLLM mapping naming series the engines do not publish; a metrics-data-source path other than /metrics |
The simulated engines publish vLLM's series at /metrics; anything else would read nothing. |
Also outside the model: conditional decode (Prefer: if-available), and the P/D sidecar's
MoRIIO parallel prefill-and-decode dispatch (BLIS always runs prefill, then the KV transfer,
then decode). BLIS hosts the router's Endpoint Picker only: llm-d's coordinator and its E/P/D
pipeline: (multimodal encode, then prefill, then decode, as in llm-d's coordinator
disaggregation guide) are not simulated, so E/P/D parity is the EPP's decisions alone.
From BLIS's former routing features¶
BLIS no longer has its own routing, admission or flow control. Each former feature either maps to an llm-d configuration or is dropped.
| Former BLIS feature | llm-d configuration |
|---|---|
--routing-policy weighted --routing-scorers ... |
A scheduling profile with the matching scorers and weights, and max-score-picker. |
scorer prefix-affinity |
approx-prefix-cache-producer + prefix-cache-scorer |
scorer precise-prefix-cache |
precise-prefix-cache-producer + prefix-cache-scorer (with a token-producer), indexing the engines' KV events |
scorers queue-depth, kv-utilization, active-requests, running-requests, load-aware, no-hit-lru |
queue-scorer, kv-cache-utilization-scorer, active-request-scorer, running-requests-size-scorer, load-aware-scorer, no-hit-lru-scorer |
--lora-scorer-weight |
lora-affinity-scorer in the profile |
--routing-policy round-robin, least-loaded, always-busiest; scorers load-balance, vllm-dp |
Dropped: the llm-d router has no such policy. The nearest configurations are random-picker, and queue-scorer or load-aware-scorer with max-score-picker. |
--admission-policy gaie-legacy |
The router's default admission: with the flowControl gate off, the legacy admission controller sheds sheddable requests (priority < 0) when the saturation detector reports a saturated pool. Its thresholds are utilization-detector parameters. |
--admission-policy tier-shed |
InferenceObjective priorities with the legacy admission controller, or flow-control priority bands and a usage-limit policy. |
--admission-policy token-bucket, reject-all; tenant_budgets |
Dropped. Per-tenant fairness is the fairness ID (the request's tenant) with round-robin-fairness-policy. |
--flow-control |
featureGates: [flowControl] |
--saturation-detector, --queue-depth-threshold, --kv-cache-util-threshold, --max-concurrency |
utilization-detector or concurrency-detector and their parameters |
--dispatch-order fifo, slo-deadline; --slo-targets |
fcfs-ordering-policy, slo-deadline-ordering-policy |
--fairness-policy |
global-strict-fairness-policy, round-robin-fairness-policy |
--max-gateway-queue-depth, --per-band-capacity |
flowControl.maxRequests, a priority band's capacity |
--usage-limit-threshold |
static-usage-limit-policy |
--request-ttl |
flowControl.defaultRequestTTL |
--queue-shedding, --dispatch-tick-interval, --in-flight-eviction |
Dropped: BLIS-only behaviors. llm-d's dispatch tick is fixed at 1 ms; its in-flight eviction is flowControl.enableEviction (admission). |
--pd-decider always, prefix-threshold; --pd-prefix-threshold |
disagg-profile-handler with always-disagg-pd-decider or prefix-based-pd-decider (nonCachedTokens) |
--prefill-routing-scorers, --decode-routing-scorers |
The prefill and decode scheduling profiles |
--encode-decider |
The disaggregation handler's encode decider (always-disagg-multimodal-decider for multimodal) |
--admission-latency, --routing-latency |
--gateway-latency |
--snapshot-refresh-interval |
The EPP flag --refresh-metrics-interval: staleness now comes from the scrape itself |
--cache-signal-delay |
--kv-event-delay: a fixed delay from an engine publishing its KV events to the router ingesting them (the old flag refreshed a snapshot of every cache at most every 50 ms) |
examples/epp-*.yaml |
The llm-d configurations themselves (deploy/config/ in llm-d-router) |
Further reading¶
- Cluster Simulation — the cluster pipeline around the router.
- Architecture — how the router is embedded.
- The llm-d router fork — how the fork hosts the router and how it is kept current.
- Invariants — INV-1, INV-6, INV-7, INV-9 and INV-13 as they apply to the router.