Skip to content

Commit 954879b

Browse files
Meiri28clauderunatom-ai
committed
perf(rm): replace per-iteration sort in greedyAlloc with a min-heap
Follow-up on top of #1621, which introduced the shared greedyAlloc loop with a pluggable replicaComparator (distributed vs packed). The loop still sorts the full candidate slice inside the allocation loop, paying O(n log n) per iteration for n iterations and giving O(n² log n) overall. Since all annotated replicas from the same underlying physical device share the same sort key, sorting at the replica granularity is wasted work — only m (the number of distinct physical devices contributing candidates) needs to be reordered. Refactor greedyAlloc to bucket candidates by their underlying physical device into a small gpuAllocState per device, holding a shared *replicaCount, the pickedFrom counter, and the remaining candidate IDs. A gpuPriorityQueue defers to the caller-supplied replicaComparator on allocated() for primary ordering and to pickedFrom for the tie-break (unchanged semantics). Each iteration pops the best device, takes one of its remaining replicas, updates counters, and pushes it back if any remain. Total cost drops to O(n log m). Both allocation policies (distributed and packed) benefit; no behavior change — the existing test suite (TestDistributedAlloc, TestPackedAlloc, TestPackedVsDistributedContrast, TestDistributedAlloc_PartiallyAllocated_DistributesAcrossDistinctGPUs, etc.) passes unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-Authored-By: runatom-ai <258621014+runatom-ai@users.noreply.github.com> Signed-off-by: Jonathan Meiri <33288957+Meiri28@users.noreply.github.com>
1 parent 5f27eee commit 954879b

1 file changed

Lines changed: 75 additions & 29 deletions

File tree

internal/rm/allocate.go

Lines changed: 75 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
package rm
1818

1919
import (
20+
"container/heap"
2021
"fmt"
21-
"sort"
2222

2323
spec "github.com/NVIDIA/k8s-device-plugin/api/config/v1"
2424
)
@@ -94,6 +94,39 @@ func (r *resourceManager) prepareCandidates(available, required []string, size i
9494
return candidates, replicas, needed, nil
9595
}
9696

97+
// gpuAllocState is the per-physical-GPU bookkeeping the greedy allocator
98+
// tracks while it consumes candidates.
99+
type gpuAllocState struct {
100+
count *replicaCount // shared reference to this GPU's replicaCount
101+
pickedFrom int // slots picked from this GPU during this allocation
102+
replicas []string // remaining annotated-ID candidates for this GPU
103+
}
104+
105+
// gpuPriorityQueue is a heap of *gpuAllocState whose ordering defers to the
106+
// policy comparator on allocated() and falls back to pickedFrom for the
107+
// tie-break so equal-allocated GPUs rotate rather than concentrating on one.
108+
type gpuPriorityQueue struct {
109+
items []*gpuAllocState
110+
preferred replicaComparator
111+
}
112+
113+
func (q *gpuPriorityQueue) Len() int { return len(q.items) }
114+
func (q *gpuPriorityQueue) Less(i, j int) bool {
115+
a, b := q.items[i], q.items[j]
116+
if a.count.allocated() != b.count.allocated() {
117+
return q.preferred(a.count, b.count)
118+
}
119+
return a.pickedFrom < b.pickedFrom
120+
}
121+
func (q *gpuPriorityQueue) Swap(i, j int) { q.items[i], q.items[j] = q.items[j], q.items[i] }
122+
func (q *gpuPriorityQueue) Push(x any) { q.items = append(q.items, x.(*gpuAllocState)) }
123+
func (q *gpuPriorityQueue) Pop() any {
124+
n := len(q.items) - 1
125+
x := q.items[n]
126+
q.items = q.items[:n]
127+
return x
128+
}
129+
97130
// greedyAlloc returns a list of devices by repeatedly selecting the best
98131
// remaining candidate according to the supplied comparator. It takes into
99132
// account already allocated replicas so that consecutive allocations keep
@@ -104,35 +137,48 @@ func (r *resourceManager) greedyAlloc(available, required []string, size int, pr
104137
return nil, err
105138
}
106139

107-
// Track how many slots have already been picked from each physical device
108-
// during this allocation. Used as the tie-break sort key below so that,
109-
// when the comparator ranks two physical GPUs equally, the allocator
110-
// rotates to a sibling device it has touched the least this round. This
111-
// keeps the distributed policy spreading replicas across physical GPUs
112-
// even when their allocated counts tie.
113-
pickedFrom := make(map[string]int)
114-
115-
// Select devices one-by-one. The supplied comparator decides which
116-
// physical GPU is preferred for the current policy. Comparators order
117-
// solely by allocated() (see TestComparatorsOrderSolelyByAllocated), so
118-
// equal allocated counts mean the comparator has no preference and the
119-
// pickedFrom tie-break above applies.
120-
var devices []string
140+
// Bucket candidates by their underlying physical GPU. Each gpuAllocState
141+
// holds a shared *replicaCount so decrementing its available count also
142+
// updates the map entry, keeping a single source of truth.
143+
byGPU := make(map[string]*gpuAllocState)
144+
for _, c := range candidates {
145+
id := AnnotatedID(c).GetID()
146+
item, ok := byGPU[id]
147+
if !ok {
148+
item = &gpuAllocState{count: replicas[id]}
149+
byGPU[id] = item
150+
}
151+
item.replicas = append(item.replicas, c)
152+
}
153+
154+
// Build the heap once. The comparator ranks GPUs on allocated() and the
155+
// pickedFrom tie-break rotates between equal-ranked ones so, e.g., the
156+
// distributed policy keeps spreading replicas across physical GPUs even
157+
// when their allocated counts tie.
158+
pq := &gpuPriorityQueue{
159+
items: make([]*gpuAllocState, 0, len(byGPU)),
160+
preferred: preferred,
161+
}
162+
for _, item := range byGPU {
163+
pq.items = append(pq.items, item)
164+
}
165+
heap.Init(pq)
166+
167+
// Pop the best GPU, take one of its replicas, update counters, push back
168+
// if any remain. Total cost is O(n log m) where n is `needed` and m is
169+
// the number of distinct physical devices contributing candidates.
170+
devices := make([]string, 0, needed)
121171
for i := 0; i < needed; i++ {
122-
sort.Slice(candidates, func(i, j int) bool {
123-
iid := AnnotatedID(candidates[i]).GetID()
124-
jid := AnnotatedID(candidates[j]).GetID()
125-
ri, rj := replicas[iid], replicas[jid]
126-
if ri.allocated() != rj.allocated() {
127-
return preferred(ri, rj)
128-
}
129-
return pickedFrom[iid] < pickedFrom[jid]
130-
})
131-
id := AnnotatedID(candidates[0]).GetID()
132-
pickedFrom[id]++
133-
replicas[id].available--
134-
devices = append(devices, candidates[0])
135-
candidates = candidates[1:]
172+
top := heap.Pop(pq).(*gpuAllocState)
173+
last := len(top.replicas) - 1
174+
pick := top.replicas[last]
175+
top.replicas = top.replicas[:last]
176+
top.count.available--
177+
top.pickedFrom++
178+
if len(top.replicas) > 0 {
179+
heap.Push(pq, top)
180+
}
181+
devices = append(devices, pick)
136182
}
137183

138184
return append(required, devices...), nil

0 commit comments

Comments
 (0)