Skip to content

Disaggregated Inference

Table of Contents


Concept

Disaggregated Inference separates the two steps of LLM token generation onto different nodes:

  1. Prefill (prompt processing): The entire input prompt is processed in a single forward pass to produce the initial KV cache and the first output token. This step is compute-bound: it requires many multiply-accumulate operations proportional to the prompt length.

  2. Decode (autoregressive generation): Starting from the KV cache produced by prefill, new tokens are generated one at a time. This step is memory-bandwidth-bound: at each step, the model reads the full KV cache from memory but performs only a small amount of computation (one token at a time).

These two steps have different hardware requirements, and running them on the same GPU forces a compromise that is suboptimal for both.


Why Separate Prefill from Decode?

graph TB
    subgraph "Compute-Bound (Prefill)"
        PF_A["Dense matmul over<br/>full prompt length<br/>(e.g., 2048 tokens)"]
        PF_B["Benefits from:<br/>high FLOPs, tensor cores"]
    end

    subgraph "Memory-Bandwidth-Bound (Decode)"
        DC_A["One token forward pass<br/>per step, reads full KV cache<br/>(growing with each step)"]
        DC_B["Benefits from:<br/>high memory bandwidth,<br/>large VRAM"]
    end

    PF_A --- PF_B
    DC_A --- DC_B
Property Prefill Decode
Computation per step O(sequence_length²) O(1)
Memory reads per step Proportional to layers × d_model Proportional to layers × sequence_length
Arithmetic intensity High (compute-bound) Low (memory-bound)
Ideal hardware High FLOPs / tensor-core density High memory bandwidth / capacity
Batch efficiency Batching multiple prompts is efficient Batching multiple sequences helps amortize KV reads

By placing prefill on compute-dense hardware and decode on memory-dense hardware, both steps operate at their optimal point simultaneously, and the two pools can be scaled independently.

Operational advantages:

  • Long prompts on prefill nodes do not delay ongoing decode sessions.
  • Decode nodes are never blocked by a slow prefill of a long document.
  • Prefill node capacity and decode node capacity can be scaled independently in response to the actual workload mix.

Full Disaggregated Flow

sequenceDiagram
    participant C as Client
    participant R as Router
    participant PF as Prefill Node
    participant DC as Decode Node

    C->>R: POST /v1/chat/completions
    R->>PF: Route request to prefill node
    Note over PF: Run prefill forward pass<br/>over prompt tokens<br/>Build KV cache for all layers
    PF->>PF: Sample first token
    PF->>R: Return first token + signal handoff
    R->>C: Stream first token (SSE)
    PF->>DC: Transfer KV cache (serialized + optionally compressed)
    DC->>DC: Admit sequence to decode batch
    loop Token generation
        DC->>DC: Decode step (one token)
        DC->>R: Stream token
        R->>C: Forward token (SSE)
    end
    DC->>R: Finish reason (stop / length)
    R->>C: data: [DONE]

The client API is identical to single-node serving.


Prefill Scheduler

src/distributed/disaggregated/prefill_scheduler.rs

The prefill node runs a dedicated scheduler that:

  • Maintains a priority queue of pending prefill requests, ordered by prompt length (shorter-first) for better throughput.
  • Bounds concurrent prefill operations by a configurable max_concurrent_prefills to prevent OOM under load.
  • Monitors memory utilization and rejects new requests when a memory_threshold (default: configurable) is exceeded.
  • Optionally runs chunked prefill: long prompts are processed in chunks of chunk_size_tokens, allowing progressive KV cache transfer to the decode node to start before the full prompt is processed.
  • Serializes the KV cache after prefill completes and hands it off to the decode node via the TransferConfig.

Key configuration fields:

Field Description
max_concurrent_prefills Maximum simultaneous prefill operations
transfer_timeout Timeout for the full handoff (serialize + transfer + ack)
memory_threshold Memory utilization ratio above which new requests are rejected (0.0-1.0)
chunked_prefill_enabled Enable chunked prefill for long sequences
chunk_size_tokens Tokens per chunk when chunked prefill is enabled
max_handoff_retries Maximum retry attempts for failed handoffs

