Distributed LLM Inference With LeaderWorkerSet: v1.0 Mechanics, the DeepSeek-R1 Pattern, and the Toil

Sources

Two 1.0 releases landed in the last three weeks that quietly close out a four-year argument in the inference-infrastructure world. LeaderWorkerSet v1.0.0 shipped on October 16, 2026, and llm-d v1.0.0 followed on October 20. The argument was this: Kubernetes schedules pods, but distributed inference needs groups — gangs of pods that are placed, started, restarted, upgraded, and drained as one unit, because the model sharded across them is one machine. Until now, that group was assembled outside the API: custom operators, Ray clusters bolted onto StatefulSets, init containers waiting on leader DNS, and half a page of YAML origami per model. LWS v1.0 makes the group itself the API object — and its parent project now hosts a second API, DisaggregatedSet, that does the same for prefill/decode-disaggregated serving.

The timing matters. The open-weight class has moved to models that physically cannot run on one node: DeepSeek-R1-class MoE models consume 500 GB+ of memory for weights alone, as the llm-d wide-EP documentation puts it — and the platform default for serving them is no longer “one Deployment, hope for the best.” This guide is the mechanics-first companion to our earlier pieces on serving topologies and the inference gateway pattern: those covered what topologies make sense and how requests route to them. This one covers the layer underneath — the Kubernetes API that stamps those topologies onto a cluster, verified against the LWS v1.0.0 release, the api/leaderworkerset/v1 and api/disaggregatedset/v1 Go types at main, and the llm-d v1.0.0 tree. No benchmark claims here — no GPU cluster was harmed in the making of this article; every number is computed from public specifications and labeled as such.

The Unit Mismatch: Kubernetes Speaks Pods, Inference Speaks Groups

Start with the failure mode that motivates the whole API. Multi-host tensor parallelism wires one model across N nodes with NCCL all-reduce collectives over the interconnect. Every GPU in the group participates in every layer. The group has exactly three useful states: all pods running, no pods running, and broken. There is no meaningful “partially deployed” state — 15 of 16 GPUs running is not 94% of a serving capacity, it is zero serving capacity plus 15 GPUs of stranded electricity and a hung collective.

Stock Kubernetes has no object with that shape:

So teams built the group by hand: a StatefulSet for workers with hostname-derived ranks, an init container that polls the leader’s DNS until it resolves, a headless Service with exactly the right selector, a liveness probe tuned to tolerate collective waits, and a custom controller or CronJob to restart the whole stack when one pod wedged. Every shop had its own variant. None of them handled rolling updates at the group level, because none of them had a revision that covered the group. LWS is that revision-bearing object.

LeaderWorkerSet Mechanics: The Group as the API Object

A LeaderWorkerSet (short name lws, group leaderworkerset.x-k8s.io/v1) creates replicas copies of a pod group. Each group is one leader pod plus size - 1 worker pods. The controller materializes the group with plain Kubernetes objects — no new pod-like primitive — which is the design decision that makes the whole thing debuggable with the tools you already have:

LeaderWorkerSet "deepseek-r1" (replicas=2, size=2)
│
├── StatefulSet deepseek-r1                (leaders; one pod per group, ordinal 0..N-1)
│     ├── pod deepseek-r1-0        leaderworkerset.sigs.k8s.io/group-index=0
│     │                           leaderworkerset.sigs.k8s.io/worker-index=0
│     │                           leaderworkerset.sigs.k8s.io/role=leader
│     └── pod deepseek-r1-1        group-index=1, worker-index=0, role=leader
│
├── StatefulSet deepseek-r1-0              (workers of group 0; named for its leader pod)
│     └── pod deepseek-r1-0-1     group-index=0, worker-index=1, role=worker
│                 (annotation leaderworkerset.sigs.k8s.io/leader-name=deepseek-r1-0)
│
├── StatefulSet deepseek-r1-1              (workers of group 1)
│     └── pod deepseek-r1-1-1
│
├── Headless Service deepseek-r1           (Shared subdomain policy)
│     (pod DNS: deepseek-r1-0.deepseek-r1, deepseek-r1-0-1.deepseek-r1, ...)
│
├── ControllerRevisions                   (group-level template history -> rolling update)
│
└── Workload + PodGroup per replica        (only if spec.scheduling is set; alpha)

The naming and identity rules come straight from the API source. The leader pod is named <lws>-<groupIndex> and is the name of the group; workers are <lws>-<groupIndex>-<workerIndex> with the leader always taking worker index 0. Every pod carries the labels leaderworkerset.sigs.k8s.io/name, group-index, worker-index, and role, plus a group-key hash that uniquely identifies the group instance. The admission logic injects three environment variables into every container — LWS_LEADER_ADDRESS (the leader’s DNS address), LWS_GROUP_SIZE, and LWS_WORKER_INDEX — which is the entire integration contract a distributed runtime needs. vLLM’s multi-node startup consumes exactly these three; no service discovery, no configmap, no sidecar.

