Skip to content

Add spread allocation policy for maximum per-pod distinct-GPU coverage - #1936

Merged
tariq1890 merged 1 commit into
NVIDIA:mainfrom
jonathan-meiri:add-spread-allocation-policy
Sep 23, 2026
Merged

tariq1890 merged 1 commit into
NVIDIA:mainfrom
jonathan-meiri:add-spread-allocation-policy

Conversation

@jonathan-meiri

Copy link
Copy Markdown

Summary

  • Add spread as a third value for --shared-devices-allocation-policy, alongside distributed (default) and packed.
  • spread prefers physical GPUs the current pod has not yet picked from, maximizing distinct-GPU coverage for a single multi-slot allocation.
  • Default behavior (distributed) is unchanged.

Depends on #1826 (heap refactor) — the current diff includes that commit until #1826 lands, then collapses to just the spread policy addition. Opened as draft for now.

Contributed by @Meiri28 on behalf of @runatom-ai.

Motivation

distributed minimizes cluster-wide load imbalance — it prefers the GPU with the fewest replicas already allocated across all pods. packed does the opposite for bin-packing. Neither guarantees that a single pod's multi-slot request touches distinct physical GPUs.

Consider a node where GPU-0 has 3 free replicas (5 allocated) and GPU-1 has 5 free (3 allocated). A pod requests 2:

┌─────────────────────────────────────────────────────────────────────┐
│              distributed (current default)                          │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  GPU 0                       GPU 1                                  │
│  ┌──────────┐               ┌──────────┐                            │
│  │ █████░░░ │               │ ███░░░░░ │      before                │
│  │  5 alloc │               │  3 alloc │                            │
│  └──────────┘               └──────────┘                            │
│                                                                     │
│  Pod requests 2 slots → distributed picks GPU with fewer allocated  │
│                                                                     │
│  ┌──────────┐               ┌──────────┐                            │
│  │ █████░░░ │               │ █████░░░ │      after                 │
│  │  5 alloc │               │  5 alloc │                            │
│  └──────────┘               └──────────┘                            │
│   ⚠ untouched                 ✓ 2 slots (pod concentrated)          │
│                                                                     │
│  → Pod runs entirely on GPU 1. No NVLink/NCCL across GPUs.          │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│              spread (new)                                           │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  GPU 0                       GPU 1                                  │
│  ┌──────────┐               ┌──────────┐                            │
│  │ █████░░░ │               │ ███░░░░░ │      before                │
│  │  5 alloc │               │  3 alloc │                            │
│  └──────────┘               └──────────┘                            │
│                                                                     │
│  Pod requests 2 slots → spread prefers GPUs untouched by THIS pod   │
│                                                                     │
│  ┌──────────┐               ┌──────────┐                            │
│  │ ██████░░ │               │ ████░░░░ │      after                 │
│  │  6 alloc │               │  4 alloc │                            │
│  └──────────┘               └──────────┘                            │
│   ★ 1 slot                    ★ 1 slot (pod spans both GPUs)        │
│                                                                     │
│  → Multi-GPU workloads (NCCL, DDP, tensor-parallel) get real        │
│    hardware diversity for a single request.                         │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Concrete workloads that benefit:

  • Data-parallel / distributed training — each rank on a distinct physical GPU so ring-allreduce / NCCL spans real hardware
  • Fault-isolated inference — a pod requesting 2 GPUs specifically to survive a single-card failure
  • NVLink topology plays — a first step toward topology-aware allocation (weak preference: at least distinct cards)

@rajatchopra explicitly floated additional policies on #1788:

"we can decide on distributed allocation or a packed one, or a topology driven one"

This adds the third policy that #1621's abstraction was built for.

Design

Small refactor: the replicaComparator signature is enriched from func(i, j *replicaCount) bool to func(i, j *gpuAllocState) bool. Each policy now owns both its primary ordering and tie-break — the queue's Less is a pure passthrough:

distributed: func(i, j *gpuAllocState) bool {
    if i.count.allocated() != j.count.allocated() {
        return i.count.allocated() < j.count.allocated()   // primary
    }
    return i.pickedFrom < j.pickedFrom                     // tie-break
}
packed: func(i, j *gpuAllocState) bool {
    if i.count.allocated() != j.count.allocated() {
        return i.count.allocated() > j.count.allocated()   // primary
    }
    return i.pickedFrom < j.pickedFrom                     // tie-break
}
spread: func(i, j *gpuAllocState) bool {
    if i.pickedFrom != j.pickedFrom {
        return i.pickedFrom < j.pickedFrom                 // primary
    }
    return i.count.allocated() < j.count.allocated()       // tie-break
}

distributed and packed are behavior-preserving — the primary+tie-break they used to get from greedyAlloc's wrapping is now spelled out in the comparator body itself. All existing tests (TestDistributedAlloc, TestPackedAlloc, TestPackedVsDistributedContrast) pass unchanged.