Decode Scheduler

src/distributed/disaggregated/decode_scheduler.rs

The decode node runs a scheduler that:

  • Receives KV caches from prefill nodes asynchronously (non-blocking to ongoing decode steps).
  • Admits sequences to the active decode batch when memory permits.
  • Runs autoregressive generation: one decode step per active sequence per iteration.
  • Emits completion events when sequences reach EOS, max_tokens, or an error, so the request router can deliver the final response.
  • Applies backpressure to prefill nodes when the ingestion queue is full.

Key configuration fields:

Field Default Description
max_batch_size 32 Maximum simultaneous decode sequences
max_sequences 128 Maximum total sequences (active + queued)
memory_threshold 0.85 Reject new sequences above this memory utilization
ingestion_queue_size 64 Maximum pending KV cache ingestion requests

Request Router

src/distributed/disaggregated/request_router.rs

The router is the entry point for all incoming API requests in disaggregated mode. Both POST /v1/chat/completions and POST /v1/completions are routed through the same prefill/decode handoff; the router:

  1. Selects the best available prefill node using a configurable load-balancing strategy.
  2. Selects the best available decode node and ships its address in the decode_target field of the prefill request frame, so the prefill node hands the KV cache off to the router-chosen decode node rather than only its own static --decode-peers configuration.
  3. Tracks the full request lifecycle across the prefill → decode handoff.
  4. Applies backpressure (queue, reject, or HTTP 503) when nodes are at capacity.
  5. On node failure, automatically re-routes affected requests to healthy peers.

Load Balancing Strategies

Strategy Description
RoundRobin Rotate requests evenly across available nodes in stable order
LeastLoaded Route to the node with the fewest active requests
MemoryAware Route to the node with the most free memory (avoids OOM)
PromptLengthAware Route long prompts to nodes with more compute headroom; short prompts to nodes that can finish quickly

Prefill-node and decode-node selection both use round-robin today, since the router has no live per-node load telemetry beyond its own dispatch counters.

Backpressure Actions

When prefill or decode nodes are at capacity, the router can take one of:

  • Queue: Hold the request in the router's pending queue.
  • Reject: Return HTTP 503 immediately.

The choice depends on the BackpressureConfig and the current load level (Low, Moderate, High, Critical). Every request passes admission control before dispatch, so a node at capacity returns HTTP 503 rather than being sent a request it cannot serve.

Health Monitoring and Failover

A background health monitor TCP-probes every registered peer's serving address on an interval, so a dead decode node (which the router never contacts directly on the request path) is detected and skipped even though the router only learns about prefill failures from a failed send. A transport error while delivering a prefill frame marks that node Unreachable, re-routes its in-flight requests to a healthy peer, and excludes it from further routing decisions until the health monitor observes it respond again. A failed first-token send or KV handoff fails only the one affected request; the prefill and decode workers keep serving the rest of their load. When admission finds no prefill node available while some are marked Unreachable, it re-probes those nodes first (single-flight, at most once per 250 ms router-wide, 500 ms connect timeout) and admits the request if one accepts, so a peer that came up after the last monitor cycle is not refused with HTTP 503. A full queue never triggers a probe.

Observability: GET /router/stats

GET /router/stats is mounted on the router's own client-facing HTTP port, alongside /v1/chat/completions. It reports per-node prefill/decode dispatch counts, node health, and a router metrics snapshot. By default the response redacts each peer's raw host:port and reports only stable router-assigned node ids (prefill-<i> / decode-<i>), enough to read the load spread and confirm a failed node is marked unreachable without disclosing the cluster topology to a client that can reach the inference port. Set MLXCEL_ROUTER_STATS_VERBOSE=1 on a trusted-segment deployment to opt back into the full address view.