Startup order and failure handling are where LWS stops being a convenience and becomes a semantics upgrade:

The restart policies deserve the full table, because picking the wrong one is the difference between a self-healing fleet and a fleet that re-pays model-load costs every time a single GPU hiccups:

restartPolicyPod or container restarts …Use when
RecreateGroupOnPodRestart (v1 default)recreate the entire group — triggered by any pod recreation or any container/init-container restartDistributed state is collective: one restarted rank invalidates all ranks. Multi-host TP/PP serving where every rank holds live collective buffers.
RecreateGroupAfterStartrecreate the entire group, but only once all pods have started (none in Pending); a group with an unschedulable pod is reported GroupUnschedulable instead of being torn downGroups waiting in a queue for capacity (Kueue) — stops a half-scheduled gang from being churned, and records a warning event + Progressing reason when the leader cannot be placed.
Default (deprecated)restart only the failed pod, StatefulSet-styleCompatibility only. Being removed; migrate to None.
Nonerestart only the failed pod, StatefulSet-styleGroups whose runtime tolerates a rank rejoining a live collective — rare for TP serving, plausible for data-parallel replicas where each member is actually independent.

Now the bounded-recovery machinery, because an unbounded RecreateGroupOnPodRestart loop is a production incident waiting to happen — one wedged node produces an infinite teardown-and-rebuild cycle that re-pays weight download, model load, and NCCL initialization on every iteration, forever. v1.0.0 adds maxGroupRestarts to cap that loop:

spec.leaderWorkerTemplate:
  restartPolicy: RecreateGroupOnPodRestart
  maxGroupRestarts: 3            # opt-in; unset (nil) = unlimited recreation
  restartBackoff:                # opt-in; exponential, defaults shown
    baseSeconds: 10              # a group must have existed >= min(base*2^count, cap)
    capSeconds: 300              # before it can be recreated; measured from
                                 # leader pod CREATION time, not failure time -
                                 # a group that ran longer than the delay
                                 # recovers immediately on failure

Budget exhausted =>
  1. controller terminates the group
  2. terminal Pod API objects are RETAINED (K8s >= 1.27 required) and carry the
     finalizer leaderworkerset.sigs.k8s.io/group-restart-budget-cleanup
     so cleanup finalizers can still land on them
  3. automatic recreation STOPS
  4. LWS status: Degraded=True, reason ReplicaRestartBudgetExceeded
     (annotations on the LWS: group-restart-counts, group-restart-charged-leaders;
      on the leader pod: group-restart-budget-exhausted)

Manual recovery: annotate the retained leader pod with
  leaderworkerset.sigs.k8s.io/recover=true
=> budget for that revision/group resets, recovery resumes

Three operational details in that block are easy to miss and expensive to learn from incidents. First, the restart budget is charged per revision per group and tracked in annotations on the LWS object itself (group-restart-counts keyed <revision>/<groupIndex>), so an operator can audit how close every group is to its cap with a single kubectl annotate --list. Second, under Hash group identity (below), admitting a replacement leader transfers the dead group’s restart count to the new group key — the budget survives group replacement, which is the only sane behavior for an autoscaled fleet. Third, changing maxGroupRestarts on an already-exhausted group does not resume it; the recover=true annotation is the only resume path. That is deliberate: budget exhaustion is treated as a signal for a human, not a knob for a reconciler to keep turning.

Rollouts operate at group granularity too. spec.rolloutStrategy is RollingUpdate-only for now, with a rollingUpdateConfiguration of maxUnavailable: 1, maxSurge: 0, partition: 0 by default — groups are upgraded one whole group at a time, and the later group does not start until the earlier one is ready. The partition field supports ordinal-partitioned canaries (update groups partition..N-1 first), and the LWS docs note the pattern is explicitly useful for xPyD-style deployments where you stage prefill before decode. The revision hash lives in leaderworkerset.sigs.k8s.io/template-revision-hash on every managed object, and the Available, Progressing, UpdateInProgress, and Degraded conditions roll up group state. HPA integration comes from a scale subresource wired to .spec.replicas/.status.replicas with status.hpaPodSelector selecting leader pods — which has a consequence we will dissect in the autoscaling section: whatever metric you autoscale on must be exposed by (or aggregated onto) the leader pod.

The DeepSeek-R1 Pattern: Multi-Host Tensor Parallelism Without Ray

Run the memory arithmetic that forces the multi-host decision, for the model the dispatch queue named and the llm-d docs call out by name. DeepSeek-R1 is a 671B-parameter MoE with Multi-head Latent Attention; its FP8 weights are 671 GB (1 byte per parameter), and its MLA KV cache runs ~576 bytes per token per layer across 61 layers — 34.3 KiB per token, all layers in. The numbers below are computed, not benchmarked (Decimal script, GB = 109, policy: 80% of VRAM usable, 70% of post-weights spare reserved for KV):

