GPUStack Operator

Model Deployment Routing

A managed router selects among the HTTP endpoints of serving replicas. Every routing choice measured here ran on a server role; on a prefill/decode (PD) pair, where each half is chosen separately, none has been run.

See Prefill and Decode for the external data parallelism (External DP) boundary within PD roles.

Contents

Which Pods receive traffic

The engine’s load balancing (LB) mode determines the candidate endpoints. The Router policy chooses among those candidates. round_robin does not change the endpoint selector.

Serving configuration HTTP candidates
Internal Server group Member 0 of each replica.
Qualified External Server group Every qualified member; each member serves one data parallelism (DP) rank.
Prefill or Decode group Member 0 of each replica, including groups with size > 1.
Encode group Excluded from answering candidates and the deployment-wide Service.
Unknown mode or unverified group Keep the existing leader restriction; do not expand discovery.

Encode Pods are never part of the deployment’s answering set. Under llm-d-router they are discovered for stage routing, and under vllm-router their Service endpoint is passed through --epd-config, while the deployment Service never targets them. All three routers (llm-d-router, vllm-router and sglang-gateway) are admitted with an Encode role for their supported engine and platform combinations. See Encode stage routing .

For an eligible External Server role, Router discovery and ordinary Services omit member-index=0. They retain the endpoint eligibility label, so an unhealthy group cannot receive new traffic. The replica’s headless Service always publishes every member for peer communication.

Known non-Internal modes follow the External selector rule. Status retains the engine’s distinct mode name. Hybrid GPU behavior remains untested. See Prefill and Decode for PD limitations.

Managed roles expose one fixed HTTP port per Pod, defaulting to 8000. All routed roles must use the same serving port. A shared custom port remains supported. Admission rejects vLLM’s MultiPort mode because it starts endpoints on additional, incremented ports. A decoder proxy’s internal backend port remains separate from its published HTTP port. See the External DP walkthrough for a minimal per-rank configuration.

Encode stage routing

How the encoder is reached depends on the engine and router. Send requests to status.endpoint in every case.

Router Engine How the encoder is reached
llm-d-router vLLM The router picks an encoder for requests with media, then the decode sidecar coordinates the transfer
vllm-router vLLM The router receives the encoder Service URL via --epd-config and drives encode, prefill and decode directly without a decode sidecar
llm-d-router SGLang The Prefill role calls the Encode role directly through --encoder-urls; the router has no encode stage
sglang-gateway SGLang The gateway routes the pair natively; the Prefill role calls the Encode role directly

With llm-d-router and vLLM, the router defines an encode scheduling profile over the Pods labelled modeldeployment.gpustack.ai/role-kind: encode. The profile applies no queue, KV cache or prefix scorers, because an encoder keeps no KV cache and tracks no tokens. A multimodal-aware decider sends a request with media through an encoder first. A text-only request goes straight to prefill and decode.

On the Ascend encode/prefill/decode (EPD) configuration (llm-d-router + vLLM-Ascend + ECExampleConnector), the encode role answers requests that carry media and stays idle for text-only traffic. The connector hands the result to Prefill through the shared volume both roles mount; the router is not in that path.

The operator keeps a vLLM encoder in the router’s candidate set on every platform. Its address-based encoder path applies only to SGLang.

The decode sidecar’s --ec-connector follows the admitted row: ec-example on Ascend and ec-nixl on NVIDIA and AMD. See Admitted matrix .

With vllm-router and vLLM (on NVIDIA and AMD), the operator passes --epd-config={"encoder_urls":["http://<md>-<encode-role>.<ns>.svc:<port>"]} to the router. The router calls the encoder Service first, forwards ec_transfer_params to the prefill worker so it pulls the embeddings over NIXL (ECCPUConnector), and strips ec_transfer_params before forwarding to decode. --epd-config is operator-owned and refused in spec.router.extraArgs.

With llm-d-router and SGLang, the router splits every request between prefill and decode, because an SGLang decode role cannot prefill. The sidecar runs without --ec-connector, since this engine uses no encoder-cache connector.

Encoders are also excluded from KV cache event discovery, because they produce no cache blocks.

Each router’s default

vllm-router and sglang-gateway route by cache_aware, and llm-d-router scores every candidate for each request. The operator renders no policy flag for the first two, so each runs its upstream default. The table describes the bundled vLLM router v0.1.15 and SGLang gateway gateway-v0.3.1; the llm-d row describes the operator’s scoring profile.

spec.router.name Default Replica selection
vllm-router cache_aware The replica whose past requests share the longest prefix with this one, when that share is above --cache-threshold (0.3); below it, the replica with the smallest record. Once the busiest replica has more than --balance-abs-threshold (64) requests over the idlest and more than --balance-rel-threshold (1.5) times as many, the least-loaded replica instead
sglang-gateway cache_aware The same algorithm, with the same three defaults
llm-d-router A fixed scoring profile The highest total of prefix-cache match (weight 3, not scored on a decode half), queue depth (2) and KV-cache utilization (2)