As defense-in-depth on the KV handoff itself, a prefill node can validate an incoming decode_target against MLXCEL_DECODE_ALLOWLIST (a comma-separated host:port list covering every router-selectable decode node) before connecting, so a forged frame from a breached segment cannot redirect a KV cache handoff off-cluster. Leave it unset only when the router's client port and the internal prefill/decode ports already sit on the same trusted network segment.


Stream Bridge

src/distributed/disaggregated/stream_bridge.rs

The stream bridge ensures that the SSE token stream delivered to the client is seamless across the prefill-to-decode node boundary.

flowchart LR
    SB["**StreamBridge**\n\nPrefill: first token\n\nDecode: generated tokens"]
    SB -- "SSE stream" --> Client

The bridge tracks four stream states:

State Description
WaitingForPrefill Waiting for the prefill node to produce the first token
HandoffToDecode First token received; waiting for decode node to start
Decoding Decode node is actively generating and streaming tokens
Complete DONE marker has been sent or an error terminated the stream

The bridge guarantees: - No gap in the token stream between the first token (from prefill) and subsequent tokens (from decode). - No duplicate tokens in the handoff. - Proper DONE signaling after the decode node finishes.

In the role-based serving deployment, the decode node sends each generated token as its own result frame, so the client sees per-token SSE deltas across the handoff rather than a buffered tail. The router does not rely on the remote stream for termination: it enforces the request's token budget itself and closes the stream when that budget is reached.

Router output passes through the same StreamFilter as single-node serving. Model-internal markers (<think>, tool-call delimiters, stray turn tokens) never reach the client, and thinking content is delivered as delta.reasoning_content in streaming responses and reasoning_content in non-streaming ones, matching single-node behavior byte for byte. Tool-call assembly into tool_calls is not yet supported on the router path; the filter suppresses tool-call delimiter markup from the stream.


KV Cache Transfer

src/distributed/kv_cache_transfer/

The KV cache produced by the prefill node must be transferred to the decode node before decoding can start. This is the primary latency cost of disaggregation.

mlxcel supports multiple transfer strategies:

graph LR
    PFN[Prefill Node] -->|transfer| DCN[Decode Node]

    subgraph "Transfer Strategies"
        FS[Full: send entire<br/>cache in one message]
        ST[Streamed: layer-by-layer<br/>as each completes]
        LP[LayerParallel: multiple layers<br/>concurrently]
    end
Strategy Description Best For
Full Serialize and send the entire KV cache as a single message Small caches, simple deployment
Streamed Send cache layers one by one as each layer's prefill completes, overlapping compute and transfer Reducing first-token latency on slower links
LayerParallel Send multiple layers concurrently over parallel connections High-bandwidth links with available parallelism

Adaptive Strategy Selection

AdaptiveSelector (src/distributed/kv_cache_transfer/adaptive.rs) measures runtime bandwidth between nodes using BandwidthEstimator and automatically selects the optimal transfer configuration. This avoids manual tuning of the transfer strategy per deployment.

Compression

Optional LZ4 compression is applied to KV cache data before transmission. Compression reduces transfer time on bandwidth-constrained links at the cost of additional CPU time on both ends.

Quantization

On-the-fly quantization of KV cache tensors is available: (src/distributed/kv_cache_transfer/quantized.rs):

  • float16 → int8: 2× compression ratio.
  • float16 → int4: 4× compression ratio (with accuracy trade-off).

Quantization reduces transfer bandwidth requirements at the cost of slight accuracy degradation in decode-step attention. For most models, int8 quantization has negligible quality impact.

KV Cache Serialization

src/distributed/kv_cache_serde/ handles serialization and deserialization of SerializableCacheState (the full KV cache) and SerializableSamplingState (the current sampling state to resume generation on the decode node).


Serving Modes

Defined in src/distributed/disaggregated/serving.rs:

