docs(spec): plan PD roles and cross-role atomic admission for ModelDeployment - #158
docs(spec): plan PD roles and cross-role atomic admission for ModelDeployment#158thxCode wants to merge 1 commit into
Conversation
…ployment Prefill and decode only work if both halves run: a prefiller with no decoder produces KV blocks nobody consumes. The single-role ModelDeployment spec creates one Kueue Workload per replica, which is right for replicas that are independently useful and exactly wrong here — on a pool that fits half of them, Kueue admits half and which half is a race. This spec lifts spec.roles from "exactly 1" to 1..10 and makes a deployment's Pods one Kueue pod group, so its roles are admitted all-or-nothing. No new CRD. Three per-role fields: kind (what the engine is told it is), acceleratorKey (which accelerator model inside the pool the role wants), and nothing else. Two constraints shape it, both verified in this tree. A Workload carries one queueName, so every role must sit in one ClusterQueue and roles[*].instanceType must be identical — this spec does not relax that and cannot. But Kueue assigns a ResourceFlavor per PodSet, so inside one ClusterQueue two roles can still land on two accelerator models of the same manufacturer. Across manufacturers stays impossible: a queue's accelerator quota is credits.gpustack.ai/<manufacturer>, one resource name per manufacturer, and one resource group covers one set of resources. Status: Specified. Implementation follows the single-role spec, with one exception the plan states: the per-role feasibility fix (F6/T10) depends on neither that spec nor cross-model pools and ships first, on its own, because it repairs a defect reachable today. Signed-off-by: thxCode <thxcode0824@gmail.com>
|
✅ OpenCodeReview: Review skipped: no items were selected. |
|
Closing this: opening it was premature, and the repository's own history says so more clearly than Three of the four Two things make this case unlike those three precedents:
The spec is unchanged on the #159 is unaffected and stays open. It repairs a defect reachable on |
There was a problem hiding this comment.
🟡 Changes recommended
Critical and moderate correctness gaps remain in Service isolation, role composition, validation, status, naming, and testing.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a design specification for multi-role ModelDeployment workloads with atomic Kueue admission.
Changes:
- Defines role APIs, validation, placement, Services, and status.
- Plans per-role flavor selection and feasibility checks.
- Outlines implementation and testing.
File summaries
| File | Description |
|---|---|
specs/2026-09-02-model-deployment-pd-atomic-admission.md |
Designs cross-role atomic admission and implementation milestones. |
Review details
Suppressed comments (2)
specs/2026-09-02-model-deployment-pd-atomic-admission.md:16
- The summary says three fields but names only two; the implementation plan confirms two additions to
ModelDeploymentRole(kindandacceleratorKey) plus separate status fields. State that this adds two per-role spec fields so the public API summary is unambiguous.
length 1; this spec lifts that bound to 1..10 and adds three per-role fields — `kind` (what the
engine is told it is), `acceleratorKey` (which accelerator model inside the pool this role wants) and
nothing else. The Pods of a deployment become one **Kueue pod group**, one PodSet per role, and the
specs/2026-09-02-model-deployment-pd-atomic-admission.md:1006
- This API example contradicts F8 and T1:
ModelDeploymentRoleStatusis required to gain bothkindandassignedFlavor, but the shown struct adds onlyAssignedFlavor. AddKindto the example and reconcile the summary's per-role field count so T1 has one unambiguous API shape.
type ModelDeploymentRoleStatus struct {
- Files reviewed: 1/1 changed files
- Comments generated: 9
- Review effort level: Balanced
| Acceptance: in addition to the deployment-wide `Service`, one `ClusterIP` Service per role | ||
| selecting that role's Pods through the `worker.gpustack.ai/role` label, owned by the deployment, | ||
| named `<deployment>-<role>`. `status.endpoint` stays the deployment-wide one. A single-role |
| The single-role spec's plain round-robin `Service` is unchanged; a P/D deployment gets one | ||
| `Service` per role plus the deployment-wide one, and nothing steers between them. This boundary | ||
| exists to agree with the one below: a spec that cannot verify the transport must not specify it. |
| **A group that is short of its total is reported.** `QuotaReserved` goes `False` with reason | ||
| `PodGroupIncomplete`, and the message names how many Pods exist against how many the group declares. | ||
| The predicate is observed: the number of the deployment's own Pods that exist, against Σ `replicas`. |
| So the webhook resolves the role's `instanceType` to its ClusterQueue, reads the pool's live | ||
| `ResourceFlavor`s, and rejects a key none of them offers, naming the offered set. A pool that has no | ||
| flavors yet (a fresh cluster) is **not** a rejection — the key may become valid in a minute — it is | ||
| accepted, and the mismatch then shows up as F6's `Retry`, which is the transient-shortage path | ||
| already. |
| `kind: server` alongside any other kind is refused: "one plain server plus a prefiller" is not a | ||
| shape anything consumes, and accepting it would mean rendering a connector configuration whose | ||
| meaning is undefined. |
| - A demand is carried **with the PodSet it came from**, and two PodSets' demands merge only when they | ||
| were assigned the **same flavor** (in addition to today's family/units/profile agreement) — so | ||
| co-located roles still share a budget and disjoint roles do not. | ||
| - A card is carried **with the flavor(s) whose node selector and accelerator key it satisfies**, and a | ||
| demand is fitted only against cards its own PodSet's flavor covers. |
| Acceptance: in addition to the deployment-wide `Service`, one `ClusterIP` Service per role | ||
| selecting that role's Pods through the `worker.gpustack.ai/role` label, owned by the deployment, | ||
| named `<deployment>-<role>`. `status.endpoint` stays the deployment-wide one. A single-role |
| - **envtest, two fake flavors of two models in one ClusterQueue**, a two-role deployment naming both: | ||
| the Workload's `podSetAssignments` carries **two different flavors**. This is where G2 is verified; | ||
| the hardware cannot show it. | ||
| - **envtest, the incomplete group** (T9): the group short of its total ⇒ **no Workload exists** and | ||
| the deployment reports `PodGroupIncomplete`; completing the group produces the Workload and clears |
| - **Cross-manufacturer heterogeneous P/D.** Prefill on NVIDIA and decode on Ascend is not | ||
| expressible, and the reason is structural: `buildResourceGroups` | ||
| (`pkg/worker/controllers/worker/node_queue.go:261`) sets an accelerated queue's `CoveredResources` | ||
| to the single resource `credits.gpustack.ai/<manufacturer>`, derived from the pool's one | ||
| `manufacturer` note. A ClusterQueue resource group covers one set of resources, and Kueue's own | ||
| ClusterQueue webhook rejects a second group repeating a covered resource | ||
| (`validateResourceGroups`). Two manufacturers' credits cannot be one queue's quota, and one | ||
| `queueName` per Workload does the rest. | ||
| - Same-manufacturer, **cross-model** heterogeneous P/D *is* in scope (G2). The two are not the | ||
| same restriction and must not be stated as one. |
What type of PR is this?
/kind documentation
/area worker
What this PR does / why we need it:
Adds the design document for cross-role PD (prefill/decode) workloads and Kueue atomic
admission. Spec only — 1 file, no implementation code, following the four
docs(spec):precedents on
main(739d7b5a,8c2cc0df,674f5583,57bfd123).Prefill and decode only work if both halves run: a prefiller with no decoder produces KV blocks
nobody consumes, and a decoder with no prefiller has nothing to decode. The single-role
ModelDeploymentspec creates one Kueue Workload per replica — correct for replicas that areindependently useful, and exactly wrong here: on a pool that fits half of them, Kueue admits half,
and which half is a race.
The spec lifts
spec.rolesfrom "exactly 1" to 1..10 and makes a deployment's Pods one Kueuepod group, so every role is admitted all-or-nothing. No new CRD. Three per-role fields:
kind(what the engine is told it is),acceleratorKey(which accelerator model inside the poolthe role wants), and nothing else.
Two constraints shape the whole design, both verified in this tree:
queueName, so every role must sit in one ClusterQueue androles[*].instanceTypemust be identical. The spec does not relax that and cannot.flavorassigner.go: each PodSet forms its ownassignment group by default), so inside one ClusterQueue two roles can still land on two
accelerator models of the same manufacturer.
Across manufacturers stays impossible, structurally rather than for want of a feature: an
accelerated queue's quota is
credits.gpustack.ai/<manufacturer>— one resource name permanufacturer — and Kueue rejects a second resource group repeating a covered resource.
Status: Specified. The plan states one exception to its own ordering: the per-role feasibilityfix (F6/T10) ships first, on its own, because it depends on neither the single-role spec nor
cross-model pools and repairs a defect reachable today.
Which issue(s) this PR links to:
None — this spec was not filed as an issue.
Special notes for your reviewer:
evidence in the body, and external sources are cited by URL; it references no local file.
models of one manufacturer in one pool, which the available hardware cannot provide, so it is
verified in envtest against two fake
ResourceFlavors — and the spec says why the fake isfaithful at that layer: the property asserted ("Kueue assigned two different flavors to the two
PodSets") is a Workload-status property, not a hardware one. The local-cluster case verifies
atomic admission by starvation, asserting a negative (zero Pods of either role running).
No throughput, latency or RDMA claim is made anywhere.
Ownshere:
NodeDevicesAdmissionReconcileris not role-aware — that is F6/T10, shipping as its own PR.buildResourceGroups(node_queue.go:280) reads Kueue'sMaxItems=16as "16 flavors perresource group" and opens a second group with the same
CoveredResourcespast 16. Kueue capsresource groups at 16 and flavors per group at 64, and
validateResourceGroupsdeclares its
seenResourcesset outside the per-group loop, so a repeated covered resource isa
field.Duplicateand the ClusterQueue update is rejected. A single-covered-resource queuecan therefore only ever have one group; the split is itself the bug and the real ceiling
is 64.
v0.17.1(hack/deps.shvsgo.mod).Every runtime behaviour the spec relies on was read from 0.18.4, the version that runs; the
skew is recorded as a standing caveat so the next reader does not take
go.modfor the truth.Does this PR introduce a user-facing change?