Both cache_aware routers log the policy once at startup, as policy: CacheAware { cache_threshold: 0.3, … }.

Prefix affinity

Under cache_aware, requests that share a long prefix and arrive one after another all go to one replica, and the others stay idle. That is the policy working, not a fault: the replica that served the prefix holds it in its prefix cache, so the next request sharing it does not recompute it.

Such a request only goes elsewhere once the load gap above opens, and traffic that sends one request at a time never has more than one in flight to open it.

Measured on two server replicas, each on its own GPU, with serial chat requests that open with the same text of about 500 characters and end in a question of their own, streaming and non-streaming in turn. No request failed:

Router Engine Requests on each replica
vllm-router vLLM 0.29.0 354 and 0
sglang-gateway SGLang 0.5.18 406 and 0
llm-d-router vLLM 0.29.0 187 and 170
llm-d-router SGLang 0.5.18 375 and 0

How any of the three spreads concurrent traffic is not measured.

Switching to round robin

Add --policy round_robin to spec.router.extraArgs on vllm-router or sglang-gateway to send each request to the next replica in turn, whatever it shares with earlier ones:

diff
 apiVersion: worker.gpustack.ai/v1
 kind: ModelDeployment
 metadata:
   name: round-robin-serving
 spec:
   router:
     name: vllm-router                    # or sglang-gateway
+    extraArgs:
+      - --policy
+      - round_robin

--policy is in neither router’s refused list , so admission passes it and the operator appends it to the command line it renders. The router then logs Starting router … | policy: RoundRobin.

Measured on the same two replicas and the same kind of traffic, with the flag set at creation: under each router, 120 requests with none failing, 60 on each replica. vllm-router counted them in vllm_router_policy_decisions_total{policy="round_robin"}, 60 per replica, and sglang-gateway in smg_worker_selection_total{policy="round_robin"}, 120 in all.

router.extraArgs is editable , so a running deployment can switch as well. The router Pod is then replaced, and the new one starts with no record of earlier prefixes. Only a flag set at creation has been run.

The value is each project’s own, and the router checks it, not admission, so a misspelled one stops the router at startup:

spec.router.name --policy values
vllm-router random, round_robin, cache_aware, power_of_two, consistent_hash, rendezvous_hash
sglang-gateway random, round_robin, cache_aware, power_of_two, prefix_hash, manual

Only round_robin has been run. The three cache_aware thresholds pass through extraArgs the same way, as do both routers’ --prefill-policy and --decode-policy for a prefill/decode pair; none of those has been run either.

No policy flag

llm-d-router cannot be switched through extraArgs. Its scorers and their weights are in the configuration document the operator renders and mounts, and --config-file, which would point the router at another one, is refused there. No field sets them either.

Serving observations

Which replicas a Router can reach is not the same question as which replicas it is serving, and the operator reports both. The first is the qualification list, and the second is an observation of the Router itself; they are published separately in status.roles[].endpoints and never collapse into one number.

The observation is a plain HTTP GET with a short timeout against each Router Pod’s own address. It deliberately does not go through the Router Service, which would load balance across the Router’s replicas and report one of them as the whole Router. What Confirmed then means is the serving-observation contract status.roles[].endpoints defines once.

A collected view informs the answer for fifteen seconds. Past that the answer becomes Unknown, so a Router that stops being reachable is reported as unobserved and never as serving whatever it served last.

The freshness bound is what keeps a stopped Router from reading as a healthy one. A view that outlived its evidence would report a serving count for a process that is gone, and the only way back would be an edit to the deployment.

Per-worker routing metrics

The deployment’s metrics snapshot does not say which replica served a request. Each router’s own /metrics does, for the series below, all seen exported in runs. A worker label is the worker’s URL, which carries its Pod address.

spec.router.name Series Meaning
vllm-router vllm_router_processed_requests_total{worker} requests sent to each worker, in both modes
vllm-router vllm_router_policy_decisions_total{policy,worker} picks each policy made, per worker
vllm-router vllm_router_pd_prefill_requests_total{worker}, vllm_router_pd_decode_requests_total{worker} in PD mode, requests sent to each prefill and each decode worker
sglang-gateway smg_worker_requests_active{worker} requests in flight to each worker at the scrape
sglang-gateway smg_worker_selection_total{worker_type,policy} picks per policy and per regular, prefill or decode worker type, not per worker
llm-d-router llm_d_epp_per_endpoint_queue_size{model_server_endpoint} requests queued at each endpoint at the scrape
llm-d-router llm_d_epp_disagg_decision_total{decision_type} requests sent through a prefill replica (prefill-decode) or straight to decode (decode-only)

Neither sglang-gateway nor llm-d-router keeps a running count per replica. Behind them, compare the engine Pods’ own counts instead, such as each Pod’s time to first token (TTFT) histogram _count.