Mode CLI role Description
Hybrid (default) --node-role hybrid Single-node operation. Zero routing, zero transfer, zero serialization overhead. Code path is identical to non-distributed serving.
PrefillOnly --node-role prefill Listens for tokenized request frames at --serving-bind, runs prefill, emits the first token, and hands the KV cache off to a decode peer. Does not generate tokens beyond the first.
DecodeOnly --node-role decode Receives KV caches from prefill peers at --serving-bind and runs autoregressive decoding. Does not process raw prompts.
Router --node-role router Model-free HTTP front. Loads only the tokenizer and chat template (no weights), tokenizes incoming requests, forwards them to a prefill node, and merges the prefill first token with the decode continuation into one client response.

Hybrid mode is zero-overhead: the HybridModeGuard wrapper detects when the mode is Hybrid and returns immediately (or delegates to the local model provider) without touching any distributed data structures. It is a direct passthrough, not "disaggregated with localhost transfer."


Configuration

CLI Flag Description
--node-role <hybrid\|prefill\|decode\|router> Serving role. hybrid is the single-node default.
--serving-bind <ADDR> This node's own bind address for the serving-role transport. The prefill node listens here for request frames, the decode node for KV handoffs, and the router for result frames.
--prefill-peers <ADDR,...> Prefill-node serving addresses the router forwards tokenized requests to.
--decode-peers <ADDR,...> Decode-node serving addresses a prefill node hands KV caches off to. The router also registers them for result demultiplexing.
--cluster-secret-file <PATH> Pre-shared cluster secret. prefill, decode, and router refuse to start without one (also read from MLXCEL_CLUSTER_SECRET_FILE or MLXCEL_CLUSTER_SECRET); hybrid needs none.
--node-id ID Unique node identifier in the cluster
--peers addr1,addr2 Static peer addresses for cluster-level discovery
--distributed-config PATH TOML cluster configuration file for distributed inference

Cluster Secret and Transport Protection

The prefill, decode, and router processes share one cluster secret, generated once with openssl rand -hex 32 > cluster.secret (at least 16 bytes) and copied to every host. Every serving-role connection runs the cluster handshake (protocol version 5): both ends prove possession of the secret with HMAC-SHA256 over the transcript, the accept or at-capacity verdict is authenticated, and every frame afterwards is encrypted and authenticated with AES-256-GCM under keys derived for that connection. A process built before v0.8.0 cannot join a v0.8.0 cluster, so upgrade every node together. See Pipeline Parallelism for the secret precedence and frame details.

Example: router, prefill, and decode on three processes

# Decode node: receives KV handoffs at --serving-bind.
mlxcel-server --model ./models/qwen3-0.6b-4bit \
  --node-role decode \
  --cluster-secret-file ./cluster.secret \
  --serving-bind 192.168.1.21:9100

# Prefill node: listens for request frames and hands KV off to the decode node.
mlxcel-server --model ./models/qwen3-0.6b-4bit \
  --node-role prefill \
  --cluster-secret-file ./cluster.secret \
  --serving-bind 192.168.1.20:9100 \
  --decode-peers 192.168.1.21:9100

# Router: model-free HTTP front (tokenizer + chat template only).
mlxcel-server --model ./models/qwen3-0.6b-4bit \
  --port 8080 \
  --node-role router \
  --cluster-secret-file ./cluster.secret \
  --serving-bind 192.168.1.10:9100 \
  --prefill-peers 192.168.1.20:9100 \
  --decode-peers 192.168.1.21:9100 \
  --no-warmup

Clients point at the router's HTTP port and use the regular OpenAI-compatible API; the merged response is byte-identical to single-node serving of the same checkpoint. Every process passes the same --model path. The router uses it only to load the tokenizer and chat template, so it needs the checkpoint files on disk but allocates no weight memory; --no-warmup skips the model warmup pass it cannot run.

Two operational bounds apply on the handoff path. Prompts longer than the prefill chunk size are chunk-prefilled to completion before the KV handoff, so long prompts work the same as on a single node. The network intake rejects prompts above an admission cap of 1,048,576 tokens.