Weights, FP8:                    671 GB
8xH100 node, total VRAM:         640 GB   -> weights DO NOT FIT on one node
16xH100 (2 nodes), total VRAM: 1,280 GB
  spare after weights:           609 GB
  KV budget (70% of spare):      426 GB
  KV token capacity @ 34.3 KiB/token (MLA, 61 layers):
                                 ~12.1M tokens of KV across the replica

Interpretation: one 16-GPU replica holds ~12M tokens of KV - i.e. ~370
concurrent 32k-context requests of pure KV headroom, before activation
memory and NCCL buffers eat their share. On an 8-GPU node the same model
does not merely serve badly - it cannot be instantiated at all.

That is why the “DeepSeek-R1 pattern” for LWS is the shape every platform team converges on: size: 2 (one leader pod + one worker pod), 8 GPUs per pod, tensor parallel 8 within each node, pipeline parallel 2 across nodes — the same topology the upstream LWS example ships for Llama-3.1-405B, retargeted. And the key v1.0-era detail: no Ray. The upstream example’s header comment says it outright — “basic multi-node inference with vLLM (GPU), no Ray” — because LWS’s injected environment replaces the cluster-bootstrapping layer Ray used to provide. Here is the full manifest, adapted from that example with the model and pinned image swapped to what we verified current at time of writing (vLLM stable v0.32.0, October 18; the upstream example pins v0.28.0 — bump deliberately, not accidentally):

apiVersion: leaderworkerset.x-k8s.io/v1
kind: LeaderWorkerSet
metadata:
  name: deepseek-r1
spec:
  replicas: 2                      # two independent 16-GPU serving groups
  rolloutStrategy:
    type: RollingUpdate
    rollingUpdateConfiguration:
      maxUnavailable: 1            # one whole group down at a time, max
      maxSurge: 0                  # no spare capacity needed for rollouts
  leaderWorkerTemplate:
    size: 2                        # 1 leader + 1 worker pod = 2 nodes/group
    restartPolicy: RecreateGroupOnPodRestart   # one dead rank => rebuild group
    maxGroupRestarts: 3            # stop the rebuild loop; page a human
    restartBackoff:
      baseSeconds: 30
      capSeconds: 600
    leaderTemplate:
      metadata:
        labels:
          role: leader             # the Service below selects exactly this
      spec:
        containers:
          - name: vllm-leader
            image: vllm/vllm-openai:v0.32.0
            env:
              - name: HF_TOKEN
                valueFrom:
                  secretKeyRef: { name: llm-d-hf-token, key: HF_TOKEN }
            command:
              - vllm
              - serve
              - deepseek-ai/DeepSeek-R1
              - --port
              - "8080"
              - --tensor-parallel-size
              - "8"               # TP within the node (NVLink domain)
              - --pipeline-parallel-size
              - "2"               # PP across the two nodes (RDMA domain)
              - --nnodes
              - $(LWS_GROUP_SIZE)  # injected by LWS: 2
              - --node-rank
              - $(LWS_WORKER_INDEX) # 0 on the leader
              - --master-addr
              - $(LWS_LEADER_ADDRESS) # headless-service DNS of the leader pod
            resources:
              limits:
                nvidia.com/gpu: "8"
                memory: 1124Gi
                ephemeral-storage: 800Gi   # weight-download scratch
              requests:
                ephemeral-storage: 800Gi
                cpu: 125
            ports:
              - containerPort: 8080
            readinessProbe:
              tcpSocket: { port: 8080 }
              initialDelaySeconds: 15
              periodSeconds: 10
            volumeMounts:
              - { mountPath: /dev/shm, name: dshm }   # NCCL/transport shm
        volumes:
          - name: dshm
            emptyDir: { medium: Memory, sizeLimit: 15Gi }
    workerTemplate:
      spec:
        containers:
          - name: vllm-worker
            image: vllm/vllm-openai:v0.32.0
            env:
              - name: HF_TOKEN
                valueFrom:
                  secretKeyRef: { name: llm-d-hf-token, key: HF_TOKEN }
            command:
              - vllm
              - serve
              - deepseek-ai/DeepSeek-R1
              - --port
              - "8080"
              - --tensor-parallel-size
              - "8"
              - --pipeline-parallel-size
              - "2"
              - --nnodes
              - $(LWS_GROUP_SIZE)
              - --node-rank
              - $(LWS_WORKER_INDEX)   # 1 on the worker
              - --master-addr
              - $(LWS_LEADER_ADDRESS)
              - --headless           # worker: join, don't serve the API
            resources:
              limits:
                nvidia.com/gpu: "8"
                memory: 1124Gi
                ephemeral-storage: 800Gi
              requests:
                ephemeral-storage: 800Gi
                cpu: 125
            volumeMounts:
              - { mountPath: /dev/shm, name: dshm }
        volumes:
          - name: dshm
            emptyDir: { medium: Memory, sizeLimit: 15Gi }
---
apiVersion: v1
kind: Service
metadata:
  name: deepseek-r1-leader