Usage

--shared-devices-allocation-policy=spread
# or
SHARED_DEVICES_ALLOCATION_POLICY=spread

Config file:

version: v1
flags:
  plugin:
    sharedDevicesAllocationPolicy: spread

Commits are DCO-signed.

@copy-pr-bot

copy-pr-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@jonathan-meiri
jonathan-meiri force-pushed the add-spread-allocation-policy branch 2 times, most recently from 31db61f to 0d2cb9f Compare July 28, 2026 09:45
@jonathan-meiri
jonathan-meiri force-pushed the add-spread-allocation-policy branch 2 times, most recently from 7c290ad to 65c4fc3 Compare August 6, 2026 13:13
@jonathan-meiri
jonathan-meiri force-pushed the add-spread-allocation-policy branch from 65c4fc3 to acdf40a Compare August 12, 2026 17:02
@jonathan-meiri
jonathan-meiri force-pushed the add-spread-allocation-policy branch 8 times, most recently from 5102bfb to 83ba5e7 Compare September 3, 2026 04:21
@jonathan-meiri
jonathan-meiri marked this pull request as ready for review September 4, 2026 04:16
@jonathan-meiri
jonathan-meiri force-pushed the add-spread-allocation-policy branch 3 times, most recently from 7cf1fbd to f3eb95c Compare September 4, 2026 04:28
@jonathan-meiri
jonathan-meiri force-pushed the add-spread-allocation-policy branch from f3eb95c to 6a77d8a Compare September 5, 2026 09:03
tariq1890
tariq1890 previously approved these changes Sep 14, 2026
@tariq1890

Copy link
Copy Markdown
Contributor

THanks @jonathan-meiri ! Can you rebase the PR?

@jonathan-meiri
jonathan-meiri force-pushed the add-spread-allocation-policy branch from 6a77d8a to 90ce189 Compare September 14, 2026 18:49
@jonathan-meiri

Copy link
Copy Markdown
Author

Rebased on latest main 🙏
Thanks for the review, @tariq1890!

Comment thread cmd/nvidia-device-plugin/main.go Outdated
@jonathan-meiri
jonathan-meiri force-pushed the add-spread-allocation-policy branch from 90ce189 to a82bb98 Compare September 14, 2026 18:53
Comment thread internal/rm/allocate.go Outdated
@jonathan-meiri
jonathan-meiri force-pushed the add-spread-allocation-policy branch from a82bb98 to 046b76b Compare September 14, 2026 19:14

@abrarshivani abrarshivani left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jonathan-meiri Thanks for working on this. LGTM.

@abrarshivani

Copy link
Copy Markdown
Contributor

/ok to test 046b76b

@tariq1890
tariq1890 dismissed their stale review September 15, 2026 17:06

needs more work

