Skip to content

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

Draft
jonathan-meiri wants to merge 2 commits into
NVIDIA:mainfrom
jonathan-meiri:add-spread-allocation-policy
Draft

Add spread allocation policy for maximum per-pod distinct-GPU coverage#1936
jonathan-meiri wants to merge 2 commits 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 from f5a8c89 to 31db61f Compare July 26, 2026 06:28
Meiri28 and others added 2 commits July 28, 2026 12:45
Follow-up on top of NVIDIA#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>
`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 31db61f to 0d2cb9f Compare July 28, 2026 09:45
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.

2 participants