spec:
  ports:
    - name: http
      port: 8080
      protocol: TCP
      targetPort: 8080
  selector:
    leaderworkerset.sigs.k8s.io/name: deepseek-r1
    role: leader            # only leaders run the OpenAI API server
  type: ClusterIP

Walk the wiring, because each line encodes a decision:

For the MoE-shaped variant of the same pattern — which the llm-d docs recommend specifically for MLA models like R1 — see the subgroups section: data-parallel attention plus expert-parallel MLPs avoids replicating the KV cache across TP ranks, which is the hidden cost of the plain-TP layout above.

Scheduling the Gang: Exclusive Placement, Kueue TAS, and Native PodGroups

A 16-GPU group is only as fast as its slowest interconnect link, and only as schedulable as its hardest-to-place member. LWS attacks placement from three directions — one stable, one via Kueue, one brand-new and alpha.

Exclusive placement (stable). Annotate the LWS (or set the new spec.placementPolicy field, which replaces the annotations and rejects conflicting values) with a topology domain, and LWS injects pod affinity/anti-affinity so that each group’s pods land inside one domain and no other group’s pods share it — a 1:1 group-to-domain mapping:

# Legacy annotation form (still used by the upstream examples):
metadata:
  annotations:
    leaderworkerset.sigs.k8s.io/exclusive-topology: cloud.google.com/gke-nodepool

# v1 field form - changing the effective placement creates a new revision:
spec:
  placementPolicy:
    type: ExclusiveTopology
    topology: topology.kubernetes.io/zone   # any node-label key: zone, rack,
                                             # block, or nvidia.com/gpu.clique
  # subGroup placement is set alongside spec.leaderWorkerTemplate.subGroupPolicy

Kueue integration (stable, and quota-aware). For capacity management, LWS workloads flow through Kueue with two annotations on the leader and worker templates that bind the whole group into one admission unit — the upstream vllm-kueue.yaml example wires it exactly like this:

apiVersion: leaderworkerset.x-k8s.io/v1
kind: LeaderWorkerSet
metadata:
  name: vllm
  labels:
    kueue.x-k8s.io/queue-name: default      # LocalQueue for quota admission
spec:
  replicas: 1
  leaderWorkerTemplate:
    size: 2
    restartPolicy: RecreateGroupAfterStart   # see the restart table: a queued,
                                             # partially-placed gang must not
                                             # be churned by the restart policy
    leaderTemplate:
      metadata:
        annotations:
          kueue.x-k8s.io/podset-group-name: "vllm-multi-host"
          kueue.x-k8s.io/podset-required-topology: "cloud.google.com/gce-topology-block"
      spec: ...
    workerTemplate:
      metadata:
        annotations:                          # BOTH templates, same values
          kueue.x-k8s.io/podset-group-name: "vllm-multi-host"
          kueue.x-k8s.io/podset-required-topology: "cloud.google.com/gce-topology-block"
      spec: ...

The podset-required-topology value makes Kueue’s Topology-Aware Scheduling place the whole group inside one topology block, all-or-nothing. Two hard operational edges to plan around, both verified in our Kueue 0.20 upgrade guide: while a LWS is Kueue-managed, leaderWorkerTemplate.size is immutable (the LWSImmutableGroupSize beta gate fixed a quota bypass where group size could be raised past reserved quota) — resharding a model across more nodes means delete-and-recreate, not in-place edit; and spec.replicas (the group count) stays mutable, so horizontal scale-out remains live.

Native gang scheduling (alpha, and honest about it). v1.0.0 ships the first native integration with the Kubernetes Workload-Aware Scheduling APIs: LWS owns the Workload and runtime PodGroup objects, sets each member pod’s spec.schedulingGroup, and lets the kube-scheduler do all-or-nothing admission — no second scheduler, no Kueue required. It is opt-in with spec.scheduling, and the empty block is the shortest useful opt-in:

# Replica level (the default): one PodGroup per replica, gang.minCount = size
spec:
  scheduling: {}                  # leader + its workers admitted together;
                                 # different replicas remain independent

# Role level: separate PodGroups for leader and workers within each replica
spec:
  scheduling:
    replica:
      leader: {}                  # setting either role selects this level
      worker: {}

# Whole-LWS level: ONE PodGroup for the entire LeaderWorkerSet
spec:
  scheduling:
    schedulingPolicy:
      gang: {}                    # replicas * size pods across all replicas;
                                 # during a rollout, gang.minCount counts
                                 # not-yet-replaced replicas at their OLD size