Comment thread internal/rm/allocate.go
Comment on lines +64 to +68
spec.AllocationPolicySpread: func(i, j *gpuAllocState) bool {
if i.pickedFrom != j.pickedFrom {
return i.pickedFrom < j.pickedFrom
}
return i.count.allocated() < j.count.allocated()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about MIG devices?

With MIG, spread would treat each MIG UUID as a distinct physical GPU, yes?. Candidate buckets use device UUIDs, while multiple MIG devices can share one parent. A multi-slot allocation can remain on one physical GPU.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, you're right. Buckets key on the device UUID (GetID), and each MIG instance has its own UUID, so spread spans distinct MIG instances that can share one physical GPU. This is pre-existing (distributed/packed too).

I've kept this PR to the spread comparator and opened #2036 to bucket by parent GPU (from Device.Index) for all policies.

Let me know if you'd prefer to fold that into this PR, or if you're happy keeping it separate.

Comment thread internal/rm/allocate.go Outdated
Comment on lines +163 to +172
// Seed pickedFrom from required so a GPU that already holds a required
// replica counts as touched by this allocation. Otherwise spread, which
// orders primarily by pickedFrom, could pick that same physical GPU again
// instead of spanning a new one.
for _, req := range required {
if item, ok := byGPU[AnnotatedID(req).GetID()]; ok {
item.pickedFrom++
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I am not wrong, this changes the tie-breaking logic and it would apply to the distributed and packed allocation policies. This goes beyond the scope of this PR - which is an introduction of a new allocation policy

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, seeding pickedFrom leaked into distributed/packed. Fixed: pickedFrom stays pure, required accounting is now spread-only (touched()), with a test to lock it in.

Packed's tie-break could improve too, but that's pre-existing. I'll follow up separately.

@jonathan-meiri
jonathan-meiri force-pushed the add-spread-allocation-policy branch 3 times, most recently from 3d1abfb to 96de15f Compare September 15, 2026 19:28
jonathan-meiri pushed a commit to jonathan-meiri/k8s-device-plugin that referenced this pull request Sep 15, 2026
Allocation buckets keyed on the device UUID, so each MIG instance looked
like a distinct physical GPU. distributed/packed/spread could therefore
stack a multi-slot request onto MIG instances of one physical card.

Bucket by physical GPU instead: MIG instances group by parent (from
Device.Index), other devices by UUID. Follow-up to NVIDIA#1936.

Co-Authored-By: Claude Opus 4.8 <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>
@jonathan-meiri
jonathan-meiri force-pushed the add-spread-allocation-policy branch from 96de15f to 3f86af2 Compare September 19, 2026 13:58
jonathan-meiri pushed a commit to jonathan-meiri/k8s-device-plugin that referenced this pull request Sep 19, 2026
Allocation buckets keyed on the device UUID, so each MIG instance looked
like a distinct physical GPU. distributed/packed/spread could therefore
stack a multi-slot request onto MIG instances of one physical card.

Bucket by physical GPU instead: MIG instances group by parent (from
Device.Index), other devices by UUID. Follow-up to NVIDIA#1936.

Co-Authored-By: Claude Opus 4.8 <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>
Comment thread internal/rm/allocate.go Outdated
Comment on lines 181 to 184

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This godoc needs to be updated now that the spread comparator has been updated to use the touched() method as a primary sort key.

@jonathan-meiri
jonathan-meiri force-pushed the add-spread-allocation-policy branch from 3f86af2 to 37fece5 Compare September 22, 2026 05:54
`distributed` minimizes cluster-wide load imbalance — it prefers the
GPU with the fewest replicas already allocated across all pods.
`packed` does the opposite for bin-packing. Neither guarantees that a
single pod's multi-slot request touches distinct physical GPUs: given
GPU-0 with 5 allocated and GPU-1 with 3 allocated, a pod requesting 2
slots under `distributed` gets both slots on GPU-1, because GPU-1 has
the lower cluster-wide allocated count both before and after the first
pick.

Add a third policy, `spread`, that primarily orders by pickedFrom (the
per-allocation counter of how many slots the current pod has taken
from each GPU) and only tie-breaks by allocated(). The pod's own picks
therefore drive selection: after taking one slot from GPU-1, GPU-0
becomes preferred (pickedFrom=0 < 1) regardless of cluster-wide load.
Result: the pod's slots span as many distinct physical GPUs as
possible, which is what multi-GPU workloads (data-parallel training,
NCCL, tensor-parallel) actually need.

The `replicaComparator` signature is enriched from
`func(i, j *replicaCount) bool` to `func(i, j *gpuAllocState) bool`
so a comparator can freely mix cluster-wide state (allocated()) and
per-allocation state (pickedFrom). Each policy now owns both its
primary ordering and its tie-break; the queue's Less becomes a pure
passthrough. distributed and packed are behavior-preserving — the
primary+tie-break they used to get from greedyAlloc's wrapping is
spelled out in the comparator body itself.

Validate `spread` in main.go alongside distributed and packed. Add
TestSpreadAlloc mirroring the existing policy suites plus
TestSpreadPrefersUntouchedGPU and TestSpreadPrefersDistinctGPUsEvenWhenUnbalanced
for the defining behavior. TestComparatorsOrderSolelyByAllocated
narrows to distributed/packed since spread intentionally violates
that invariant.

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>
@jonathan-meiri
jonathan-meiri force-pushed the add-spread-allocation-policy branch from 37fece5 to 7062bd0 Compare September 22, 2026 05:55
jonathan-meiri pushed a commit to jonathan-meiri/k8s-device-plugin that referenced this pull request Sep 22, 2026
Allocation buckets keyed on the device UUID, so each MIG instance looked
like a distinct physical GPU. distributed/packed/spread could therefore
stack a multi-slot request onto MIG instances of one physical card.

Bucket by physical GPU instead: MIG instances group by parent (from
Device.Index), other devices by UUID. Follow-up to NVIDIA#1936.

Co-Authored-By: Claude Opus 4.8 <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>
@tariq1890

Copy link
Copy Markdown
Contributor

/ok to test 7062bd0

@tariq1890
tariq1890 merged commit 86142cf into NVIDIA:main Sep 23, 2026
12 checks passed
@jonathan-meiri

Copy link
Copy Markdown
Author

Thanks @tariq1890 and @abrarshivani for the thorough reviews and the merge, really appreciate it!

The MIG parent-GPU bucketing follow-up we discussed is up as #2036 whenever you have a chance.

jonathan-meiri pushed a commit to jonathan-meiri/k8s-device-plugin that referenced this pull request Sep 23, 2026
Allocation buckets keyed on the device UUID, so each MIG instance looked
like a distinct physical GPU. distributed/packed/spread could therefore
stack a multi-slot request onto MIG instances of one physical card.

Bucket by physical GPU instead: MIG instances group by parent (from
Device.Index), other devices by UUID. Follow-up to NVIDIA#1936.

Co-Authored-By: Claude Opus 4.8 <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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants