Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions api/v1alpha1/nodeclaim_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,19 @@ type NodeClaimSpec struct {
// +optional
Region string `json:"region,omitempty"`

// AcceleratorID is the provider's own identifier for what actually serves this
// claim (e.g. AWS "p5.48xlarge", RunPod "NVIDIA H100 80GB HBM3"), resolved at
// placement time from the Pod's requested accelerator type + count via the
// provider's MapAccelerator. Unlike Provider/CapacityType/Region it is NOT a
// provisioning input (the provider re-derives it from the Pod) — it is recorded
// for reporting, like PoolRef, so `kubectl get nc` shows the concrete SKU behind
// each instance without cross-referencing the Pod. Empty for a CPU-only claim,
// which requests no accelerator.
// Accelerator is the requested accelerator pool this claim serves, as
// "type:count" (e.g. "H100:8"), resolved at placement time from the Pod's
// accelerator type + count. It names the POOL, not the concrete SKU: a launch
// may span several interchangeable provider instance types (AWS's fleet tries
// alternates), so the exact instance type is only known post-launch from the
// observed instance — this field stays truthful regardless of which alternate
// lands. Unlike Provider/CapacityType/Region it is NOT a provisioning input (the
// provider re-derives it from the Pod) — it is recorded for reporting, like
// PoolRef, so `kubectl get nc` shows what each instance serves without
// cross-referencing the Pod. Empty for a CPU-only claim, which requests no
// accelerator.
// +optional
AcceleratorID string `json:"acceleratorID,omitempty"`
Accelerator string `json:"accelerator,omitempty"`

// PoolRef is the NodePool whose policy produced this claim, for reporting.
// +optional
Expand Down Expand Up @@ -150,7 +153,7 @@ type NodeClaimStatus struct {
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Provider",type=string,JSONPath=`.spec.provider`
// +kubebuilder:printcolumn:name="Region",type=string,JSONPath=`.spec.region`
// +kubebuilder:printcolumn:name="ACCELERATOR_ID",type=string,JSONPath=`.spec.acceleratorID`
// +kubebuilder:printcolumn:name="ACCELERATOR",type=string,JSONPath=`.spec.accelerator`
// +kubebuilder:printcolumn:name="CAPACITY_TYPE",type=string,JSONPath=`.spec.capacityType`
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
// +kubebuilder:printcolumn:name="Instance",type=string,JSONPath=`.status.instanceID`
Expand Down
2 changes: 1 addition & 1 deletion api/v1alpha1/nodepool_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ const (
type FailoverPolicy struct {
// BlocklistTTL is how long a failed placement is excluded before the
// provider becomes a candidate for it again.
// +kubebuilder:default="10m"
// +kubebuilder:default="3m"
BlocklistTTL metav1.Duration `json:"blocklistTTL,omitempty"`
}

Expand Down
25 changes: 14 additions & 11 deletions config/crd/bases/nebula.inftyai.com_nodeclaims.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ spec:
- jsonPath: .spec.region
name: Region
type: string
- jsonPath: .spec.acceleratorID
name: ACCELERATOR_ID
- jsonPath: .spec.accelerator
name: ACCELERATOR
type: string
- jsonPath: .spec.capacityType
name: CAPACITY_TYPE
Expand Down Expand Up @@ -70,16 +70,19 @@ spec:
ledger, not a spec: its reason to exist is to survive the Node so teardown can
reclaim the external instance and never leak a paid GPU.
properties:
acceleratorID:
accelerator:
description: |-
AcceleratorID is the provider's own identifier for what actually serves this
claim (e.g. AWS "p5.48xlarge", RunPod "NVIDIA H100 80GB HBM3"), resolved at
placement time from the Pod's requested accelerator type + count via the
provider's MapAccelerator. Unlike Provider/CapacityType/Region it is NOT a
provisioning input (the provider re-derives it from the Pod) — it is recorded
for reporting, like PoolRef, so `kubectl get nc` shows the concrete SKU behind
each instance without cross-referencing the Pod. Empty for a CPU-only claim,
which requests no accelerator.
Accelerator is the requested accelerator pool this claim serves, as
"type:count" (e.g. "H100:8"), resolved at placement time from the Pod's
accelerator type + count. It names the POOL, not the concrete SKU: a launch
may span several interchangeable provider instance types (AWS's fleet tries
alternates), so the exact instance type is only known post-launch from the
observed instance — this field stays truthful regardless of which alternate
lands. Unlike Provider/CapacityType/Region it is NOT a provisioning input (the
provider re-derives it from the Pod) — it is recorded for reporting, like
PoolRef, so `kubectl get nc` shows what each instance serves without
cross-referencing the Pod. Empty for a CPU-only claim, which requests no
accelerator.
type: string
capacityType:
description: |-
Expand Down
2 changes: 1 addition & 1 deletion config/crd/bases/nebula.inftyai.com_nodepools.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ spec:
RunPod reports no capacity) is temporarily excluded and re-tried.
properties:
blocklistTTL:
default: 10m
default: 3m
description: |-
BlocklistTTL is how long a failed placement is excluded before the
provider becomes a candidate for it again.
Expand Down
2 changes: 1 addition & 1 deletion config/samples/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: gpu-workload-sample
name: gpu-workload-sample-a100
namespace: default
labels:
app.kubernetes.io/managed-by: nebula
Expand Down
5 changes: 2 additions & 3 deletions config/samples/nebula_v1alpha1_nodepool.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ metadata:
spec:
# strategy (the inner, provider-ranking axis).
providers:
- name: modal
- name: aws
regions:
- us-east-1
Expand All @@ -18,7 +17,7 @@ spec:
- eu-west-1
- ca-central-1
- sa-east-1
- name: modal
# - name: modal
# - name: runpod
# Outer axis: try OnDemand on every provider first, fall back to Spot.
capacityTypes:
Expand All @@ -27,4 +26,4 @@ spec:
# Inner axis: within the active capacity tier.
strategy: Ordered
failover:
blocklistTTL: 10m
blocklistTTL: 3m
8 changes: 4 additions & 4 deletions internal/controller/nodeclaim_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,16 @@ func (f *fakeProvider) List(context.Context) ([]provider.Instance, error) {
return f.list, f.listErr
}
func (f *fakeProvider) Offerings(context.Context) ([]provider.Offering, error) { return nil, nil }
func (f *fakeProvider) MapAccelerator(c string, _ int32) (string, bool) {
func (f *fakeProvider) MapAccelerator(c string, _ int32) ([]string, bool) {
if f.gpus == nil {
return c, true // offer any accelerator
return []string{c}, true // offer any accelerator
}
for _, g := range f.gpus {
if g == c {
return c, true
return []string{c}, true
}
}
return "", false
return nil, false
}
func (f *fakeProvider) ClassifyProvisionError(error, string, string) provider.BlockScope {
return provider.BlockScope{}
Expand Down
9 changes: 5 additions & 4 deletions internal/controller/pod_placement_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,11 @@ type placement struct {
// Empty means the provider's configured default region; region-simple
// providers leave it empty.
region string
// acceleratorID is the provider's own identifier for what serves this request
// (e.g. AWS "p5.48xlarge"), resolved via MapAccelerator(type, count). Empty for
// a CPU-only Pod, which requests no accelerator.
acceleratorID string
// accelerator is the requested pool identity (type:count, e.g. "H100:8"). It is
// what the blocklist keys on and the NodeClaim reports — the pool, not the
// provider SKU, so it stays truthful when a launch spans interchangeable
// instance types. Empty for a CPU-only Pod, which requests no accelerator.
accelerator string
}

// needsPlacement reports whether the Pod is an opted-in workload still held by
Expand Down
18 changes: 9 additions & 9 deletions internal/controller/pod_placement_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func (b *fakeBlocklist) BlockedUntil(c failover.Candidate) (time.Duration, bool)
if e.Provider != "" && e.Provider != c.Provider {
continue
}
if e.AcceleratorID != "" && e.AcceleratorID != c.AcceleratorID {
if e.Accelerator != "" && e.Accelerator != c.Accelerator {
continue
}
if e.CapacityType != "" && e.CapacityType != c.CapacityType {
Expand Down Expand Up @@ -181,10 +181,10 @@ func TestPlacement_UngatesAndRoutesAndCreatesClaim(t *testing.T) {
if nc.Spec.Provider != provider.ProviderModal || nc.Spec.PodRef.UID != "uid-1" || nc.Spec.PoolRef != "pool-a" {
t.Fatalf("unexpected claim spec: %+v", nc.Spec)
}
// The provider's resolved accelerator id is recorded for reporting (the fake's
// MapAccelerator echoes the canonical type, so H100 -> "H100").
if nc.Spec.AcceleratorID != "H100" {
t.Fatalf("expected claim to record acceleratorID H100, got %q", nc.Spec.AcceleratorID)
// The request's POOL identity (type:count) is recorded for reporting: H100 with
// no explicit count defaults to 1, so the pool is "H100:1".
if nc.Spec.Accelerator != "H100:1" {
t.Fatalf("expected claim to record accelerator H100:1, got %q", nc.Spec.Accelerator)
}
}

Expand Down Expand Up @@ -251,13 +251,13 @@ func TestPlacement_CPUOnlyPodMatchesAnyProvider(t *testing.T) {
if got.Spec.NodeSelector[nebulav1alpha1.ProviderLabel] != provider.ProviderModal {
t.Fatalf("expected a CPU-only Pod to place on modal, got %v", got.Spec.NodeSelector)
}
// A CPU-only claim requests no accelerator, so the id is left empty.
// A CPU-only claim requests no accelerator, so the pool identity is left empty.
var nc nebulav1alpha1.NodeClaim
if err := c.Get(context.Background(), types.NamespacedName{Name: "default-p1"}, &nc); err != nil {
t.Fatalf("expected NodeClaim default-p1: %v", err)
}
if nc.Spec.AcceleratorID != "" {
t.Fatalf("expected empty acceleratorID for a CPU-only claim, got %q", nc.Spec.AcceleratorID)
if nc.Spec.Accelerator != "" {
t.Fatalf("expected empty accelerator for a CPU-only claim, got %q", nc.Spec.Accelerator)
}
}

Expand Down Expand Up @@ -406,7 +406,7 @@ func TestPlacement_FailsOverToNextRegionWhenBlocked(t *testing.T) {
prov := &fakeProvider{name: provider.ProviderModal, gpus: []string{"H100"}}
r, c := newPlacementReconciler(t, []client.Object{pod, pool}, prov)
r.Blocklist = &fakeBlocklist{blocked: []failover.Candidate{
{Provider: provider.ProviderModal, AcceleratorID: "H100", CapacityType: nebulav1alpha1.CapacityOnDemand, Region: "us-east-1"},
{Provider: provider.ProviderModal, Accelerator: "H100:1", CapacityType: nebulav1alpha1.CapacityOnDemand, Region: "us-east-1"},
}}

reconcilePod(t, r, "default", "p1")
Expand Down
56 changes: 28 additions & 28 deletions internal/controller/pod_placement_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,25 +111,25 @@ func (r *PodPlacementReconciler) selectPlacement(ctx context.Context, pod *corev
continue // unregistered; NodePool status surfaces this separately
}
// A CPU-only Pod (no accelerator) matches any provider; an accelerator
// Pod only matches a provider whose catalog serves that (type, count). The
// resolved id is also what the block is keyed on, so it is captured here
// from the same lookup that decides servability.
acceleratorID := ""
// Pod only matches a provider whose catalog serves that (type, count).
// MapAccelerator is consulted only for that servability check — the block
// key and the reported identity are the POOL (type:count), not the
// provider's SKU, so a launch spanning alternates and a post-launch SKU
// swap never desync the key. Empty for a CPU-only Pod.
accelerator := util.AcceleratorPool(accel, count)
if accel != "" {
id, offered := prov.MapAccelerator(accel, count)
if !offered {
if _, offered := prov.MapAccelerator(accel, count); !offered {
log.V(1).Info("skipping candidate: provider does not offer the accelerator",
"provider", ref.Name, "accelerator", accel, "count", count)
continue
}
acceleratorID = id
}
for _, region := range regionsFor(ref) { // inner: region
if until, blocked := r.blockedUntil(ref.Name, acceleratorID, tier, region); blocked {
if until, blocked := r.blockedUntil(ref.Name, accelerator, tier, region); blocked {
// Servable but failed recently; try the next region, then the next
// tier, and remember when this one frees so we can requeue for it.
log.Info("skipping candidate: blocked by failover blocklist",
"provider", ref.Name, "acceleratorID", acceleratorID,
"provider", ref.Name, "accelerator", accelerator,
"capacityType", tier, "region", region, "freesIn", until.String())
if until > 0 && (soonest == 0 || until < soonest) {
soonest = until
Expand All @@ -139,10 +139,10 @@ func (r *PodPlacementReconciler) selectPlacement(ctx context.Context, pod *corev
log.Info("selected placement candidate",
"provider", ref.Name, "capacityType", tier, "region", region)
return placement{
provider: ref.Name,
capacityType: tier,
region: region,
acceleratorID: acceleratorID,
provider: ref.Name,
capacityType: tier,
region: region,
accelerator: accelerator,
}, true, 0
}
}
Expand Down Expand Up @@ -174,21 +174,21 @@ func regionsFor(ref nebulav1alpha1.ProviderSpec) []string {
return ref.Regions
}

// blockedUntil reports whether the (provider, acceleratorID, tier, region)
// blockedUntil reports whether the (provider, accelerator, tier, region)
// candidate is currently excluded by the failover blocklist and, if so, how long
// until it frees (for the requeue hint). acceleratorID is the provider's resolved
// id for the request (see selectPlacement), so a capacity block matches only
// candidates on the same instance type / pool. It is nil-safe: with no blocklist
// wired (tests, or a blocklist-less build) nothing is ever blocked.
func (r *PodPlacementReconciler) blockedUntil(provName, acceleratorID string, tier nebulav1alpha1.CapacityType, region string) (time.Duration, bool) {
// until it frees (for the requeue hint). accelerator is the request's pool
// identity (type:count; see selectPlacement), so a capacity block matches only
// candidates on the same pool. It is nil-safe: with no blocklist wired (tests, or
// a blocklist-less build) nothing is ever blocked.
func (r *PodPlacementReconciler) blockedUntil(provName, accelerator string, tier nebulav1alpha1.CapacityType, region string) (time.Duration, bool) {
if r.Blocklist == nil {
return 0, false
}
return r.Blocklist.BlockedUntil(failover.Candidate{
Provider: provName,
AcceleratorID: acceleratorID,
CapacityType: tier,
Region: region,
Provider: provName,
Accelerator: accelerator,
CapacityType: tier,
Region: region,
})
}

Expand Down Expand Up @@ -235,11 +235,11 @@ func (r *PodPlacementReconciler) ensureClaim(ctx context.Context, pod *corev1.Po
Name: pod.Name,
UID: string(pod.UID),
},
Provider: p.provider,
CapacityType: p.capacityType,
Region: p.region,
AcceleratorID: p.acceleratorID,
PoolRef: pool.Name,
Provider: p.provider,
CapacityType: p.capacityType,
Region: p.region,
Accelerator: p.accelerator,
PoolRef: pool.Name,
},
}
err = r.Create(ctx, claim)
Expand Down
22 changes: 11 additions & 11 deletions pkg/failover/blocklist.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,18 @@ type entry struct {
expiresAt time.Time
}

// Candidate is a placement being considered — the (provider, acceleratorID, tier,
// Candidate is a placement being considered — the (provider, accelerator, tier,
// region) tuple the blocklist is queried against. It is the query counterpart to a
// recorded BlockScope: Blocked reports whether any live entry's scope covers it.
// AcceleratorID is the provider's RESOLVED id for what would serve the request
// (the (type, count) mapped through MapAccelerator), not the bare accelerator
// type, so a block recorded against one instance type/capacity pool matches only
// candidates that share it.
// Accelerator is the request's POOL identity (type:count, e.g. "H100:8"), not the
// provider's SKU id, so a block recorded against one (type, count) pool matches
// only candidates that share it — and stays stable even when a launch spans
// several interchangeable provider instance types.
type Candidate struct {
Provider string
AcceleratorID string
CapacityType nebulav1alpha1.CapacityType
Region string
Provider string
Accelerator string
CapacityType nebulav1alpha1.CapacityType
Region string
}

// Blocklist is a concurrency-safe, TTL-bounded set of provider blocks. The zero
Expand Down Expand Up @@ -207,7 +207,7 @@ func scopeCovers(scope provider.BlockScope, c Candidate) bool {
if scope.DenyAll {
return true
}
if !ptrMatches(scope.AcceleratorID, c.AcceleratorID) {
if !ptrMatches(scope.Accelerator, c.Accelerator) {
return false
}
if scope.CapacityType != "" && scope.CapacityType != c.CapacityType {
Expand All @@ -230,7 +230,7 @@ func scopeCovers(scope provider.BlockScope, c Candidate) bool {
func scopeEqual(a, b provider.BlockScope) bool {
return a.DenyAll == b.DenyAll &&
a.CapacityType == b.CapacityType &&
ptrEqual(a.AcceleratorID, b.AcceleratorID) &&
ptrEqual(a.Accelerator, b.Accelerator) &&
ptrEqual(a.Region, b.Region)
}

Expand Down
Loading
Loading