The alpha restrictions are worth more than the feature summary, because they define whether you can use it at all: spec.scheduling must be set at creation and the level is immutable; startupPolicy: LeaderReady is rejected (workers are not created until the leader is ready, so the gang could never form — a validation rule that saves you a deadlock); it cannot be combined with the exclusive-topology annotation in this alpha; leader and workers must share a priorityClassName; and schedulingPolicy/schedulingConstraints/disruptionMode/resourceClaims are immutable after creation (only gang.minCount follows replicas and size). One subtlety from the docs saves a debugging session during LWS upgrades: while the webhook and controller briefly run different LWS versions mid-rollout, a leader admitted by the new webhook can land in a different PodGroup than the workers the old controller creates — LWS detects this and recreates the replica (deleting its leader pod) as long as no worker is scheduled yet, recording a RecreateGroup event. If you see that event storm during a controller upgrade, it is the system healing itself, not a bug in your workload.

Subgroups: Hierarchical Collectives for Expert-Parallel Fleets

Between “every pod in the group” and “every pod on its own” there is a third topology that pure leader/worker APIs miss: sub-groups, where a large group is partitioned into fixed-size chunks with their own identity, their own placement domains, and their own failure isolation. LWS’s subGroupPolicy exists because real multi-node serving racks are hierarchical — NVLink inside a node, NVSwitch or IB blocks between them — and collectives want to follow that hierarchy:

spec:
  leaderWorkerTemplate:
    size: 16                        # e.g. 16 nodes = one wide-EP group
    subGroupPolicy:
      subGroupSize: 2               # 8 sub-groups of 2 pods each; IMMUTABLE,
                                   # must divide size (or size-1 with the
                                   # leader as the extra pod in group 0)
      subGroupPolicyType: LeaderWorker   # LeaderWorker | LeaderExcluded
# Pod labels injected:
#   leaderworkerset.sigs.k8s.io/subgroup-index, subgroup-key (hash),
#   annotation subgroup-size
# Placement per sub-group:
#   spec.placementPolicy.subGroup:
#     type: ExclusiveTopology
#     topology: topology.kubernetes.io/zone   # each sub-group its own zone,
#                                             # other sub-groups kept out

The two policy types differ in exactly one way: LeaderWorker includes the leader pod in the first sub-group; LeaderExcluded (only valid when size - 1 divides evenly by subGroupSize) keeps the leader out of all sub-groups — the shape you want when the leader is a coordinator that must not consume a collective slot. The llm-d docs name the canonical use: hierarchical collective communication and targeted fault isolation across multi-node groups.

This is also where the DeepSeek-R1 pattern gets its MoE-native upgrade. The wide expert-parallelism architecture serves exactly the “very large MoE models like DeepSeek-R1” case: attention layers run data-parallel (one KV copy per token — the docs are blunt that MLA’s KV cache “replicates when sharded with tensor parallelism”, which is the hidden tax of the plain-TP layout in the YAML above), while the expert MLP layers run expert-parallel so every accelerator holds a slice of the experts. The per-token routing is sparse — DeepSeek selects 8 of 256 experts — so the dispatch/combine collectives (all-to-all, on DeepEP-class kernels) move far less data than TP all-reduces and tolerate network-rack-class interconnects. vLLM’s flags for the layout are --data-parallel-size plus --enable-expert-parallel, per the llm-d vLLM integration page. The deployment consequence: one wide-EP group spans several nodes, so it is one LWS group; each DP rank can expose its own API-server port (8000–8007 for eight ranks per pod in the docs’ example), and the InferencePool lists every rank port so the router scores individual podIP:port endpoints rather than whole pods — prefix-cache affinity lands on the rank that holds the prefix, not on whichever rank the pod’s internal balancer picks.

Group Identity and Autoscaling: Why Serving Fleets Want Hash Mode

Everything above assumed groupIdentity: Ordinal (the default): leaders managed by a StatefulSet, groups named 0..N-1, scale-down always removing the highest ordinal. That is exactly the wrong behavior for an autoscaled serving fleet. HPA scales a fleet down when utilization drops; Ordinal scale-down removes the highest-numbered group regardless of health. If group 3 is wedged and group 7 is fine, scaling from 8 to 7 replicas deletes healthy group 7 and keeps the wedged group — you paid for a rebuild to shed capacity you did not want to lose. Hash identity fixes the ordering problem by changing the manager:

Ordinal (default)Hash
Leader managerStatefulSetDeployment (names limited to 43 chars; 54 for size-1 groups)
Group naming<lws>-0..N-1, stable across restartsRandom group key per group; a recreated group gets a new key — identity is ephemeral
Scale-down victimAlways the highest ordinal — health-blindUnscheduled and not-ready groups first (ReplicaSet ranks victims on group health via the group-ready readiness gate on the leader)
Replacement timingReplacement cannot start before its predecessor is gone (StatefulSet semantics)groupReplacementPolicy: PostTermination (default; scheduling gate until the old group’s pods are fully gone) or Immediate (plain ReplicaSet behavior — on a full cluster it preempts unrelated workloads)
Rejected combinations—volumeClaimTemplates (PVCs would reattach to a successor that never reuses identity); rollingUpdateConfiguration.partition (defined over ordinals)

