Repository navigation
Description
Background
Scaling a PD (prefill/decode-disaggregated) service today requires a full helm upgrade — even when the only knob we want to turn is "number of slices". The decision server / LLMSLORequirement pipeline already knows how many slices it wants, but there is no in-cluster way to write that number down. This issue is an architectural discussion for what the scaling channel should be.
The current shape
The sglang and vllm Helm charts render PD as:
- One
DisaggregatedSet(disaggregatedset.x-k8s.io/v1) whosespec.slices = N, with two roles (prefill,decode) — each role renders aLeaderWorkerSetper slice. - Per-slice, rendered outside the DS by the chart:
- 2 headless Services (
<release>-prefill-hl-s<i>,<release>-decode-hl-s<i>) — so the router can DNS-resolve the leaders of its slice. - 1 router
Deployment(<release>-router-s<i>), replicas =pd.router.replicas(usually 1), with await-upstreamsinitContainer gating on both headless Services, podAffinity pinning it to that slice's prefill leader node, and a drift-liveness probe that restarts it when DNS gains an IP. - 1 ClusterIP per-slice router Service behind the release's front-door Service (selector:
app=<r>, component=routerso it fans across slices).
- 2 headless Services (
- (vllm+mooncake only) 1 release-scoped
mc-masterDeployment + ClusterIP Service (<release>-mc-master). Single replica by deliberate choice — its comment block says "the contact pattern is per-request-batch and light, a ~190x startup margin" so HA is out of scope. - Per-slice PDBs (engine roles × slices + router per slice when replicas >= 2).
Changing replicaCount (== slices) requires re-running helm upgrade with a new values blob. This works, but it is a deployment-shaped operation:
- It re-renders every object in the release (ModelRoute, CART, ServiceMonitor, …).
- It needs the helm binary + credentials on the invoker, which the scaler/controller tier does not naturally have.
- Helm's Secret-based release state is an extra RBAC surface.
- The scaler has no notion of "what values does this release carry" — that context lives in a helm Secret, invisible to the decision server.
The goal
An in-cluster **spec.slices: N** (or CR-equivalent) that the scaler can patch as a single write, and have the whole slice fan-out (engines + router + services + mc-master if any) reconcile around it. helm upgrade` stays as the human/CI path for shape changes (image bumps, P/D ratio, parallelism layout); slice count becomes an operational knob.
Design space we've considered
Option A — extend the DS CRD with spec.sliceServices (and add router / mc-master as DS roles)
Add router and mc-master as size-1 LeaderWorkerSet-based roles to the DS, and extend the DS controller to render per-slice Services alongside.
| Concern | Read |
|---|---|
| DS role shape doesn't fit singletons | Roles are LWS-shaped (leader + worker templates). A size-1 leader-only LWS works, but bends the schema's intent. |
| Router lifecycle | wait-upstreams initContainer moves verbatim into the router role's leaderTemplate — zero behavior change. |
| mc-master's topology | Per-slice master requires per-slice ConfigMap wiring; the current "one release-scoped master" design argues against that. |
| CRD-level Service rendering is against upstream direction | KEP-766 removed DS-created Services (https://github.com/kubernetes-sigs/lws/tree/main/keps/766-DisaggregatedSet#service-orchestration). The controller's stance is "Pods are the API; consumers select on disaggregatedset.x-k8s.io/{name, slice, role, revision} labels." |
| Cost | CRD version bump + controller change + chart change. Highest coordination surface. |
Option B — add a sibling CRD (SliceServices) under a tiny controller
The chart still renders the DS + one SliceServices CR describing the per-slice Service templates. A small controller watches the parent DS's spec.slices and materialises the N Services.
- Scaler touches only
ds.spec.slices. - The tiny controller is a fan-out derivative of DS state — not user-facing.
- Router/mc-master stay Deployments — all the tuning in
pd-ds.yaml(init gate, drift-liveness, podAffinity, terminationGracePeriod) carries over.
Option C — PDRelease CRD + full operator
A new CRD wraps the whole PD shape (spec.engine, spec.prefill, spec.decode, spec.slices, …). An operator renders the same set of objects the helm chart renders today, and owns the diff/apply.
- One CR as the public API; the chart becomes optional or shell-only.
- Operator implements render (Go text/template / Jinja2 mirroring pd-ds.yaml), apply, and status aggregation.
- Highest implementation cost (~1500+ lines + CRD + tests). Highest long-term control.
Option D — keep the helm chart as the render source of truth; expose a tiny "scaler-facing" actuator that calls helm template | kubectl --server-side
Key insight: helm's bookkeeping is only its Secret-based release history. helm template has no bookkeeping at all.
The apiserver's native metadata.managedFields (server-side apply) is the bookkeeping layer the ecosystem has been converging toward.
Shape:
decision-server says N slices
│
▼
scaler / tiny controller
helm template <chart> -f stored-values.yaml --set replicaCount=N \
| kubectl apply --server-side --field-manager=pd-scaler -f -
▼
apiserver applies diff
- Values blob lives in a ConfigMap alongside the release — no helm Secrets involved.
- RBAC is just "CRUD on the rendered objects" — the same list any operator-shaped solution needs. Not meaningfully heavier than options A–C.
- Zero new CRDs, zero new operator code beyond ~50 lines of scripting around
helm template + kubectl apply. - helm-verify harness (renders every variant against gold and diffs) continues to own byte-correctness of the chart.
- The 6 "Cons" of the previous options (role shape mismatch, init ordering, PDB semantics, mc-master topology, label migration, API migration) all disappear — router stays a Deployment, mc-master stays a Deployment, per-slice Services stay in the chart.
Key questions for slo-api / scaler owners
- What is the intended scaling channel for PD? Should the scaler patch a CRD (
spec.slices), or invoke a chart-shaped renderer (helm or otherwise)? The two imply different ownership models for the render layer. - Does slo-api's decision pipeline have opinions about values blob storage (where the "rest of the values" beyond
sliceslive)? A ConfigMap-per-release is the natural answer for option D. - Do we want to commit to APIs the chart effectively owns today (router Deployment shape, mc-master shape, per-slice Service naming) by promoting them into a CRD/operator surface — or leave that layer free to evolve under the chart?
- Failure-domain preference: if
spec.slices=3but only 2 slices come Ready, where should the condition be reported (CRD status? helm release state? scaler metrics)?
Our recommendation
Start with Option D (helm template + server-side apply, driven by a tiny actuator), treat it as a prototype. If it breaks (helm state conflicts, drift, dual-write with human helm upgrade), graduate the actuator into an operator-shaped PDRelease (Option C). Options A and B require upstream DS CRD negotiation that doesn't appear to be on our critical path.
The pattern is essentially "helm-as-render-library, kube-apiserver-managedFields-as-bookkeeping" — which is also how Flux's helm-controller and several GitOps pipelines already behave at scale.
Related upstream context
- KEP-766 DisaggregatedSet — https://github.com/kubernetes-sigs/lws/tree/main/keps/766-DisaggregatedSet (see "Service Orchestration" — removed)
- KEP-846 DisaggregatedSet Slices — https://github.com/kubernetes-sigs/lws/tree/main/keps/846-disaggregatedset-slices (Non-Goal ci: bump actions/checkout from 6.0.2 to 7.0.1 #3: "Aggregate cross-slice Service / traffic routing … is not introduced")
- DS API source we are reading against:
sigs.k8s.io/lws/api/disaggregatedset/v1(local mirror available).
Activity
[-]Design: in-cluster scaling channel for PD slices (helm upgrade is too heavyweight)[/-][+]Design: in-cluster scaling channel for PD slices[/+]huskcass commentedon Oct 22, 2026
Proposal: a
SliceSetworkload CRD owned by llm-operator (scale channel =SliceSet.spec.replicas)Summary. None of A–D gives us the right ownership model. What is missing is a StatefulSet-shaped workload whose unit is a whole PD slice: today the chart expands one slice's objects with
range $s := until $slicesloops, so the slice count only exists at render time. We propose a new namespaced CRD,SliceSet(serving.modelsphere.dev/v1alpha1), reconciled by llm-operator:spec.replicas= slice count, with a/scalesubresource. The chart rendersSliceSet+LLMScaler(targetRef: SliceSet), and the scaler writes one integer. No helm involvement per scale step.spec.template= the full shape of one slice: engines (a DS spec), router (Deployment spec), per-role headless Services, per-slice PDBs.s<i>like StatefulSet. Scale-up fills the lowest missing ordinal; scale-down removes the highest and tears it down in a fixed order (out of routing → router → engines).DisaggregatedSetwithslices: 1. We do not wrap one DS withslices: N(reasons below).helm upgrade(via LLMService) stays the path for shape changes. Slice count becomes an operational knob.What we checked (sources)
/scalesubresource.DisaggregatedSetonly declares+kubebuilder:subresource:status(types.go#L402). KEP-849 explicitly rejected/scaleon DS mapped tospec.slices("could ship in a future KEP"; KEP-849 Alt 2). The only DS-level/scaleis the per-roleDisaggregatedSetRoleScaler(scaling.mode: External). Its webhook rejectsslices > 1while any role is External (webhook#L155-L160).for slice := range sliceCount { reconcileSlice(...) }, and each slice runs its own revision transition (controller#L173). Combined with our chart defaultmaxSurge: 0, maxUnavailable: <role replicas>(atomic per role, pd-ds.yaml#L62), an image bump today takes every slice down at once. The chart comment's assumption that HA lives "at the tier above (slices…)" does not hold under DS semantics.slicesis excluded from the revision hash, so changing it never triggers a rollout.disaggregatedset.x-k8s.io/{name,role,slice,revision}(+subrole) and envDISAGGREGATEDSET_{NAME,ROLE,SLICE,REVISION}. LWS names are<ds>-<slice>-<revision>-<role>and rotate on every rollout.bc27272): each slice is 2 headless Services (-prefill-hl-s<i>,-decode-hl-s<i>, selector on DS labels +worker-index: "0") + router Deployment-router-s<i>+ PDBs (-<role>-s<i>, and-router-s<i>when router replicas ≥ 2). There is no per-slice router ClusterIP Service: the front-door Service selectscomponent: routeracross all slices (service.yaml#L20).replicaCount==spec.slices. PD + scaler is currently rejected at render time (pd-guards.yaml#L37-L38), because the LWS names rotate.e0bbf23): LLMService upgrades with helm v4,ServerSideApply=true, andForceConflictsonly when annotated for the current generation (helm.go#L204-L209). It re-applies only when the values hash changes and watches onlyLLMService. LLMScaler readsspec.replicasandstatus.{replicas,updatedReplicas,readyReplicas,terminatingReplicas,observedGeneration}off an unstructured target and writesspec.replicaswith a plainUpdate(guards.go#L238, llmscaler_controller.go#L314). Its rollout guard only tracksDeployment.Answers to the four key questions
SliceSet.spec.replicas, through the existing LLMScaler (or HPA/KEDA via/scale). The render layer stays split. The chart owns the shape. The SliceSet controller owns the fan-out: ordinals, per-slice objects, ordering. The scaler never renders anything and never touches the LLMService. An external scaler targeting the LLMService would invert ownership and loop: scaler → LLMService → helm upgrade → re-rendered scaler. It would also make every scale step a full release upgrade.LLMService.spec.layers. The decision pipeline only needsserviceId → slice count, which the Custom provider already reads. We would not add a per-release values ConfigMap.SliceSet.status. It hasreplicas/readyReplicas/updatedReplicas/terminatingReplicas/observedGeneration(the exact fields LLMScaler already reads, soreadyReplicasmeans ready slices), per-ordinalslices[]entries, andAvailable/Progressing/ReplicaFailureconditions naming the stuck ordinal. LLMScaler mirrors it incurrentReplicas. Helm release state never reflects runtime health.API sketch
Per ordinal
i, the controller owns (controller ownerRefs, labelserving.modelsphere.dev/slice: "<i>"):DisaggregatedSet <name>-s<i>(slices: 1), Services<name>-<role>-hl-s<i>(selectordisaggregatedset.x-k8s.io/name=<name>-s<i>, role=<role>, leaderworkerset.sigs.k8s.io/worker-index="0",publishNotReadyAddresses: true), Deployment<name>-router-s<i>, PDBs<name>-<role>-s<i>/<name>-router-s<i>. Names stay identical to today except the DS. Into router containers the controller injectsSLICE_ORDINAL,SLICE_<ROLE>_HL(FQDN) andSLICE_<ROLE>_WANT, so the chart's router script reads env instead of{{ $s }}.Template fields are typed (lws
DisaggregatedRoleSpec,PodTemplateSpec, a small Service-port/PDB subset), not generic object templates. That gives schema validation and clean SSA diffs, keeps ordinal substitution to a few known fields, and allows a narrow RBAC (DS, Deployments, Services, PDBs) instead of*/*.Wiring and ownership
Who owns
spec.replicas: whenscaler.enabled, the chart omitsspec.replicas(the CRD defaults it to1on create, and LLMScaler lifts it tominReplicas). This is the HPA practice. It matters concretely here: LLMService applies with SSA and no force, so a renderedreplicaswould make every later upgrade either fail on a field-manager conflict with the scaler, or (with the force annotation) snap the slice count back. Turning the scaler on for an existing release follows the documented SSA hand-off (k8s: migrating to HPA): the scaler writes once, then the chart drops the field. Whenscaler.enabled=false, the chart rendersreplicas: {{ .Values.replicaCount }}exactly as today.Small llm-operator changes beyond the new controller: (a) LLMScaler's rollout guard also tracks
SliceSet(generation == observedGeneration && updatedReplicas == replicas). (b) Optionally, LLMScaler writes through/scaleor SSA with its own field manager instead of a full-objectUpdate. (c) Drop thepd.enabled && scaler.enabledrender guard.Semantics
Scale up (N → M): for each missing ordinal, lowest first, create the headless Services and the DS (
slices: 1) at the update revision, then the router Deployment. Itswait-upstreamsgate and required podAffinity already handle ordering. The router joins the front door only when its readiness passes. Parallel creation across new ordinals is the default (podManagementPolicy: Parallel-like), since slices are independent.Scale down (M → N): highest ordinal first, one at a time (or
maxStepReplicasfrom LLMScaler):scaleDown.drainSeconds.A PDB never blocks this, because it is a controller delete and not an eviction. Status reports
terminatingReplicasuntil the ordinal is gone, so LLMScaler's settle guard works as-is.Template update: a SliceSet revision hash (ControllerRevision) covers
template. WithRollingUpdate, ordinals>= partitionare updated highest-first, at mostmaxUnavailableslices not-ready at a time. "Updating a slice" means patching that ordinal's DS spec, so the DS planner does the cross-role, same-version rollout inside the slice. The router is updated in place. A slice counts as updated + ready when its DS isAvailableat the new revision and its router Deployment is available. After an engines-only change, the controller bumps a router pod-template annotation so the router re-resolves upstreams once, instead of relying only on drift-liveness. Changingreplicasnever triggers a rollout (same as DSslices). New slices created mid-rollout use the update revision.OnDeleteis available for manual, slice-by-slice upgrades.Why one DS per ordinal (
slices: 1) rather than one DS withslices: N, or raw LWSslices: NmaxUnavailable, partition, canary)slices--slices > 1ExclusiveSlicespreads slices of one DSExclusiveTopologyworks across DSs;ExclusiveSlicespread must be injected by SliceSetKEP-846 rejected "multiple DS objects" because there was "no single object to manage or scale". SliceSet is that single object, so the objection does not apply. Managing LWS directly gives us nothing that per-ordinal DS does not, and costs the hardest part of DS.
Compared with A–D
sliceServices): goes against KEP-766/846, needs upstream negotiation, bends a singleton into an LWS role, and still has DS's all-slices-at-once rollout.SliceServicessidecar CRD): fixes Services but not the router's lifecycle, rollout budget or teardown order, and splits one slice's state across two controllers.helm template | kubectl apply): has the ownership reversal described in Q1. A second writer forks the LLMService's helm-v4 SSA release (conflicting field managers, history that no longer matches the cluster), every scale step is a full re-render, and the scaler needs chart + values + render credentials.SliceSet is the only option that makes slice count a single in-cluster write while keeping the chart as the single shape owner.
Open questions
SliceSetinserving.modelsphere.dev, or in a separateworkloads.group? "Slice" also collides with TPU/JobSet vocabulary, soPDSliceSetis the conservative alternative.ExclusiveSliceacross its child DSs, or requireExclusiveTopology?<release>-s<i>-0-<rev8>-<role>. Check the 63-char limit for the longest release names, or give the child DS a short hash name.template.sidecarServices.<release>to<release>-s0(a one-time engine restart per release), or adopt the existing DS as ordinal 0.aceforeverd commentedon Oct 23, 2026
temp solution in swiss side only: modelsphere/swiss#30
It build scale loop inside swiss, and goes directly into swiss's apply API
remotego commentedon Nov 2, 2026
closed. see modelsphere/swiss#30