The mechanics behind the “health-aware scale-down” row are the cleverest part of the design: each Hash-mode leader carries a leaderworkerset.sigs.k8s.io/group-ready readiness gate that the LWS pod controller clears only once the group’s worker StatefulSet is ready — so a leader is Ready only when its whole group is. The leader Deployment’s ReplicaSet then ranks scale-down victims using standard pod-health semantics, which now encode group health. And PostTermination replacement admission counts capacity honestly: gated leaders are admitted oldest-first up to G - T (gate count minus tearing-down groups), keyed off the old group’s pods being fully deleted — not the leader object, which the ReplicaSet can delete in the background seconds before its workers release capacity. The docs spell out the failure mode this avoids: counting the leader object alone lets a replacement land where its predecessor’s GPUs are still occupied. Two serving-specific consequences: treat group identity as ephemeral and never cache a leader’s DNS address across a replacement; and note that a waiting replacement appears as a SchedulingGated pod and counts in status.replicas — capacity dashboards must not read that as serving capacity.

DisaggregatedSet: Prefill/Decode as a Kubernetes API

LWS solved “one group.” Disaggregated serving — the DeepSeek production pattern where compute-bound prefill and bandwidth-bound decode run on separate, differently-shaped pools — needs two coupled groups that scale at different ratios, roll out in lockstep, and exchange KV cache. Two independent LWS objects get you two independent revision histories: a decode pool on revision A paired with a prefill pool on revision B works right up until the KV-cache transfer format between them changes, and then it fails per request. DisaggregatedSet (disaggregatedset.x-k8s.io/v1, short names dset/disaggset, KEP-766) is the parent API that fixes the coupling by construction:

DisaggregatedSet "r1-disagg" (slices=2, revision B)
│
├── slice 0
│   ├── LeaderWorkerSet r1-disagg-0-B-prefill   <- child LWS, one per role
│   │     └── prefill pods (llm-d.ai/role=prefill)
│   ├── LeaderWorkerSet r1-disagg-0-B-decode
│   │     └── decode pods  (llm-d.ai/role=decode, routing sidecar injected)
│   └── [opt-in] ComputeDomain for (slice 0, revision B)     (DRA, alpha)
│
├── slice 1
│   └── ... same topology, independent failure/restart domain
│
└── per-role status: desiredReplicas, readyReplicas, updatedReplicas,
    minAvailableReplicas; conditions Available, Progressing,
    MinimumReplicasAvailable, SubRolesAssigned

Child LWS naming: <ds>-<slice>-<revision>-<role>
Pod env injected:  DISAGGREGATEDSET_NAME, _ROLE, _SLICE, _REVISION
Labels consumed by the llm-d router: disaggregatedset.x-k8s.io/{name,role,slice,revision}

The API’s core vocabulary, from the api/disaggregatedset/v1 types: roles are the functional phases (prefill, decode — the release notes say 2–10 are supported), each with its own leaderWorkerTemplate inlined and its own replica count; slices are complete copies of the whole multi-role topology — scaling slices adds or removes full end-to-end serving units, never a lone prefill pod stranded without decode partners. Placement comes in three grades: None, ExclusiveSlice (all roles of one slice co-located in one topology domain — mandatory when KV moves between roles over NIXL RDMA — while other DisaggregatedSets may still share the domain), and ExclusiveTopology (full 1:1 domain-to-slice mapping across all DisaggregatedSets in the cluster). Role scaling is Static or External — the latter creates a DisaggregatedSetRoleScaler named <ds>-<role> that an external autoscaler (KEDA is the documented example) drives, ignoring the inline replicas, which is how a fleet survives a 10:1 prompt:completion burst without over-provisioning decode.

v1.0.0 made DisaggregatedSet genuinely production-grade, and two of its changes are upgrade hazards worth quoting precisely from the release notes. First, the rollout default flipped: DisaggregatedSet roles without a rollingUpdateConfiguration now roll out with maxSurge: 0, maxUnavailable: 1 — like LeaderWorkerSet — instead of the old maxSurge: 1, maxUnavailable: 0. Teams that relied on bringing the new revision up before draining the old must now set maxSurge: 1/maxUnavailable: 0 explicitly or their rollouts will trade capacity for safety after the upgrade. Second, a stale-object trap: v1.0.0 removes support for slice-less LeaderWorkerSets generated for DisaggregatedSets created under v0.9.x — if such a set has not rolled out under v0.10.x or v0.11.x first, install one of those, complete one template rollout, then upgrade. On the positive side of the ledger, v1.0.0 adds sub-roles (independently scalable partitions of one role with their own rollout budgets, routing labels, and Ordinal/Hash group identity — KEP-960), fractional rollout windows that advance roles proportionally to their replica counts so new batches start before earlier ones are fully Ready (PR #907), and opt-in scaling during rollouts via spec.scalingPolicy.duringRollout: AdvanceRollout (KEP-1022).

Here is the smallest honest P/D manifest — note the example’s own caveat in the upstream repo: the NIXL connector and prefill/decode wiring are deployment-specific; this is a starting point, not turnkey:

apiVersion: disaggregatedset.x-k8s.io/v1
kind: DisaggregatedSet
metadata:
  name: vllm-disagg
spec:
  slices: 1
  placementPolicy:
    type: ExclusiveSlice            # KV crosses roles over RDMA: co-locate them
    topology: topology.kubernetes.io/zone
  roles:
    - name: prefill
      spec:
        leaderWorkerTemplate:
          size: 1
          workerTemplate:
            metadata:
              labels:
                role: prefill
                llm-d.ai/role: prefill        # router-role detection
                llm-d.ai/engine-type: vllm
                llm-d.ai/inferenceServing: "true"
            spec:
              containers:
                - name: vllm
                  image: vllm/vllm-openai:v0.32.0
                  command:
                    - sh
                    - -c
                    - "python3 -m vllm.entrypoints.openai.api_server --port 8080
                       --model deepseek-ai/DeepSeek-R1
                       --kv-transfer-config '{\"kv_connector\":\"NixlConnector\",\"kv_role\":\"kv_producer\"}'"
                  resources:
                    limits: { nvidia.com/gpu: "1" }
                  ports:
                    - containerPort: 8080
                  readinessProbe:
                    tcpSocket: { port: 8080 }
    - name: decode
      spec:
        leaderWorkerTemplate:
          size: 1
          workerTemplate:
            metadata:
              labels:
                role: decode
                llm-d.ai/role: decode
                llm-d.ai/engine-type: vllm
                llm-d.ai/inferenceServing: "true"
            spec:
              containers:
                - name: vllm
                  image: vllm/vllm-openai:v0.32.0
                  command:
                    - sh
                    - -c
                    - "python3 -m vllm.entrypoints.openai.api_server --port 8080
                       --model deepseek-ai/DeepSeek-R1
                       --kv-transfer-config '{\"kv_connector\":\"NixlConnector\",\"kv_role\":\"kv_consumer\"}'"
                  resources:
                    limits: { nvidia.com/gpu: "1" }
                  ports:
                    - containerPort: 8080
                  readinessProbe:
                    tcpSocket: { port: 8080 }

The request path that this topology implies — which the llm-d stack drives through the inference gateway (Gateway API Inference Extension) — is where the topology pays for itself. The sequence, verified against the llm-d disaggregated-serving design doc:

Client
  │
  ▼
Proxy (Envoy, standalone mode or GAIE InferenceGateway)
  │  forwards request metadata to EPP
  ▼
EPP: disagg-profile-handler
  │  1. decode-profile: filter/score/pick  -> D endpoint
  │  2. decider: how much of this prompt is UNCACHED on D?
  │       small uncached suffix ─────────────► return D only (skip disagg;
  │                                            decode prefills locally)
  │       large uncached suffix:
  │  3. prefill-profile: filter/score/pick ─► return P + D endpoints
  ▼
Proxy ──► routing sidecar (in the DECODE pod), header:
          x-llm-d-prefiller-host-port = <prefill address(es)>
  │
  ▼ (vLLM nixlv2 two-phase protocol)
sidecar ──► P: request { max_tokens=1, kv_transfer_params=remote-decode-metadata }
P        ──► P:  run prefill, hold KV blocks in GPU memory
P        ──► sidecar: response carrying KVTransferParams
sidecar ──► D (local): request + KVTransferParams (+ do_remote_prefill)
D        ──► P: PULL KV blocks over NIXL RDMA (GPUDirect; GPU-registered memory,
  │            no staging buffers, no CPU copy)
P        ──► P:  release KV cache
D        ──► D:  run decode loop ──► sidecar ──► Proxy ──► Client

Three details in that flow carry disproportionate production weight. The decider makes disaggregation conditional per request — a prompt whose prefix is already cached on the chosen decode pod skips the prefill pool entirely, which is the mechanism that makes P/D and prefix-cache-aware routing compose instead of fight. The lazy handshake (decode and prefill workers establish NIXL peer connections on first pairing via a background ZMQ metadata exchange, with no central coordinator) means scale events do not need a connection-reconfiguration step — but also that the first request after any pairing pays the setup. And the fallback policy is a real decision: with kv_load_failure_policy=recompute, a decode pod that fails a remote KV pull (say, its prefill partner was scale-downed mid-transfer) recomputes prefill locally instead of failing the request — trading latency for availability, per request.

Rollouts are the coupling problem again, and the answer is a router plugin. During a DisaggregatedSet rollout, old and new revisions run side by side; prefill and decode from different revisions may use incompatible KV-cache formats or transfer protocols, so requests must not pair across revisions. The LWS docs’ llm-d integration page documents the disaggregatedset-rollout-screener EPP plugin: a revision receives traffic only when it has at least one Ready pod in every required role (no traffic to a revision whose prefill pods are still loading weights, however ready its decode pods are), and in max-role mode the screener computes each revision’s traffic share from the role with the most Ready pods across eligible revisions — six ready decode pods in A and four in B gives B a 40% share, counted in pods, not GPUs. The share hand-off between one router (simplest: both profiles run after one revision choice) or per-role routers (a coordinator copies the x-llm-d-disagg-revision header from prefill’s response into the decode request) is plumbing; the design point is that the API gives the router revision labels (disaggregatedset.x-k8s.io/revision) that make the pinning possible, and LWS’s own rollout pacing (groups not updated until their predecessors are ready) keeps the share math monotonic.

The Accelerator Layer: DRA ComputeDomains for NVL72-Generation Racks

Multi-host TP assumes the interconnect is Ethernet-or-better and lets NCCL sort out the topology. The GB200/GB300 NVL72 generation inverts that assumption: NVLink now spans racks (Multi-Node NVLink), and workloads that share GPU memory across pods — cuda_ipc, NCCL/NVSHMEM collectives, NIXL NVLink KV-cache transfer — must join a shared IMEX domain, which on Kubernetes is an NVIDIA DRA ComputeDomain object served by the k8s-dra-driver-gpu. LWS v1.0.0 automates that wiring (KEP-1259, AutoComputeDomain feature gate, alpha), which is the difference between a rack-native serving stack and a Kubernetes deployment that happens to occupy an NVL72:

# Controller install with the gate on (Helm):
#   helm upgrade --install lws oci://registry.k8s.io/lws/charts/lws \
#     --namespace lws-system --create-namespace \
#     --set featureGates.AutoComputeDomain=true

apiVersion: leaderworkerset.x-k8s.io/v1
kind: LeaderWorkerSet
metadata:
  name: deepseek-r1
  annotations:
    leaderworkerset.sigs.k8s.io/auto-compute-domain: "enabled"
    leaderworkerset.sigs.k8s.io/exclusive-topology: nvidia.com/gpu.clique
spec:
  replicas: 2
  leaderWorkerTemplate:
    size: 4
    workerTemplate:
      spec:
        containers:
          - name: vllm
            image: nvcr.io/nvidia/vllm:latest
            resources:
              limits: { nvidia.com/gpu: "4" }
          - name: routing-proxy
            image: envoyproxy/envoy:v1.31-latest   # CPU-only sidecar: claim
                                                   # injection skips it
# LWS creates one ComputeDomain per replica; a restarted/replaced group gets
# a NEW domain. The lws-compute-domain-channel ResourceClaim is injected only
# into containers requesting nvidia.com/gpu > 0.
# DisaggregatedSet variant: disaggregatedset.x-k8s.io/auto-compute-domain:
# "enabled" -> one domain per (slice, revision), shared across roles.

The operational constraints in the docs are the load-bearing part. An IMEX channel connects GPUs only within the same physical NVLink clique (nvidia.com/gpu.clique), and the driver enforces at most one active ComputeDomain per node in driverManaged mode even when the node has free GPUs — so a pod that cannot join its group’s domain is a pod that cannot run, full node or not. Pair the opt-in with placement constraints so a domain’s members co-locate on one clique and separate domains land on distinct nodes; rolling updates with maxSurge > 0 need spare clique nodes, or you set maxSurge: 0 and drain-before-reuse when every clique node is occupied. And the disable path is genuinely hazardous: managed ComputeDomains deliberately carry no owner reference (ownership is tracked in a compute-domain-owner-uid label so garbage collection cannot delete a domain while pods drain), so if you turn the feature gate off and delete the workload, its domains are not garbage collected — they sit in Terminating held by the compute-domain-protection finalizer until the gate is re-enabled or you remove the finalizer by hand after the pods are gone. The docs’ runbook order is the only safe one: remove the annotations, wait for the managed domains to be deleted, then touch the gate.

Production Realities

Everything above is the brochure. This is the part that decides whether the architecture survives contact with your on-call rotation.

Who Should Skip This

The Decision Table

Your workloadAPIWhy
Model fits one node, single poolDeployment + gatewayNo group exists; LWS adds objects without adding semantics
Multi-host TP/PP (R1-class dense-shard serving)LeaderWorkerSetGroup = shard; restart, rollout, placement, and rank wiring at group granularity; RecreateGroupOnPodRestart + restart budget
Wide-EP MoE (DP attention / EP experts)LeaderWorkerSet (+ subgroups)One DP/EP group per LWS replica; subgroups partition it along the interconnect hierarchy; per-rank ports feed the InferencePool
Prefill/decode disaggregation with coupled scalingDisaggregatedSetRoles + slices + shared revision; revision-gated routing across the rollout; External role scaling for KEDA
NVL72-rack deployments sharing GPU memory across podsLWS/DS + auto-compute-domainDRA ComputeDomain lifecycle per replica or per slice; IMEX domains are a scheduling constraint LWS understands
Capacity is contested across teamsLWS/DS under KueueGroup becomes one quota-reserving podset; TAS pins it to a topology block; size immutable while managed

References & Further Reading