feat(gpu): multi-GPU instance types (#1099)

This commit is contained in:
Julian Sommer
2026-09-23 09:02:09 +10:00
committed by GitHub
parent 5c5a2bc0b7
commit 71059834e7
34 changed files with 1532 additions and 191 deletions
+15
View File
@@ -136,6 +136,21 @@ aws ec2 run-instances \
--key-name spinifex-key
```
### Multi-GPU Instances
The GPU count comes from the instance type, exactly as it does in EC2. AWS multi-GPU types pair eight GPUs with a very large host — `p4d.24xlarge` is 96 vCPU and 1152 GiB, — so an edge rig with eight cards and 256 GiB cannot admit them even though it has the GPUs. For those hosts Spinifex offers its own `gpu.*` family, which is not an EC2 family:
```bash
aws ec2 run-instances \
--image-id $GPU_AMI \
--instance-type gpu.8x4c \
--key-name spinifex-key
```
`gpu.<count>x<vcpu>c` is that many GPUs at that many vCPU each, so `gpu.8x4c` is eight GPUs on 32 vCPU and 128 GiB. Counts run in three tiers — `2c`, `4c` and `8c` — all at 4 GiB of memory per vCPU. The GPU model and VRAM reported in `DescribeInstanceTypes` are whatever the node discovered, so a `gpu.8x4c` on an 8×RTX 3090 box advertises eight RTX 3090s at 24 GiB each.
A node holding more than one distinct GPU model offers single-GPU types only, and logs each withheld type by name. Two devices count as the same model when their reported name and VRAM match, which keeps a mixed-SKU H100 node fully usable.
To verify the GPU is visible from inside the instance, SSH in and run:
```bash
+17 -2
View File
@@ -7,6 +7,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/instancetypes"
)
// capacityReservation is an in-memory On-Demand Capacity Reservation pinned to
@@ -83,7 +84,8 @@ func (rm *ResourceManager) CancelReservation(id, accountID string) (*capacityRes
// at create moves to allocated* and ConsumedCount bumps, so schedulable capacity
// is unchanged by construction. Errors: unknown/foreign id ->
// InvalidCapacityReservationId.NotFound; type mismatch -> InvalidParameterValue;
// already full -> ReservationCapacityExceeded.
// already full -> ReservationCapacityExceeded; no free GPU ->
// InsufficientInstanceCapacity.
func (rm *ResourceManager) AllocateFromReservation(crID, accountID string, it *ec2.InstanceTypeInfo) error {
rm.mu.Lock()
rec, ok := rm.reservations[crID]
@@ -99,6 +101,13 @@ func (rm *ResourceManager) AllocateFromReservation(crID, accountID string, it *e
rm.mu.Unlock()
return errors.New(awserrors.ErrorReservationCapacityExceeded)
}
// Re-checked under the write lock, as the general path does: two targeted
// launches can both clear ReservationAvailable and only one find a GPU.
if instancetypes.IsGPUType(it) &&
rm.admissibleGPUInstances(aws.StringValue(it.InstanceType)) < 1 {
rm.mu.Unlock()
return errors.New(awserrors.ErrorInsufficientInstanceCapacity)
}
rm.reservedCRVCPU -= rec.VCPUPerInstance
rm.reservedCRMem -= rec.MemGBPerInstance
rm.allocatedVCPU += rec.VCPUPerInstance
@@ -136,6 +145,8 @@ func (rm *ResourceManager) ReleaseToReservation(crID string, it *ec2.InstanceTyp
// (Total - Consumed), or 0 when the reservation is unknown, owned by another
// account, or for a different instance type. The daemon handler's up-front check
// turns those zero cases into precise errors before the launch loop.
// A GPU type is additionally gated on free GPUs: a reservation reserves no pool
// entry, so its free slots say nothing about whether a GPU is there to claim.
func (rm *ResourceManager) ReservationAvailable(crID, accountID string, it *ec2.InstanceTypeInfo) int {
rm.mu.RLock()
defer rm.mu.RUnlock()
@@ -143,7 +154,11 @@ func (rm *ResourceManager) ReservationAvailable(crID, accountID string, it *ec2.
if !ok || rec.AccountID != accountID || rec.InstanceType != aws.StringValue(it.InstanceType) {
return 0
}
return rec.TotalInstanceCount - rec.ConsumedCount
free := rec.TotalInstanceCount - rec.ConsumedCount
if instancetypes.IsGPUType(it) {
free = min(free, rm.admissibleGPUInstances(aws.StringValue(it.InstanceType)))
}
return free
}
// ValidateReservationTarget is the owning daemon's up-front semantic check for a
+31 -15
View File
@@ -597,15 +597,7 @@ func (rm *ResourceManager) GetAvailableInstanceTypeInfos(showCapacity bool) []*e
// GPU types are capacity-gated by GPU pool size, not host CPU/memory.
// The GPU is the scarce resource; CPU/memory on GPU-class hardware is abundant.
if instancetypes.IsGPUType(it) {
availGPU := 0
if rm.gpuManager != nil {
availGPU = rm.gpuManager.Available()
}
gpusNeeded := instancetypes.GPUCountForType(name)
count := 0
if gpusNeeded > 0 {
count = availGPU / gpusNeeded
}
count := rm.admissibleGPUInstances(name)
if showCapacity {
for range count {
infos = append(infos, it)
@@ -696,6 +688,12 @@ func (rm *ResourceManager) GetResourceStats() (totalVCPU int, totalMemGB float64
if typeCap.VCPU == 0 || typeCap.MemoryGB == 0 {
continue
}
// CPU and memory never bind first on a gpu.* shape, so without this the
// census calls a GPU type schedulable on a node with no free GPU. EKS
// host selection and reservation creation both read this figure.
if instancetypes.IsGPUType(it) {
typeCap.Available = min(typeCap.Available, rm.admissibleGPUInstances(name))
}
caps = append(caps, typeCap)
}
return totalVCPU, totalMemGB, reservedVCPU, reservedMemGB, allocVCPU, allocMemGB, caps
@@ -2757,8 +2755,11 @@ func (d *Daemon) applyGPUConfig(enabled bool) {
}
mgr, models, migProfiles := buildGPUPool(probe.Devices, d.config.Daemon)
d.gpuManager = mgr
d.resourceMgr.reloadGPUTypes(models, migProfiles, mgr)
// Claimer before types: reloadGPUTypes starts advertising GPU types and
// admitting against them, and a launch landing before the claimer is
// wired would otherwise boot without the GPUs it was admitted for.
d.instanceService.SetGPUClaimer(&daemonGPUClaimer{d: d})
d.resourceMgr.reloadGPUTypes(models, migProfiles, mgr)
slog.Info("GPU passthrough enabled via config reload", "gpus", len(probe.Devices))
return
}
@@ -2914,11 +2915,8 @@ func (rm *ResourceManager) admitLocked(instanceType *ec2.InstanceTypeInfo, count
requiresGPU := instancetypes.IsGPUType(instanceType)
availGPU := 0
if requiresGPU && rm.gpuManager != nil {
gpusNeeded := instancetypes.GPUCountForType(instanceTypeName)
if gpusNeeded > 0 {
availGPU = rm.gpuManager.Available() / gpusNeeded
}
if requiresGPU {
availGPU = rm.admissibleGPUInstances(instanceTypeName)
}
budget := canAllocateCount(
@@ -2936,6 +2934,24 @@ func (rm *ResourceManager) admitLocked(instanceType *ec2.InstanceTypeInfo, count
return n, "budget"
}
// admissibleGPUInstances returns how many instances of a GPU type the free pool
// can back. Whole GPUs and MIG slices count separately, so a node with free
// slices and no free whole GPU refuses a whole-GPU type rather than failing it.
func (rm *ResourceManager) admissibleGPUInstances(instanceTypeName string) int {
if rm.gpuManager == nil {
return 0
}
gpusNeeded := instancetypes.GPUCountForType(instanceTypeName)
if gpusNeeded <= 0 {
return 0
}
if instancetypes.IsMIGType(instanceTypeName) {
profile := instancetypes.MIGProfileFromType(instanceTypeName)
return rm.gpuManager.AvailableSlices(profile) / gpusNeeded
}
return rm.gpuManager.AvailableWhole() / gpusNeeded
}
// liveMemGate clamps n by current MemAvailable, catching overcommit that the
// static -m budget misses. Returns n unchanged when disabled or on read error.
func (rm *ResourceManager) liveMemGate(n int, instanceType *ec2.InstanceTypeInfo) int {
+27 -22
View File
@@ -133,31 +133,37 @@ func TestHandleNodeStatus_MIG(t *testing.T) {
assert.Empty(t, byGI[2].InstanceID, "unclaimed slice is free")
}
func TestHandleNodeVMs_GPUAttachment_WholeGPU(t *testing.T) {
func TestHandleNodeVMs_GPUAttachments_WholeGPU(t *testing.T) {
daemon := createTestDaemon(t, sharedNATSURL)
instanceType := getTestInstanceType(t)
dev := gpu.GPUDevice{PCIAddress: "0000:01:00.0", Model: "NVIDIA A10", MemoryMiB: 23028, IOMMUGroup: -1}
mgr := gpu.NewManager([]gpu.GPUDevice{dev})
require.NoError(t, mgr.ReclaimByAddress(dev.PCIAddress, "i-gpu-vm"))
first := gpu.GPUDevice{PCIAddress: "0000:01:00.0", Model: "NVIDIA A10", MemoryMiB: 23028, IOMMUGroup: -1}
second := gpu.GPUDevice{PCIAddress: "0000:02:00.0", Model: "NVIDIA A10", MemoryMiB: 23028, IOMMUGroup: -1}
mgr := gpu.NewManager([]gpu.GPUDevice{first, second})
require.NoError(t, mgr.ReclaimByAddress(first.PCIAddress, "i-gpu-vm"))
require.NoError(t, mgr.ReclaimByAddress(second.PCIAddress, "i-gpu-vm"))
daemon.gpuManager = mgr
daemon.vmMgr.Insert(&vm.VM{
ID: "i-gpu-vm",
Status: vm.StateRunning,
InstanceType: instanceType,
GPUAttachments: []gpu.GPUAttachment{{PCIAddress: dev.PCIAddress}},
ID: "i-gpu-vm",
Status: vm.StateRunning,
InstanceType: instanceType,
GPUAttachments: []gpu.GPUAttachment{
{PCIAddress: first.PCIAddress},
{PCIAddress: second.PCIAddress},
},
})
resp := gpuVMsRequest(t, daemon, "spinifex.node.vms.gpuwhole")
require.Len(t, resp.VMs, 1)
require.NotNil(t, resp.VMs[0].GPU)
assert.Equal(t, "NVIDIA A10", resp.VMs[0].GPU.Model)
assert.Equal(t, int64(23028), resp.VMs[0].GPU.VRAMMiB)
assert.Equal(t, dev.PCIAddress, resp.VMs[0].GPU.PCIAddress)
assert.Empty(t, resp.VMs[0].GPU.Profile)
assert.Empty(t, resp.VMs[0].GPU.MdevPath)
require.Len(t, resp.VMs[0].GPUs, 2)
assert.Equal(t, "NVIDIA A10", resp.VMs[0].GPUs[0].Model)
assert.Equal(t, int64(23028), resp.VMs[0].GPUs[0].VRAMMiB)
assert.Equal(t, first.PCIAddress, resp.VMs[0].GPUs[0].PCIAddress)
assert.Equal(t, second.PCIAddress, resp.VMs[0].GPUs[1].PCIAddress)
assert.Empty(t, resp.VMs[0].GPUs[0].Profile)
assert.Empty(t, resp.VMs[0].GPUs[0].MdevPath)
}
func TestHandleNodeVMs_GPUAttachment_MIG(t *testing.T) {
@@ -185,13 +191,12 @@ func TestHandleNodeVMs_GPUAttachment_MIG(t *testing.T) {
resp := gpuVMsRequest(t, daemon, "spinifex.node.vms.gpumig")
require.Len(t, resp.VMs, 1)
require.NotNil(t, resp.VMs[0].GPU)
assert.Equal(t, dev.Model, resp.VMs[0].GPU.Model)
assert.Equal(t, int64(24576), resp.VMs[0].GPU.VRAMMiB)
assert.Equal(t, "1g.24gb", resp.VMs[0].GPU.Profile)
assert.Equal(t, mdevPath, resp.VMs[0].GPU.MdevPath)
assert.Empty(t, resp.VMs[0].GPU.PCIAddress)
require.Len(t, resp.VMs[0].GPUs, 1)
assert.Equal(t, dev.Model, resp.VMs[0].GPUs[0].Model)
assert.Equal(t, int64(24576), resp.VMs[0].GPUs[0].VRAMMiB)
assert.Equal(t, "1g.24gb", resp.VMs[0].GPUs[0].Profile)
assert.Equal(t, mdevPath, resp.VMs[0].GPUs[0].MdevPath)
assert.Empty(t, resp.VMs[0].GPUs[0].PCIAddress)
}
func TestHandleNodeVMs_GPUAttachment_Absent(t *testing.T) {
@@ -207,5 +212,5 @@ func TestHandleNodeVMs_GPUAttachment_Absent(t *testing.T) {
resp := gpuVMsRequest(t, daemon, "spinifex.node.vms.nogpu")
require.Len(t, resp.VMs, 1)
assert.Nil(t, resp.VMs[0].GPU)
assert.Empty(t, resp.VMs[0].GPUs)
}
+21 -3
View File
@@ -600,6 +600,26 @@ func resolveVMGPU(att gpu.GPUAttachment, byMdev, byPCI map[string]gpu.PoolEntry)
return nil
}
// resolveVMGPUs resolves every attachment it can, reporting a short list rather
// than passing it off as complete. One unresolved attachment used to read as no
// GPU at all; eight make a shortened list a plausible wrong answer.
func resolveVMGPUs(instanceID string, attachments []gpu.GPUAttachment, byMdev, byPCI map[string]gpu.PoolEntry) []types.VMGPUInfo {
gpus := make([]types.VMGPUInfo, 0, len(attachments))
for _, attachment := range attachments {
if info := resolveVMGPU(attachment, byMdev, byPCI); info != nil {
gpus = append(gpus, *info)
continue
}
slog.Warn("VM GPU attachment has no pool entry",
"instanceId", instanceID, "pci", attachment.PCIAddress, "mdev", attachment.MdevPath)
}
if len(gpus) != len(attachments) {
slog.Warn("VM holds GPU attachments that did not resolve",
"instanceId", instanceID, "attached", len(attachments), "resolved", len(gpus))
}
return gpus
}
// handleNodeVMs responds with the list of VMs running on this node.
// Used by the CLI: spx get vms.
func (d *Daemon) handleNodeVMs(msg *nats.Msg) string {
@@ -622,9 +642,7 @@ func (d *Daemon) handleNodeVMs(msg *nats.Msg) string {
if v.Instance != nil && v.Instance.LaunchTime != nil {
info.LaunchTime = v.Instance.LaunchTime.Unix()
}
if len(v.GPUAttachments) > 0 {
info.GPU = resolveVMGPU(v.GPUAttachments[0], poolByMdev, poolByPCI)
}
info.GPUs = resolveVMGPUs(v.ID, v.GPUAttachments, poolByMdev, poolByPCI)
vms = append(vms, info)
})
+41 -11
View File
@@ -1,6 +1,9 @@
package daemon
import (
"errors"
"fmt"
"github.com/mulgadc/spinifex/spinifex/gpu"
handlers_ec2_instance "github.com/mulgadc/spinifex/spinifex/handlers/ec2/instance"
)
@@ -14,20 +17,47 @@ type daemonGPUClaimer struct {
var _ handlers_ec2_instance.GPUClaimer = (*daemonGPUClaimer)(nil)
func (g *daemonGPUClaimer) Claim(instanceID, profileName string) (*gpu.GPUAttachment, error) {
dev, mig, err := g.d.gpuManager.Claim(instanceID, profileName)
if err != nil {
return nil, err
func (g *daemonGPUClaimer) manager() *gpu.Manager {
g.d.mu.Lock()
defer g.d.mu.Unlock()
return g.d.gpuManager
}
func (g *daemonGPUClaimer) Claim(instanceID, profileName string, count int) ([]gpu.GPUAttachment, error) {
if count < 1 {
return nil, fmt.Errorf("GPU claim count must be positive: %d", count)
}
if mig != nil {
return &gpu.GPUAttachment{MdevPath: mig.MdevPath}, nil
mgr := g.manager()
if mgr == nil {
return nil, errors.New("GPU manager is unavailable")
}
return &gpu.GPUAttachment{
PCIAddress: dev.PCIAddress,
XVGAEnabled: gpuXVGAEnabled(dev, g.d.config.Daemon.GPUModelOverrides),
}, nil
attachments := make([]gpu.GPUAttachment, 0, count)
for range count {
dev, mig, err := mgr.Claim(instanceID, profileName)
if err != nil {
if rollbackErr := mgr.Release(instanceID); rollbackErr != nil &&
!errors.Is(rollbackErr, gpu.ErrNoGPUClaimed) {
return nil, errors.Join(err, fmt.Errorf("roll back GPU claims: %w", rollbackErr))
}
return nil, err
}
if mig != nil {
attachments = append(attachments, gpu.GPUAttachment{MdevPath: mig.MdevPath})
continue
}
attachments = append(attachments, gpu.GPUAttachment{
PCIAddress: dev.PCIAddress,
XVGAEnabled: gpuXVGAEnabled(dev, g.d.config.Daemon.GPUModelOverrides),
})
}
return attachments, nil
}
func (g *daemonGPUClaimer) Release(instanceID string) error {
return g.d.gpuManager.Release(instanceID)
mgr := g.manager()
if mgr == nil {
return gpu.ErrNoGPUClaimed
}
return mgr.Release(instanceID)
}
+55
View File
@@ -0,0 +1,55 @@
//test:in-package — daemonGPUClaimer and its Daemon field are unexported;
package daemon
import (
"os"
"path/filepath"
"testing"
"github.com/mulgadc/spinifex/spinifex/gpu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newMIGClaimManager(t *testing.T, count int) *gpu.Manager {
t.Helper()
dev := gpu.GPUDevice{PCIAddress: "0000:03:00.0", Model: "NVIDIA A10"}
instances := make([]gpu.MIGInstance, 0, count)
for i := range count {
mdevPath := filepath.Join(t.TempDir(), "mdev")
require.NoError(t, os.WriteFile(mdevPath, []byte("mdev"), 0o600))
instances = append(instances, gpu.MIGInstance{
GIID: i + 1,
MdevPath: mdevPath,
Profile: gpu.MIGProfile{Name: "1g.10gb", MemoryMiB: 10 * 1024},
})
}
mgr := gpu.NewManager(nil)
mgr.AddMIGInstances(dev, instances)
return mgr
}
func TestDaemonGPUClaimerClaimsRequestedCount(t *testing.T) {
mgr := newMIGClaimManager(t, 2)
claimer := &daemonGPUClaimer{d: &Daemon{gpuManager: mgr}}
attachments, err := claimer.Claim("i-two-gpu", "1g.10gb", 2)
require.NoError(t, err)
require.Len(t, attachments, 2)
assert.NotEqual(t, attachments[0].MdevPath, attachments[1].MdevPath)
assert.Zero(t, mgr.AvailableSlices("1g.10gb"))
require.NoError(t, claimer.Release("i-two-gpu"))
assert.Equal(t, 2, mgr.AvailableSlices("1g.10gb"))
}
func TestDaemonGPUClaimerRollsBackPartialClaim(t *testing.T) {
mgr := newMIGClaimManager(t, 1)
claimer := &daemonGPUClaimer{d: &Daemon{gpuManager: mgr}}
attachments, err := claimer.Claim("i-too-many", "1g.10gb", 2)
require.Error(t, err)
assert.Nil(t, attachments)
assert.Equal(t, 1, mgr.AvailableSlices("1g.10gb"))
}
+234
View File
@@ -0,0 +1,234 @@
//test:in-package — drives ResourceManager.canAllocateLocked and builds
package daemon
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/gpu"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// gpuTypeForTest builds an InstanceTypeInfo whose cpu/mem fit the test node
// comfortably, so only the GPU gate can bind the admission count.
func gpuTypeForTest(name string, gpus int64) *ec2.InstanceTypeInfo {
return &ec2.InstanceTypeInfo{
InstanceType: aws.String(name),
VCpuInfo: &ec2.VCpuInfo{DefaultVCpus: aws.Int64(2)},
MemoryInfo: &ec2.MemoryInfo{SizeInMiB: aws.Int64(1024)},
GpuInfo: &ec2.GpuInfo{
Gpus: []*ec2.GpuDeviceInfo{{
Count: aws.Int64(gpus),
Manufacturer: aws.String("NVIDIA"),
Name: aws.String("A10G"),
MemoryInfo: &ec2.GpuDeviceMemoryInfo{SizeInMiB: aws.Int64(24576)},
}},
TotalGpuMemoryInMiB: aws.Int64(24576 * gpus),
},
}
}
// carvedMIGManager returns a manager holding only carved MIG slices — no whole
// GPU is free.
func carvedMIGManager(t *testing.T, slices int) *gpu.Manager {
t.Helper()
root := t.TempDir()
dev := gpu.GPUDevice{
PCIAddress: "0000:01:00.0", VendorID: "10de", DeviceID: "20b5",
MIGCapable: true, MIGEnabled: true, IOMMUGroup: -1,
}
instances := make([]gpu.MIGInstance, slices)
for i := range instances {
path := filepath.Join(root, string(rune('a'+i)))
require.NoError(t, os.MkdirAll(path, 0755))
instances[i] = gpu.MIGInstance{
GIID: i + 1,
MdevPath: path,
Profile: gpu.MIGProfile{Name: "1g.10gb", MemoryMiB: 10240},
}
}
m := gpu.NewManager(nil)
m.AddMIGInstances(dev, instances)
return m
}
// A node with free slices and no free whole GPU must refuse a whole-GPU type at
// admission, not fail it later at Claim.
func TestAdmission_FreeSlicesDoNotBackAWholeGPUType(t *testing.T) {
rm := &ResourceManager{
hostVCPU: 64,
hostMemGB: 256.0,
gpuManager: carvedMIGManager(t, 7),
}
assert.Zero(t, rm.canAllocateLocked(gpuTypeForTest("g5.xlarge", 1), 10),
"seven slices are not one whole GPU")
assert.Equal(t, 7, rm.canAllocateLocked(gpuTypeForTest("mig.1g.10gb", 1), 10),
"the same seven slices back seven MIG instances")
assert.Zero(t, rm.canAllocateLocked(gpuTypeForTest("mig.3g.40gb", 1), 10),
"a profile with no slices carved is not available")
}
// A multi-GPU type divides the free whole GPUs by its own count.
func TestAdmission_MultiGPUTypeDividesTheWholePool(t *testing.T) {
devices := make([]gpu.GPUDevice, 8)
for i := range devices {
devices[i] = gpu.GPUDevice{PCIAddress: string(rune('a' + i)), IOMMUGroup: i}
}
rm := &ResourceManager{
hostVCPU: 256,
hostMemGB: 1024.0,
gpuManager: gpu.NewManager(devices),
}
assert.Equal(t, 8, rm.canAllocateLocked(gpuTypeForTest("gpu.1x4c", 1), 10))
assert.Equal(t, 2, rm.canAllocateLocked(gpuTypeForTest("gpu.4x4c", 4), 10))
assert.Equal(t, 1, rm.canAllocateLocked(gpuTypeForTest("g5.48xlarge", 8), 10))
}
// A reservation is a counter over vCPU and memory slots and reserves no pool
// entry, so its free slots say nothing about whether a GPU is there to claim.
func TestReservationAvailable_GatesOnFreeGPUs(t *testing.T) {
gpuType := gpuTypeForTest("gpu.4x4c", 4)
devices := make([]gpu.GPUDevice, 4)
for i := range devices {
devices[i] = gpu.GPUDevice{PCIAddress: string(rune('a' + i)), IOMMUGroup: i}
}
mgr := gpu.NewManager(devices)
rm := &ResourceManager{
hostVCPU: 64,
hostMemGB: 256.0,
gpuManager: mgr,
instanceTypes: map[string]*ec2.InstanceTypeInfo{"gpu.4x4c": gpuType},
reservations: make(map[string]*capacityReservation),
}
// Three slots reserved, but only four GPUs on the node — one instance.
require.NoError(t, rm.CreateReservation(&capacityReservation{
ID: "cr-gpu",
AccountID: "acct-a",
InstanceType: "gpu.4x4c",
AvailabilityZone: "ap-southeast-2a",
TotalInstanceCount: 3,
VCPUPerInstance: 2,
MemGBPerInstance: 1.0,
InstanceMatchCriteria: "open",
Tenancy: "default",
InstancePlatform: "Linux/UNIX",
CreateDate: time.Now(),
}))
assert.Equal(t, 1, rm.ReservationAvailable("cr-gpu", "acct-a", gpuType),
"three reserved slots cannot outrun four GPUs at four per instance")
// With the GPUs gone from the pool the reservation backs nothing at all.
for i := range devices {
mgr.MarkFailed(devices[i].PCIAddress)
}
assert.Zero(t, rm.ReservationAvailable("cr-gpu", "acct-a", gpuType))
}
// ReservationAvailable reserves nothing, so two targeted launches can both
// clear it. Without the write-lock re-check the loser consumes a reservation
// slot and then dies at claim time with no GPU to take.
func TestAllocateFromReservation_RechecksFreeGPUs(t *testing.T) {
gpuType := gpuTypeForTest("gpu.4x4c", 4)
devices := make([]gpu.GPUDevice, 4)
for i := range devices {
devices[i] = gpu.GPUDevice{PCIAddress: string(rune('a' + i)), IOMMUGroup: i}
}
mgr := gpu.NewManager(devices)
rm := &ResourceManager{
hostVCPU: 64,
hostMemGB: 256.0,
gpuManager: mgr,
instanceTypes: map[string]*ec2.InstanceTypeInfo{"gpu.4x4c": gpuType},
reservations: make(map[string]*capacityReservation),
}
require.NoError(t, rm.CreateReservation(&capacityReservation{
ID: "cr-gpu",
AccountID: "acct-a",
InstanceType: "gpu.4x4c",
AvailabilityZone: "ap-southeast-2a",
TotalInstanceCount: 3,
VCPUPerInstance: 2,
MemGBPerInstance: 1.0,
InstanceMatchCriteria: "open",
Tenancy: "default",
InstancePlatform: "Linux/UNIX",
CreateDate: time.Now(),
}))
require.NoError(t, rm.AllocateFromReservation("cr-gpu", "acct-a", gpuType),
"the first launch takes the node's only four GPUs")
// The GPUs leave the pool between the two calls — claimed by the winner of
// the race, failed, or dropped by a SIGHUP rebuild. Two slots still remain.
for i := range devices {
mgr.MarkFailed(devices[i].PCIAddress)
}
err := rm.AllocateFromReservation("cr-gpu", "acct-a", gpuType)
require.Error(t, err, "free slots do not conjure a GPU")
assert.Equal(t, awserrors.ErrorInsufficientInstanceCapacity, err.Error(),
"the reservation is not what ran out")
}
// The node census sized GPU types on host CPU and memory alone, which for the
// gpu.* shapes is never the binding constraint. EKS host selection and
// CreateCapacityReservation both size themselves from this figure.
func TestGetResourceStats_GPUTypesAreGatedOnGPUs(t *testing.T) {
gpuType := gpuTypeForTest("gpu.4x4c", 4)
devices := []gpu.GPUDevice{
{PCIAddress: "a", IOMMUGroup: 0},
{PCIAddress: "b", IOMMUGroup: 1},
{PCIAddress: "c", IOMMUGroup: 2},
{PCIAddress: "d", IOMMUGroup: 3},
}
mgr := gpu.NewManager(devices)
rm := &ResourceManager{
hostVCPU: 64,
hostMemGB: 256.0,
gpuManager: mgr,
instanceTypes: map[string]*ec2.InstanceTypeInfo{"gpu.4x4c": gpuType},
reservations: make(map[string]*capacityReservation),
}
capFor := func() types.InstanceTypeCap {
t.Helper()
_, _, _, _, _, _, caps := rm.GetResourceStats()
for _, c := range caps {
if c.Name == "gpu.4x4c" {
return c
}
}
t.Fatal("gpu.4x4c missing from the census")
return types.InstanceTypeCap{}
}
assert.Equal(t, 1, capFor().Available, "four GPUs back one four-GPU instance")
for i := range devices {
mgr.MarkFailed(devices[i].PCIAddress)
}
assert.Zero(t, capFor().Available,
"a node with no free GPU must not advertise a GPU type as schedulable")
}
// A non-GPU reservation keeps answering on slots alone.
func TestReservationAvailable_NonGPUTypeIsUngated(t *testing.T) {
rm := newReservationTestRM()
rm.gpuManager = gpu.NewManager(nil)
require.NoError(t, rm.CreateReservation(microReservation("cr-1", "acct-a", 2)))
assert.Equal(t, 2, rm.ReservationAvailable("cr-1", "acct-a", microType()))
}
+9 -3
View File
@@ -1,6 +1,7 @@
package daemon
import (
"fmt"
"testing"
"github.com/aws/aws-sdk-go/aws"
@@ -388,14 +389,19 @@ func TestCanAllocateLocked_MIGvsWholeGPU(t *testing.T) {
}
// Host with 8 schedulable vCPUs and 32 GiB schedulable memory (after
// reserve), and a GPU pool with 5 free slots — plenty for the GPU-slot
// gate so these assertions exercise the cpu/mem gate in isolation.
// reserve), and a GPU pool with 5 free whole GPUs and 5 MIG-capable ones —
// plenty for either GPU gate, so these assertions exercise the cpu/mem gate
// in isolation.
mgr := gpu.NewManager(make([]gpu.GPUDevice, 5))
for i := range 5 {
mgr.AddMIGGPU(gpu.GPUDevice{PCIAddress: fmt.Sprintf("0000:8%d:00.0", i)})
}
rm := &ResourceManager{
hostVCPU: 10,
hostMemGB: 34.0,
reservedVCPU: 2,
reservedMem: 2.0,
gpuManager: gpu.NewManager(make([]gpu.GPUDevice, 5)),
gpuManager: mgr,
}
migType := &ec2.InstanceTypeInfo{
+43 -2
View File
@@ -245,6 +245,11 @@ func (m *Manager) Release(instanceID string) error {
continue
}
// Scoped to this entry, not the whole release: an instance can hold
// several GPUs, and one that unbound cleanly stays available however
// its siblings fared.
var entryErr error
for _, member := range entry.groupMembers {
orig := entry.memberDrivers[member.PCIAddress]
if orig == "vfio-pci" {
@@ -258,6 +263,9 @@ func (m *Manager) Release(instanceID string) error {
if err := unbindVFIO(m.sysfsRoot, member.PCIAddress, orig); err != nil {
slog.Error("GPU release failed for IOMMU group member",
"instance", instanceID, "pci", member.PCIAddress, "err", err)
if entryErr == nil {
entryErr = err
}
if firstErr == nil {
firstErr = err
}
@@ -268,7 +276,7 @@ func (m *Manager) Release(instanceID string) error {
entry.groupMembers = nil
entry.memberDrivers = nil
if firstErr != nil {
if entryErr != nil {
entry.Available = false
slog.Error("GPU marked unavailable after failed release — operator action required",
"gpu", entry.Device.PCIAddress)
@@ -387,7 +395,9 @@ func (m *Manager) AddMIGInstances(device GPUDevice, instances []MIGInstance) {
}
}
// Available returns the count of GPUs that can be claimed right now.
// Available counts free whole GPUs and free MIG slices alike. Admission must
// use AvailableWhole or AvailableSlices instead: two whole GPUs and fourteen
// carved slices answer 16, which would admit two 8-GPU instances against two.
func (m *Manager) Available() int {
m.mu.Lock()
defer m.mu.Unlock()
@@ -400,6 +410,37 @@ func (m *Manager) Available() int {
return n
}
// AvailableWhole returns the count of whole GPUs that can be claimed right now.
// A GPU carved into MIG slices is not one of them, and neither is an un-carved
// MIG-capable GPU held in freeMIGGPUs.
func (m *Manager) AvailableWhole() int {
m.mu.Lock()
defer m.mu.Unlock()
n := 0
for _, e := range m.pool {
if e.Available && e.InstanceID == "" && e.MIGInstance == nil {
n++
}
}
return n
}
// AvailableSlices counts free slices of this profile plus one per un-carved
// GPU in a single snapshot. The un-carved budget is shared across profiles:
// this answers for one type in isolation, not a sum across different types.
func (m *Manager) AvailableSlices(profileName string) int {
m.mu.Lock()
defer m.mu.Unlock()
n := len(m.freeMIGGPUs)
for _, e := range m.pool {
if e.Available && e.InstanceID == "" && e.MIGInstance != nil &&
e.MIGInstance.Profile.Name == profileName {
n++
}
}
return n
}
// AllocatedCount returns the number of GPUs currently claimed by instances.
func (m *Manager) AllocatedCount() int {
m.mu.Lock()
+104
View File
@@ -0,0 +1,104 @@
//test:in-package — reuses the in-package sysfs and MIG fixtures
package gpu
import (
"testing"
"github.com/stretchr/testify/assert"
)
// A pool holding whole GPUs and carved slices together must not report one kind
// as the other. Available counts both, which is why admission uses neither it
// nor a division of it.
func TestAvailableWholeAndSlicesCountSeparately(t *testing.T) {
root := t.TempDir()
m := NewManager([]GPUDevice{
{PCIAddress: "0000:01:00.0", IOMMUGroup: 1},
{PCIAddress: "0000:02:00.0", IOMMUGroup: 2},
})
carved := newMIGDevice("0000:03:00.0")
m.AddMIGInstances(carved, []MIGInstance{
{GIID: 1, MdevPath: makeMdevDir(t, root, "uuid-gi1"), Profile: MIGProfile{Name: "1g.10gb", MemoryMiB: 10240}},
{GIID: 2, MdevPath: makeMdevDir(t, root, "uuid-gi2"), Profile: MIGProfile{Name: "1g.10gb", MemoryMiB: 10240}},
{GIID: 3, MdevPath: makeMdevDir(t, root, "uuid-gi3"), Profile: MIGProfile{Name: "3g.40gb", MemoryMiB: 40960}},
})
assert.Equal(t, 5, m.Available(), "Available counts every free entry")
assert.Equal(t, 2, m.AvailableWhole(), "a carved GPU is not a whole GPU")
assert.Equal(t, 2, m.AvailableSlices("1g.10gb"))
assert.Equal(t, 1, m.AvailableSlices("3g.40gb"))
assert.Zero(t, m.AvailableSlices("7g.80gb"), "a profile with no slices carved is not available")
}
// Each un-carved GPU contributes one conservative slot to a single profile's
// admission decision. Counts for different profiles must not be added together.
func TestFreeMIGGPUsAreSharedAcrossProfiles(t *testing.T) {
m := NewManager(nil)
m.AddMIGGPU(newMIGDevice("0000:01:00.0"))
m.AddMIGGPU(newMIGDevice("0000:02:00.0"))
assert.Equal(t, 2, m.AvailableSlices("1g.10gb"), "both GPUs are carvable, once each")
assert.Equal(t, 2, m.AvailableSlices("7g.80gb"), "the same budget backs either profile, not both")
assert.Zero(t, m.AvailableWhole(), "a MIG-capable GPU held for carving is not offerable whole")
assert.Zero(t, m.Available(), "freeMIGGPUs are not pool entries yet")
}
func TestAvailableSlicesIncludesCarvableGPUsOnce(t *testing.T) {
m := NewManager(nil)
m.pool = []gpuEntry{
{Available: true, MIGInstance: &MIGInstance{Profile: MIGProfile{Name: "1g.10gb"}}},
{Available: true, InstanceID: "i-busy", MIGInstance: &MIGInstance{Profile: MIGProfile{Name: "1g.10gb"}}},
{Available: false, MIGInstance: &MIGInstance{Profile: MIGProfile{Name: "1g.10gb"}}},
{Available: true, MIGInstance: &MIGInstance{Profile: MIGProfile{Name: "7g.80gb"}}},
{Available: true},
}
m.AddMIGGPU(newMIGDevice("0000:01:00.0"))
assert.Equal(t, 2, m.AvailableSlices("1g.10gb"))
assert.Equal(t, 2, m.AvailableSlices("7g.80gb"))
assert.Equal(t, 1, m.AvailableSlices("3g.40gb"))
}
func TestAvailableSlicesSnapshotsPoolTransitions(t *testing.T) {
dev := newMIGDevice("0000:01:00.0")
m := NewManager(nil)
m.AddMIGGPU(dev)
done := make(chan struct{})
go func() {
defer close(done)
for range 1000 {
m.mu.Lock()
m.freeMIGGPUs = nil
m.pool = []gpuEntry{{Device: dev, Available: true,
MIGInstance: &MIGInstance{Profile: MIGProfile{Name: "7g.80gb"}},
}}
m.mu.Unlock()
m.mu.Lock()
m.pool = nil
m.freeMIGGPUs = []GPUDevice{dev}
m.mu.Unlock()
}
}()
for range 1000 {
assert.Equal(t, 1, m.AvailableSlices("7g.80gb"), "a GPU must not be counted in both pools")
}
<-done
}
// The whole-GPU claim path already skips slices; only the arithmetic was wrong.
func TestAvailableWholeDropsAClaimedGPU(t *testing.T) {
root, dev := buildManagerSysfs(t)
m := newManagerForTest([]GPUDevice{dev}, root)
assert.Equal(t, 1, m.AvailableWhole())
if _, _, err := m.Claim("i-1", ""); err != nil {
t.Fatalf("claim: %v", err)
}
assert.Zero(t, m.AvailableWhole())
if err := m.Release("i-1"); err != nil {
t.Fatalf("release: %v", err)
}
assert.Equal(t, 1, m.AvailableWhole())
}
@@ -0,0 +1,70 @@
// asserts on unexported pool state that no exported accessor distinguishes.
//
//test:in-package — drives Release against the in-package sysfs fixtures and
package gpu
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// A multi-GPU instance releasing with one bad device must lose only that one.
// firstErr was scoped to the whole release, so the first failure condemned
// every GPU after it — harmless at one GPU per instance, node-emptying at eight.
func TestManagerRelease_FailureIsScopedToTheFailingGPU(t *testing.T) {
root := t.TempDir()
buildSysfsDevice(t, root, "0000:03:00.0", "0x030200", "0x10de", "0x2236", "nvidia", 7)
buildSysfsDevice(t, root, "0000:04:00.0", "0x030200", "0x10de", "0x2236", "nvidia", 8)
makeSysfsDriverDir(t, root, "vfio-pci")
makeSysfsDriverDir(t, root, "nvidia")
m := newManagerForTest([]GPUDevice{
{PCIAddress: "0000:03:00.0", Vendor: VendorNVIDIA, VendorID: "10de", DeviceID: "2236",
IOMMUGroup: 7, OriginalDriver: "nvidia"},
{PCIAddress: "0000:04:00.0", Vendor: VendorNVIDIA, VendorID: "10de", DeviceID: "2236",
IOMMUGroup: 8, OriginalDriver: "nvidia"},
}, root)
for range 2 {
_, _, err := m.Claim("i-001", "")
require.NoError(t, err)
}
require.Zero(t, m.AvailableWhole(), "both GPUs are claimed")
// The fixture does not move the driver symlink the way the kernel does on
// bind, and Release skips a device that is not vfio-pci bound.
vfioDriverPath := filepath.Join(root, "bus/pci/drivers/vfio-pci")
for _, addr := range []string{"0000:03:00.0", "0000:04:00.0"} {
devPath := filepath.Join(root, "bus/pci/devices", addr)
require.NoError(t, os.Remove(filepath.Join(devPath, "driver")))
require.NoError(t, os.Symlink(vfioDriverPath, filepath.Join(devPath, "driver")))
}
// Break only the first GPU's unbind, by way of its own driver_override.
// A directory in the file's place fails the write whatever the test uid,
// where a read-only mode would not stop root.
override := filepath.Join(root, "bus/pci/devices/0000:03:00.0/driver_override")
require.NoError(t, os.Remove(override))
require.NoError(t, os.Mkdir(override, 0o755))
err := m.Release("i-001")
require.Error(t, err, "a failed unbind is still reported")
assert.Equal(t, 1, m.AvailableWhole(),
"the GPU that unbound cleanly must return to the pool")
assert.Equal(t, 2, m.TotalCount(), "both GPUs are still physically present")
for _, e := range m.pool {
switch e.Device.PCIAddress {
case "0000:03:00.0":
assert.False(t, e.Available, "the GPU that failed to unbind is held back")
case "0000:04:00.0":
assert.True(t, e.Available, "its sibling was never touched by the failure")
}
assert.Empty(t, e.InstanceID, "every entry is unbound from the instance either way")
}
}
+5 -4
View File
@@ -68,11 +68,12 @@ type InstanceTypeAllocator interface {
InstanceTypes() map[string]*ec2.InstanceTypeInfo
}
// GPUClaimer binds a GPU for a starting instance and returns an attachment
// descriptor. For whole-GPU passthrough the descriptor carries the PCI address;
// for MIG slices it carries the mdev path. nil claimer means no GPU passthrough.
// GPUClaimer binds GPUs for a starting instance and returns their attachment
// descriptors. For whole-GPU passthrough each descriptor carries a PCI address;
// for MIG slices it carries an mdev path. Claims are all-or-nothing. A nil
// claimer means no GPU passthrough.
type GPUClaimer interface {
Claim(instanceID, profileName string) (*gpu.GPUAttachment, error)
Claim(instanceID, profileName string, count int) ([]gpu.GPUAttachment, error)
Release(instanceID string) error
}
+65 -18
View File
@@ -22,7 +22,6 @@ import (
"github.com/mulgadc/spinifex/spinifex/ebsmetadata"
"github.com/mulgadc/spinifex/spinifex/ebsprovider"
"github.com/mulgadc/spinifex/spinifex/filterutil"
"github.com/mulgadc/spinifex/spinifex/gpu"
handlers_dns "github.com/mulgadc/spinifex/spinifex/handlers/dns"
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
"github.com/mulgadc/spinifex/spinifex/instancetypes"
@@ -1092,17 +1091,37 @@ func (s *InstanceServiceImpl) LaunchRunInstances(ctx context.Context, instances
instance.Instance.BlockDeviceMappings = append(instance.Instance.BlockDeviceMappings, mapping)
}
if s.gpuClaimer != nil && instancetypes.IsGPUType(instanceType) {
profileName := instancetypes.MIGProfileFromType(aws.StringValue(instanceType.InstanceType))
att, gpuErr := s.gpuClaimer.Claim(instance.ID, profileName)
if gpuErr != nil {
slog.ErrorContext(ctx, "LaunchRunInstances: GPU claim failed", "instanceId", instance.ID, "err", gpuErr)
if instancetypes.IsGPUType(instanceType) {
// Admission gates on the daemon's GPU manager, which is a different
// field wired at a different moment. Booting a GPU type without a
// claimer would hand the customer a running, GPU-less instance.
if s.gpuClaimer == nil {
slog.ErrorContext(ctx, "LaunchRunInstances: GPU type admitted with no GPU claimer wired",
"instanceId", instance.ID, "instanceType", aws.StringValue(instanceType.InstanceType))
s.vmMgr.MarkFailed(ctx, instance, "gpu_claim_failed")
continue
}
instance.GPUAttachments = []gpu.GPUAttachment{*att}
slog.InfoContext(ctx, "LaunchRunInstances: GPU claimed for instance", "instanceId", instance.ID,
"pci", att.PCIAddress, "mdev", att.MdevPath)
profileName := instancetypes.MIGProfileFromType(aws.StringValue(instanceType.InstanceType))
gpuCount := instancetypes.GPUCountForType(aws.StringValue(instanceType.InstanceType))
attachments, gpuErr := s.gpuClaimer.Claim(instance.ID, profileName, gpuCount)
if gpuErr != nil {
slog.ErrorContext(ctx, "LaunchRunInstances: GPU claim failed", "instanceId", instance.ID, "count", gpuCount, "err", gpuErr)
s.vmMgr.MarkFailed(ctx, instance, "gpu_claim_failed")
continue
}
if len(attachments) != gpuCount {
slog.ErrorContext(ctx, "LaunchRunInstances: GPU claim returned the wrong count",
"instanceId", instance.ID, "requested", gpuCount, "got", len(attachments))
if releaseErr := s.gpuClaimer.Release(instance.ID); releaseErr != nil {
slog.ErrorContext(ctx, "LaunchRunInstances: GPU release after short claim failed",
"instanceId", instance.ID, "err", releaseErr)
}
s.vmMgr.MarkFailed(ctx, instance, "gpu_claim_failed")
continue
}
instance.GPUAttachments = attachments
slog.InfoContext(ctx, "LaunchRunInstances: GPUs claimed for instance", "instanceId", instance.ID,
"count", len(attachments), "attachments", attachments)
}
if err := s.vmMgr.Run(ctx, instance); err != nil {
@@ -2593,22 +2612,50 @@ func (s *InstanceServiceImpl) StartStoppedInstance(ctx context.Context, input *S
instance.DesiredState = vm.DesiredRunning
s.vmMgr.Insert(instance)
// Claim GPU for GPU instance types.
// Reclaim the GPU count the instance type advertises, which is what the
// instance was admitted against. A record holding a different number was
// written by a build that took the count from somewhere else.
gpuClaimed := false
if s.gpuClaimer != nil && instancetypes.IsGPUType(instanceType) {
profileName := instancetypes.MIGProfileFromType(aws.StringValue(instanceType.InstanceType))
att, gpuErr := s.gpuClaimer.Claim(instance.ID, profileName)
if gpuErr != nil {
slog.ErrorContext(ctx, "StartStoppedInstance: GPU claim failed", "instanceId", input.InstanceID, "err", gpuErr)
if instancetypes.IsGPUType(instanceType) {
// Admission gates on the daemon's GPU manager, which is a different
// field wired at a different moment. Starting a GPU type without a
// claimer would hand the customer a running, GPU-less instance.
if s.gpuClaimer == nil {
slog.ErrorContext(ctx, "StartStoppedInstance: GPU type admitted with no GPU claimer wired",
"instanceId", input.InstanceID, "instanceType", instance.InstanceType)
s.resourceMgr.Deallocate(instanceType)
s.vmMgr.Delete(instance.ID)
s.restoreClaimedStoppedInstance(ctx, instance)
return nil, errors.New(awserrors.ErrorInsufficientInstanceCapacity)
}
instance.GPUAttachments = []gpu.GPUAttachment{*att}
profileName := instancetypes.MIGProfileFromType(aws.StringValue(instanceType.InstanceType))
gpuCount := instancetypes.GPUCountForType(instance.InstanceType)
if recorded := len(instance.GPUAttachments); recorded > 0 && recorded != gpuCount {
slog.WarnContext(ctx, "StartStoppedInstance: recorded GPU count differs from the instance type",
"instanceId", input.InstanceID, "instanceType", instance.InstanceType,
"recorded", recorded, "reclaiming", gpuCount)
}
attachments, gpuErr := s.gpuClaimer.Claim(instance.ID, profileName, gpuCount)
if gpuErr == nil && len(attachments) != gpuCount {
slog.ErrorContext(ctx, "StartStoppedInstance: GPU claim returned the wrong count",
"instanceId", input.InstanceID, "requested", gpuCount, "got", len(attachments))
if relErr := s.gpuClaimer.Release(instance.ID); relErr != nil {
slog.ErrorContext(ctx, "StartStoppedInstance: GPU release after short claim failed",
"instanceId", input.InstanceID, "err", relErr)
}
gpuErr = errors.New("GPU claim returned fewer GPUs than the instance type requires")
}
if gpuErr != nil {
slog.ErrorContext(ctx, "StartStoppedInstance: GPU claim failed", "instanceId", input.InstanceID, "count", gpuCount, "err", gpuErr)
s.resourceMgr.Deallocate(instanceType)
s.vmMgr.Delete(instance.ID)
s.restoreClaimedStoppedInstance(ctx, instance)
return nil, errors.New(awserrors.ErrorInsufficientInstanceCapacity)
}
instance.GPUAttachments = attachments
gpuClaimed = true
slog.InfoContext(ctx, "GPU claimed for instance", "instanceId", input.InstanceID,
"pci", att.PCIAddress, "mdev", att.MdevPath)
slog.InfoContext(ctx, "GPUs claimed for instance", "instanceId", input.InstanceID,
"count", len(attachments), "attachments", attachments)
}
if err := s.vmMgr.Run(ctx, instance); err != nil {
@@ -18,6 +18,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/ebsmetadata"
"github.com/mulgadc/spinifex/spinifex/gpu"
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
"github.com/mulgadc/spinifex/spinifex/instancetypes"
"github.com/mulgadc/spinifex/spinifex/tags"
"github.com/mulgadc/spinifex/spinifex/testutil"
spxtypes "github.com/mulgadc/spinifex/spinifex/types"
@@ -2318,19 +2319,23 @@ func TestTerminateStoppedInstance_ReleaseAttachedENIs(t *testing.T) {
// --- StartStoppedInstance tests ---
type fakeGPUClaimer struct {
claimed []string
released []string
claimErr error
attachment gpu.GPUAttachment
claimed []string
claimCounts []int
released []string
claimErr error
attachments []gpu.GPUAttachment
}
func (f *fakeGPUClaimer) Claim(instanceID, _ string) (*gpu.GPUAttachment, error) {
func (f *fakeGPUClaimer) Claim(instanceID, _ string, count int) ([]gpu.GPUAttachment, error) {
f.claimed = append(f.claimed, instanceID)
f.claimCounts = append(f.claimCounts, count)
if f.claimErr != nil {
return nil, f.claimErr
}
att := f.attachment
return &att, nil
if f.attachments != nil {
return append([]gpu.GPUAttachment(nil), f.attachments...), nil
}
return make([]gpu.GPUAttachment, count), nil
}
func (f *fakeGPUClaimer) Release(instanceID string) error {
@@ -2759,6 +2764,131 @@ func TestStartStoppedInstance_ConcurrentClaimRace(t *testing.T) {
assert.False(t, stillInMgr, "a failed Run must not leave the VM in the manager map")
}
// A restart reclaims the count the instance type advertises, which is what the
// instance was admitted against. A record holding some other number — the only
// source of one was the withdrawn launch override — does not move the count.
func TestStartStoppedInstance_ReclaimsTheInstanceTypeGPUCount(t *testing.T) {
tests := []struct {
name string
itype string
recorded int
wantClaim int
}{
{name: "multi-GPU type", itype: "g5.12xlarge", recorded: 4, wantClaim: 4},
{name: "record disagrees with the type", itype: "g5.2xlarge", recorded: 3, wantClaim: 1},
{name: "legacy record with no attachments", itype: "g5.12xlarge", recorded: 0, wantClaim: 4},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
id := "i-multi-gpu"
attachments := make([]gpu.GPUAttachment, test.recorded)
for i := range attachments {
attachments[i] = gpu.GPUAttachment{PCIAddress: fmt.Sprintf("0000:0%d:00.0", i+1)}
}
store := &vmmock.StateStore{Stopped: map[string]*vm.VM{
id: {
ID: id,
Status: vm.StateStopped,
AccountID: "acc",
InstanceType: test.itype,
GPUAttachments: attachments,
},
}}
instanceType := &ec2.InstanceTypeInfo{
InstanceType: aws.String(test.itype),
GpuInfo: &ec2.GpuInfo{Gpus: []*ec2.GpuDeviceInfo{{
Count: aws.Int64(int64(instancetypes.GPUCountForType(test.itype))),
}}},
}
prov := &fakeResourceCapacityProvider{
instanceTypes: map[string]*ec2.InstanceTypeInfo{test.itype: instanceType},
}
claimer := &fakeGPUClaimer{}
mgr := vm.NewManagerWithDeps(vm.Deps{VolumeMounter: raceVolumeMounter{}})
svc := &InstanceServiceImpl{
stoppedStore: store,
resourceMgr: prov,
vmMgr: mgr,
gpuClaimer: claimer,
}
_, err := svc.StartStoppedInstance(
context.Background(),
&StartStoppedInstanceInput{InstanceID: id},
"acc",
)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
assert.Equal(t, []int{test.wantClaim}, claimer.claimCounts)
assert.Equal(t, []string{id}, claimer.released)
})
}
}
// A GPU type that cannot be given its GPUs must not start. Admission gates on
// the daemon's GPU manager, a different field wired at a different moment, so
// neither a missing claimer nor a short claim is caught anywhere else — and
// both would otherwise return a running, GPU-less instance.
func TestStartStoppedInstance_RefusesWhenGPUsCannotBeGuaranteed(t *testing.T) {
tests := []struct {
name string
claimer GPUClaimer
wantReleased bool
}{
{name: "no claimer wired", claimer: nil},
{
name: "claim returns fewer GPUs than the type requires",
claimer: &fakeGPUClaimer{attachments: []gpu.GPUAttachment{{PCIAddress: "0000:01:00.0"}}},
wantReleased: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
const id, itype = "i-multi-gpu", "g5.12xlarge"
store := &vmmock.StateStore{Stopped: map[string]*vm.VM{
id: {ID: id, Status: vm.StateStopped, AccountID: "acc", InstanceType: itype},
}}
instanceType := &ec2.InstanceTypeInfo{
InstanceType: aws.String(itype),
GpuInfo: &ec2.GpuInfo{Gpus: []*ec2.GpuDeviceInfo{{
Count: aws.Int64(int64(instancetypes.GPUCountForType(itype))),
}}},
}
prov := &fakeResourceCapacityProvider{
instanceTypes: map[string]*ec2.InstanceTypeInfo{itype: instanceType},
}
svc := &InstanceServiceImpl{
stoppedStore: store,
resourceMgr: prov,
vmMgr: vm.NewManagerWithDeps(vm.Deps{VolumeMounter: raceVolumeMounter{}}),
gpuClaimer: test.claimer,
}
_, err := svc.StartStoppedInstance(
context.Background(),
&StartStoppedInstanceInput{InstanceID: id},
"acc",
)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInsufficientInstanceCapacity, err.Error(),
"a GPU that cannot be claimed is a capacity refusal, not a launch")
if test.wantReleased {
claimer, ok := test.claimer.(*fakeGPUClaimer)
require.True(t, ok)
assert.Equal(t, []string{id}, claimer.released,
"the partial claim must not be left holding pool entries")
}
})
}
}
// --- PrepareRunInstances / ec2.cmd dispatch tests ---------------------------
type fakeAMILoader struct {
@@ -2946,6 +3076,46 @@ func TestPrepareRunInstances_InsufficientCapacity(t *testing.T) {
assert.Equal(t, awserrors.ErrorInsufficientInstanceCapacity, err.Error())
}
// ElasticInferenceAccelerators is a real EC2 parameter for Elastic Inference,
// whose types are eia1.*/eia2.*. It carries no Spinifex meaning: the GPU count
// comes from the instance type, so Type=gpu,Count=3 neither succeeds as an
// override nor gates capacity.
func TestPrepareRunInstances_ElasticInferenceAcceleratorIsNotAGPUOverride(t *testing.T) {
instanceType := &ec2.InstanceTypeInfo{
InstanceType: aws.String("g5.2xlarge"),
GpuInfo: &ec2.GpuInfo{Gpus: []*ec2.GpuDeviceInfo{{
Count: aws.Int64(1),
}}},
}
types := map[string]*ec2.InstanceTypeInfo{"g5.2xlarge": instanceType}
prov := &fakeResourceCapacityProvider{
instanceTypes: types,
canAllocFn: func(_ *ec2.InstanceTypeInfo, count int) int { return count },
}
svc := &InstanceServiceImpl{
instanceTypes: types,
amiLoader: &fakeAMILoader{byID: map[string]ebsmetadata.AMI{
"ami-1": {ImageOwnerAlias: "acc"},
}},
resourceMgr: prov,
gpuClaimer: &fakeGPUClaimer{},
}
_, instances, _, err := svc.PrepareRunInstances(context.Background(), &ec2.RunInstancesInput{
InstanceType: aws.String("g5.2xlarge"),
ImageId: aws.String("ami-1"),
MinCount: aws.Int64(2),
MaxCount: aws.Int64(2),
ElasticInferenceAccelerators: []*ec2.ElasticInferenceAccelerator{{
Type: aws.String("gpu"), Count: aws.Int64(3),
}},
}, "acc", "")
require.NoError(t, err)
assert.Len(t, instances, 2)
assert.Equal(t, 1, instancetypes.GPUCountForType("g5.2xlarge"),
"the count must stay a lookup on the type name")
}
func TestPrepareRunInstances_HappyPathNoENI(t *testing.T) {
types, _ := defaultPrepareInstanceTypes()
prov := &fakeResourceCapacityProvider{
+8 -1
View File
@@ -107,7 +107,14 @@ func (s *Service) ProvisionCapacity(ctx context.Context, input *ProvisionCapacit
var amiID string
if instancetypes.IsGPUTypeName(instanceType) {
amiID, err = lookupECSGPUNodeAMI(ctx, s.deps.Images, accountID, instancetypes.GPUVendorForType(instanceType))
// Refusing beats the non-GPU AMI: that boots a container instance with
// the GPUs passed through and no driver, which registers no GPU
// resource and strands every GPU task as PENDING with nothing logged.
vendor := instancetypes.GPUVendorForType(instanceType)
if vendor == "" {
return nil, fmt.Errorf("ecs: instance type %s does not name a GPU vendor, so its GPU node AMI cannot be resolved", instanceType)
}
amiID, err = lookupECSGPUNodeAMI(ctx, s.deps.Images, accountID, vendor)
} else {
amiID, err = lookupECSNodeAMI(ctx, s.deps.Images, accountID)
}
+8
View File
@@ -253,6 +253,14 @@ func (s *EKSServiceImpl) createNodegroup(ctx context.Context, acctKV jetstream.K
minSize, maxSize, desired := scalingFromInput(input.ScalingConfig)
gpuEnabled, gpuVendor := gpuFieldsForInstanceTypes(instanceTypes)
if gpuEnabled && gpuVendor == "" {
// Caught here rather than at launch: without a vendor there is no GPU
// AMI to resolve, and the nodegroup would otherwise reach Ready with
// the GPUs passed through, no driver, and every GPU pod Pending.
slog.ErrorContext(ctx, "createNodegroup: GPU instance types name no GPU vendor",
"cluster", cluster, "nodegroup", ng, "instanceTypes", instanceTypes)
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
amiType := aws.StringValue(input.AmiType)
if amiType == "" {
+118 -22
View File
@@ -1,6 +1,8 @@
package instancetypes
import (
"fmt"
"maps"
"slices"
"strings"
)
@@ -69,6 +71,8 @@ func GPUModelForVendorDevice(vendorID, deviceID string) *GPUModel {
// a GPU instance type's family prefix (e.g. "g5.xlarge" -> "g5"), or "" if
// instanceType is not a known GPU family.
func GPUVendorForType(instanceType string) string {
// gpu.* and mig.* fall through to "": neither names a family, so no
// knownGPUModels entry matches and the vendor comes from discovery instead.
family, _, _ := strings.Cut(instanceType, ".")
for i := range knownGPUModels {
if knownGPUModels[i].Family == family {
@@ -78,9 +82,12 @@ func GPUVendorForType(instanceType string) string {
return ""
}
// IsGPUTypeName reports whether instanceType belongs to a known GPU family.
// IsGPUTypeName reports whether instanceType is GPU-backed. gpu.* and mig.*
// qualify even though GPUVendorForType cannot name their vendor: the vendor
// comes from discovery, so callers needing one must handle an empty answer.
func IsGPUTypeName(instanceType string) bool {
return GPUVendorForType(instanceType) != ""
return IsBuiltinGPUType(instanceType) || IsMIGType(instanceType) ||
GPUVendorForType(instanceType) != ""
}
// cpuGeneration represents a specific CPU microarchitecture generation
@@ -199,38 +206,50 @@ var memorySizes = []instanceSize{
// memorySizesSmall is memorySizes without 12xlarge and 24xlarge (older/ARM families).
var memorySizesSmall = slices.Clone(memorySizes[:6])
// g4dnSizes are the single-GPU G4dn instance sizes (1x NVIDIA T4 each).
// g4dnSizes are the G4dn instance sizes (NVIDIA T4). 12xlarge carries 4 GPUs,
// every other size one. g4dn.metal is bare metal and is not offered.
var g4dnSizes = []instanceSize{
{"xlarge", 4, 16},
{"2xlarge", 8, 32},
{"4xlarge", 16, 64},
{"8xlarge", 32, 128},
{"12xlarge", 48, 192},
{"16xlarge", 64, 256},
}
// g4adSizes are the single-GPU G4ad instance sizes (1x AMD Radeon Pro V520 each).
// g4adSizes are the G4ad instance sizes (AMD Radeon Pro V520). 8xlarge carries
// 2 GPUs and 16xlarge 4.
var g4adSizes = []instanceSize{
{"xlarge", 4, 16},
{"2xlarge", 8, 32},
{"4xlarge", 16, 64},
{"8xlarge", 32, 128},
{"16xlarge", 64, 256},
}
// g5Sizes are the single-GPU G5 instance sizes (1x NVIDIA A10G each).
// g5Sizes are the G5 instance sizes (NVIDIA A10G). 12xlarge and 24xlarge carry
// 4 GPUs and 48xlarge 8; every other size one.
var g5Sizes = []instanceSize{
{"xlarge", 4, 16},
{"2xlarge", 8, 32},
{"4xlarge", 16, 64},
{"8xlarge", 32, 128},
{"12xlarge", 48, 192},
{"16xlarge", 64, 256},
{"24xlarge", 96, 384},
{"48xlarge", 192, 768},
}
// g6Sizes are the single-GPU G6 instance sizes (1x NVIDIA L4 each).
// g6Sizes are the G6 instance sizes (NVIDIA L4), which mirror g5 exactly.
var g6Sizes = []instanceSize{
{"xlarge", 4, 16},
{"2xlarge", 8, 32},
{"4xlarge", 16, 64},
{"8xlarge", 32, 128},
{"12xlarge", 48, 192},
{"16xlarge", 64, 256},
{"24xlarge", 96, 384},
{"48xlarge", 192, 768},
}
// gr6Sizes are the single-GPU Gr6 instance sizes (1x NVIDIA L4, memory-optimized).
@@ -248,11 +267,70 @@ var g7eSizes = []instanceSize{
{"12xlarge", 48, 512},
}
// gpuCountPerType overrides the default of 1 GPU for multi-GPU instance sizes.
var gpuCountPerType = map[string]int{
"g7e.12xlarge": 2,
// builtinGPUFamily is the one non-AWS GPU family Spinifex defines, for the host
// shapes AWS does not sell. A bare "gpu" is not an EC2 family name, so the
// prefix is reserved and cannot collide with a real type.
const builtinGPUFamily = "gpu"
const builtinGPUPrefix = builtinGPUFamily + "."
const MaxGPUsPerInstance = 8
// builtinGPUTiers are the vCPU-per-GPU ratios the gpu.* family offers.
var builtinGPUTiers = []int{2, 4, 8}
// builtinGPUMemPerVCPUGB is AWS's general-purpose vCPU:memory ratio, applied to
// every tier so no gpu.* shape is one EC2 would find unfamiliar.
const builtinGPUMemPerVCPUGB = 4
func IsBuiltinGPUType(instanceType string) bool {
return strings.HasPrefix(instanceType, builtinGPUPrefix)
}
// builtinGPUSizes and builtinGPUCounts describe the gpu.* family: the name
// gpu.<count>x<vcpu>c is that many GPUs at that many vCPU each.
var builtinGPUSizes, builtinGPUCounts = func() ([]instanceSize, map[string]int) {
sizes := make([]instanceSize, 0, MaxGPUsPerInstance*len(builtinGPUTiers))
counts := make(map[string]int, MaxGPUsPerInstance*len(builtinGPUTiers))
for _, perGPU := range builtinGPUTiers {
for n := 1; n <= MaxGPUsPerInstance; n++ {
vcpus := n * perGPU
suffix := fmt.Sprintf("%dx%dc", n, perGPU)
sizes = append(sizes, instanceSize{suffix, vcpus, float64(vcpus * builtinGPUMemPerVCPUGB)})
counts[builtinGPUPrefix+suffix] = n
}
}
return sizes, counts
}()
// gpuCountPerType overrides the default of 1 GPU for multi-GPU instance sizes.
// Every figure for an AWS family is that family's real EC2 GPU count.
var gpuCountPerType = func() map[string]int {
m := map[string]int{
"g4dn.12xlarge": 4,
"g4ad.8xlarge": 2,
"g4ad.16xlarge": 4,
"g5.12xlarge": 4,
"g5.24xlarge": 4,
"g5.48xlarge": 8,
"g6.12xlarge": 4,
"g6.24xlarge": 4,
"g6.48xlarge": 8,
"g6e.12xlarge": 4,
"g6e.24xlarge": 4,
"g6e.48xlarge": 8,
"g7e.12xlarge": 2,
"p3.8xlarge": 4,
"p3.16xlarge": 8,
"p3dn.24xlarge": 8,
"p4d.24xlarge": 8,
"p4de.24xlarge": 8,
"p5.48xlarge": 8,
"p5e.48xlarge": 8,
}
maps.Copy(m, builtinGPUCounts)
return m
}()
// GPUCountForType returns the number of GPUs required by the given instance type.
// Returns 1 for all single-GPU and non-GPU types.
func GPUCountForType(instanceType string) int {
@@ -262,49 +340,63 @@ func GPUCountForType(instanceType string) int {
return 1
}
// g6eSizes are the single-GPU G6e instance sizes (1x NVIDIA L40S each).
// g6eSizes are the G6e instance sizes (NVIDIA L40S). 12xlarge and 24xlarge
// carry 4 GPUs and 48xlarge 8; every other size one.
var g6eSizes = []instanceSize{
{"xlarge", 4, 32},
{"2xlarge", 8, 64},
{"4xlarge", 16, 128},
{"8xlarge", 32, 256},
{"12xlarge", 48, 384},
{"16xlarge", 64, 512},
{"24xlarge", 96, 768},
{"48xlarge", 192, 1536},
}
// p3Sizes are the single-GPU P3 instance sizes (1x NVIDIA V100 16 GiB each).
// P3 8xlarge and larger have multiple GPUs and are excluded.
// p3Sizes are the P3 instance sizes (NVIDIA V100 SXM2 16 GiB). 8xlarge carries
// 4 GPUs and 16xlarge 8.
var p3Sizes = []instanceSize{
{"2xlarge", 8, 61},
{"8xlarge", 32, 244},
{"16xlarge", 64, 488},
}
// p3dnSizes are the single-GPU P3dn instance sizes (1x NVIDIA V100 32 GiB each).
// P3dn 24xlarge has 8 GPUs and is excluded.
// p3dnSizes are the P3dn instance sizes (NVIDIA V100 SXM2 32 GiB). 24xlarge is
// the only size AWS sells and carries 8 GPUs; 2xlarge is a Spinifex single-GPU
// size kept for hosts that cannot meet the 24xlarge shape.
var p3dnSizes = []instanceSize{
{"2xlarge", 8, 61},
{"24xlarge", 96, 768},
}
// p4dSizes are the single-GPU P4d instance sizes (1x NVIDIA A100 SXM4 40 GiB each).
// P4d 24xlarge has 8 GPUs and is excluded.
// p4dSizes are the P4d instance sizes (NVIDIA A100 SXM4 40 GiB). 24xlarge is
// the only size AWS sells and carries 8 GPUs; xlarge is a Spinifex single-GPU
// size kept for hosts that cannot meet the 24xlarge shape.
var p4dSizes = []instanceSize{
{"xlarge", 4, 32},
{"24xlarge", 96, 1152},
}
// p4deSizes are the single-GPU P4de instance sizes (1x NVIDIA A100 SXM4 80 GiB each).
// P4de 24xlarge has 8 GPUs and is excluded.
// p4deSizes are the P4de instance sizes (NVIDIA A100 SXM4 80 GiB), shaped
// exactly as p4d; xlarge is the Spinifex single-GPU size.
var p4deSizes = []instanceSize{
{"xlarge", 4, 32},
{"24xlarge", 96, 1152},
}
// p5Sizes are the single-GPU P5 instance sizes (1x NVIDIA H100 SXM5 80 GiB each).
// P5 48xlarge has 8 GPUs and is excluded.
// p5Sizes are the P5 instance sizes (NVIDIA H100 SXM5 80 GiB). 48xlarge is the
// only size AWS sells and carries 8 GPUs; 4xlarge is the Spinifex single-GPU
// size.
var p5Sizes = []instanceSize{
{"4xlarge", 16, 256},
{"48xlarge", 192, 2048},
}
// p5eSizes are the single-GPU P5e instance sizes (1x NVIDIA H200 SXM5 141 GiB each).
// P5e 48xlarge has 8 GPUs and is excluded.
// p5eSizes are the P5e instance sizes (NVIDIA H200 SXM5 141 GiB), shaped
// exactly as p5; 4xlarge is the Spinifex single-GPU size.
var p5eSizes = []instanceSize{
{"4xlarge", 16, 256},
{"48xlarge", 192, 2048},
}
// systemSizes defines internal-only instance types for system VMs (LB, NAT GW, etc.).
@@ -370,6 +462,10 @@ var instanceFamilyDefs = []instanceFamilyDef{
{name: "p5", sizes: p5Sizes, currentGen: true},
{name: "p5e", sizes: p5eSizes, currentGen: true},
// Non-AWS GPU family for host shapes AWS does not sell; emitted by
// GenerateBuiltinGPUTypes rather than by matching a model's family.
{name: builtinGPUFamily, sizes: builtinGPUSizes, currentGen: true},
// Memory Optimized (1:8 vCPU:memory)
{name: "r4", sizes: memorySizesSmall, currentGen: false},
{name: "r5", sizes: memorySizes, currentGen: true},
+104 -36
View File
@@ -158,9 +158,22 @@ func generateSystemTypes(arch string) map[string]*ec2.InstanceTypeInfo {
return types
}
// GenerateGPUTypes returns InstanceTypeInfo entries for each GPU model with GpuInfo populated.
// GenerateGPUTypes returns InstanceTypeInfo entries for each GPU model plus the
// built-in gpu.* family. models carries one entry per whole GPU, so a node
// resolving more than one distinct model offers no type above a count of 1.
func GenerateGPUTypes(models []GPUModel, arch string) map[string]*ec2.InstanceTypeInfo {
types := make(map[string]*ec2.InstanceTypeInfo)
if len(models) == 0 {
return types
}
maxGPUsPerInstance := MaxGPUsPerInstance
if !gpuModelsHomogeneous(models) {
maxGPUsPerInstance = 1
slog.Warn("node holds more than one distinct GPU model, offering single-GPU types only",
"models", len(models))
}
seen := make(map[string]bool)
for _, model := range models {
@@ -175,48 +188,103 @@ func GenerateGPUTypes(models []GPUModel, arch string) map[string]*ec2.InstanceTy
}
for _, size := range def.sizes {
name := fmt.Sprintf("%s.%s", def.name, size.suffix)
gpuCount := int64(GPUCountForType(name))
types[name] = &ec2.InstanceTypeInfo{
InstanceType: aws.String(name),
VCpuInfo: &ec2.VCpuInfo{
DefaultVCpus: aws.Int64(int64(size.vcpus)),
},
MemoryInfo: &ec2.MemoryInfo{
SizeInMiB: aws.Int64(int64(size.memoryGB * 1024)),
},
ProcessorInfo: &ec2.ProcessorInfo{
SupportedArchitectures: []*string{aws.String(arch)},
},
GpuInfo: &ec2.GpuInfo{
Gpus: []*ec2.GpuDeviceInfo{{
Count: aws.Int64(gpuCount),
Manufacturer: aws.String(model.Manufacturer),
Name: aws.String(model.Name),
MemoryInfo: &ec2.GpuDeviceMemoryInfo{
SizeInMiB: aws.Int64(model.MemoryMiB),
},
}},
TotalGpuMemoryInMiB: aws.Int64(model.MemoryMiB * gpuCount),
},
CurrentGeneration: aws.Bool(def.currentGen),
BurstablePerformanceSupported: aws.Bool(false),
Hypervisor: aws.String("kvm"),
SupportedVirtualizationTypes: []*string{aws.String("hvm")},
SupportedRootDeviceTypes: []*string{aws.String("ebs")},
NetworkInfo: NetworkInfoForType(name),
PlacementGroupInfo: &ec2.PlacementGroupInfo{
SupportedStrategies: []*string{
aws.String("cluster"),
aws.String("spread"),
},
},
if !gpuTypeOffered(name, maxGPUsPerInstance) {
continue
}
types[name] = gpuInstanceTypeInfo(name, size, model, arch, def.currentGen)
}
}
}
// gpu.* is no model's family, so GenerateGPUTypes cannot reach it by the
// match above. It advertises whichever model the node resolved.
maps.Copy(types, generateBuiltinGPUTypes(models[0], arch, maxGPUsPerInstance))
return types
}
// gpuModelsHomogeneous reports whether every GPU advertises the same name and
// VRAM. Those reach GpuInfo, so devices advertising identically are
// interchangeable — which spares a mixed-SKU H100 node differing only by PCI ID.
func gpuModelsHomogeneous(models []GPUModel) bool {
for _, m := range models[1:] {
if m.Name != models[0].Name || m.MemoryMiB != models[0].MemoryMiB {
return false
}
}
return true
}
// gpuTypeOffered reports whether a node handing at most maxGPUsPerInstance GPUs
// to one instance can offer this type, logging by name when it cannot. An
// operator who sees a type silently absent has no thread to pull.
func gpuTypeOffered(name string, maxGPUsPerInstance int) bool {
required := GPUCountForType(name)
if required <= maxGPUsPerInstance {
return true
}
slog.Info("GPU instance type not offered",
"instanceType", name, "gpus_required", required,
"gpus_offerable", maxGPUsPerInstance)
return false
}
// generateBuiltinGPUTypes emits the gpu.* family for the node's resolved GPU
// model. Every figure but the model itself is static, so the family is
// identical on every node that discovers the same hardware.
func generateBuiltinGPUTypes(model GPUModel, arch string, maxGPUsPerInstance int) map[string]*ec2.InstanceTypeInfo {
types := make(map[string]*ec2.InstanceTypeInfo, len(builtinGPUSizes))
for _, size := range builtinGPUSizes {
name := builtinGPUPrefix + size.suffix
if !gpuTypeOffered(name, maxGPUsPerInstance) {
continue
}
types[name] = gpuInstanceTypeInfo(name, size, model, arch, true)
}
return types
}
// gpuInstanceTypeInfo builds one GPU InstanceTypeInfo. The vCPU and memory
// figures come from the size, the GPU count from the type name, and the model,
// VRAM and manufacturer from discovery.
func gpuInstanceTypeInfo(name string, size instanceSize, model GPUModel, arch string, currentGen bool) *ec2.InstanceTypeInfo {
gpuCount := int64(GPUCountForType(name))
return &ec2.InstanceTypeInfo{
InstanceType: aws.String(name),
VCpuInfo: &ec2.VCpuInfo{
DefaultVCpus: aws.Int64(int64(size.vcpus)),
},
MemoryInfo: &ec2.MemoryInfo{
SizeInMiB: aws.Int64(int64(size.memoryGB * 1024)),
},
ProcessorInfo: &ec2.ProcessorInfo{
SupportedArchitectures: []*string{aws.String(arch)},
},
GpuInfo: &ec2.GpuInfo{
Gpus: []*ec2.GpuDeviceInfo{{
Count: aws.Int64(gpuCount),
Manufacturer: aws.String(model.Manufacturer),
Name: aws.String(model.Name),
MemoryInfo: &ec2.GpuDeviceMemoryInfo{
SizeInMiB: aws.Int64(model.MemoryMiB),
},
}},
TotalGpuMemoryInMiB: aws.Int64(model.MemoryMiB * gpuCount),
},
CurrentGeneration: aws.Bool(currentGen),
BurstablePerformanceSupported: aws.Bool(false),
Hypervisor: aws.String("kvm"),
SupportedVirtualizationTypes: []*string{aws.String("hvm")},
SupportedRootDeviceTypes: []*string{aws.String("ebs")},
NetworkInfo: NetworkInfoForType(name),
PlacementGroupInfo: &ec2.PlacementGroupInfo{
SupportedStrategies: []*string{
aws.String("cluster"),
aws.String("spread"),
},
},
}
}
// IsGPUType returns true if the instance type has GPU resources.
func IsGPUType(info *ec2.InstanceTypeInfo) bool {
return info.GpuInfo != nil && len(info.GpuInfo.Gpus) > 0
+6 -4
View File
@@ -461,9 +461,10 @@ func TestGPUModelForVendorDevice_Unknown(t *testing.T) {
func TestGenerateGPUTypes_NVIDIAa10g(t *testing.T) {
types := GenerateGPUTypes([]GPUModel{NVIDIAa10g}, "x86_64")
// g5 has 5 single-GPU sizes: xlarge, 2xlarge, 4xlarge, 8xlarge, 16xlarge
assert.Len(t, types, 5)
for _, name := range []string{"g5.xlarge", "g5.2xlarge", "g5.4xlarge", "g5.8xlarge", "g5.16xlarge"} {
// Every g5 size, plus the built-in gpu.* family.
assert.Len(t, types, len(g5Sizes)+len(builtinGPUSizes))
for _, name := range []string{"g5.xlarge", "g5.2xlarge", "g5.4xlarge", "g5.8xlarge",
"g5.12xlarge", "g5.16xlarge", "g5.24xlarge", "g5.48xlarge"} {
assert.True(t, hasFamily(types, name), "expected %s", name)
}
@@ -485,7 +486,8 @@ func TestGenerateGPUTypes_NVIDIAa10g(t *testing.T) {
func TestGenerateGPUTypes_DeduplicatesSameFamily(t *testing.T) {
// Two GPUs of the same model (same family) should produce only one set of types.
types := GenerateGPUTypes([]GPUModel{NVIDIAa10g, NVIDIAa10g}, "x86_64")
assert.Len(t, types, 5, "duplicate GPU model should not double the type count")
assert.Len(t, types, len(g5Sizes)+len(builtinGPUSizes),
"duplicate GPU model should not double the type count")
}
func TestGenerateGPUTypes_EmptyModels(t *testing.T) {
@@ -15,6 +15,9 @@ func TestGPUVendorForType(t *testing.T) {
{"p4d.xlarge", "nvidia"},
{"m5.large", ""},
{"", ""},
// Both name a shape, not a family; the vendor comes from discovery.
{"gpu.8x4c", ""},
{"mig.1g.10gb", ""},
}
for _, tc := range tests {
t.Run(tc.instanceType, func(t *testing.T) {
@@ -23,6 +26,9 @@ func TestGPUVendorForType(t *testing.T) {
}
}
// A false answer routes ECS and EKS to the non-GPU AMI, which boots the node
// with its GPUs passed through and no driver. The vendor being unknowable from
// the name is not a reason to call these types CPU-only.
func TestIsGPUTypeName(t *testing.T) {
tests := []struct {
instanceType string
@@ -32,6 +38,9 @@ func TestIsGPUTypeName(t *testing.T) {
{"p4d.xlarge", true},
{"m5.large", false},
{"", false},
{"gpu.1x2c", true},
{"gpu.8x8c", true},
{"mig.1g.10gb", true},
}
for _, tc := range tests {
t.Run(tc.instanceType, func(t *testing.T) {
+279
View File
@@ -0,0 +1,279 @@
//test:in-package — pins instanceFamilyDefs and gpuCountPerType against each
package instancetypes
import (
"fmt"
"slices"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// gpuFamilies are every family in instanceFamilyDefs that GenerateGPUTypes can
// emit. Listed here rather than derived so a new GPU family has to be added
// deliberately, with its counts.
var gpuFamilies = []string{
"g4dn", "g4ad", "g5", "g6", "gr6", "g6e", "g7e",
"p3", "p3dn", "p4d", "p4de", "p5", "p5e",
builtinGPUFamily,
}
// wantGPUCounts pins the GPU count of every size of every GPU family. A size
// added without a count, or a count without a size, fails here — otherwise a
// wrong figure surfaces only when a customer sizes a workload from it.
var wantGPUCounts = map[string]int{
"g4dn.xlarge": 1,
"g4dn.2xlarge": 1,
"g4dn.4xlarge": 1,
"g4dn.8xlarge": 1,
"g4dn.12xlarge": 4,
"g4dn.16xlarge": 1,
"g4ad.xlarge": 1,
"g4ad.2xlarge": 1,
"g4ad.4xlarge": 1,
"g4ad.8xlarge": 2,
"g4ad.16xlarge": 4,
"g5.xlarge": 1,
"g5.2xlarge": 1,
"g5.4xlarge": 1,
"g5.8xlarge": 1,
"g5.12xlarge": 4,
"g5.16xlarge": 1,
"g5.24xlarge": 4,
"g5.48xlarge": 8,
"g6.xlarge": 1,
"g6.2xlarge": 1,
"g6.4xlarge": 1,
"g6.8xlarge": 1,
"g6.12xlarge": 4,
"g6.16xlarge": 1,
"g6.24xlarge": 4,
"g6.48xlarge": 8,
"gr6.4xlarge": 1,
"gr6.8xlarge": 1,
"g6e.xlarge": 1,
"g6e.2xlarge": 1,
"g6e.4xlarge": 1,
"g6e.8xlarge": 1,
"g6e.12xlarge": 4,
"g6e.16xlarge": 1,
"g6e.24xlarge": 4,
"g6e.48xlarge": 8,
// g7e is a Spinifex family, grandfathered and not extended.
"g7e.2xlarge": 1,
"g7e.4xlarge": 1,
"g7e.8xlarge": 1,
"g7e.12xlarge": 2,
"p3.2xlarge": 1,
"p3.8xlarge": 4,
"p3.16xlarge": 8,
"p3dn.2xlarge": 1, // Spinifex single-GPU size
"p3dn.24xlarge": 8,
"p4d.xlarge": 1, // Spinifex single-GPU size
"p4d.24xlarge": 8,
"p4de.xlarge": 1, // Spinifex single-GPU size
"p4de.24xlarge": 8,
"p5.4xlarge": 1, // Spinifex single-GPU size
"p5.48xlarge": 8,
"p5e.4xlarge": 1, // Spinifex single-GPU size
"p5e.48xlarge": 8,
}
func init() {
// The gpu.* family is generated, so its counts are pinned by the shape
// assertions in TestBuiltinGPUFamily rather than repeated by hand.
for n := 1; n <= MaxGPUsPerInstance; n++ {
for _, perGPU := range builtinGPUTiers {
wantGPUCounts[fmt.Sprintf("%s%dx%dc", builtinGPUPrefix, n, perGPU)] = n
}
}
}
func TestGPUCountForType_PinsEveryGPUSize(t *testing.T) {
defined := make(map[string]bool, len(wantGPUCounts))
for _, def := range instanceFamilyDefs {
if !slices.Contains(gpuFamilies, def.name) {
continue
}
for _, size := range def.sizes {
name := def.name + "." + size.suffix
defined[name] = true
want, pinned := wantGPUCounts[name]
if !assert.True(t, pinned, "%s has no pinned GPU count", name) {
continue
}
assert.Equal(t, want, GPUCountForType(name), "GPU count for %s", name)
}
}
for name := range wantGPUCounts {
assert.True(t, defined[name], "%s is pinned but no longer defined — removing a type is breaking", name)
}
}
// Every name in gpuCountPerType must be a type that exists. An entry for a size
// that was never defined silently does nothing.
func TestGPUCountPerType_HasNoOrphanEntries(t *testing.T) {
for name := range gpuCountPerType {
_, ok := DefaultVCPUs(name)
assert.True(t, ok, "gpuCountPerType names %s, which is not a defined instance type", name)
}
}
func TestBuiltinGPUFamily(t *testing.T) {
assert.Len(t, builtinGPUSizes, MaxGPUsPerInstance*len(builtinGPUTiers))
for n := 1; n <= MaxGPUsPerInstance; n++ {
for _, perGPU := range builtinGPUTiers {
name := fmt.Sprintf("%s%dx%dc", builtinGPUPrefix, n, perGPU)
t.Run(name, func(t *testing.T) {
wantVCPUs := n * perGPU
vcpus, ok := DefaultVCPUs(name)
require.True(t, ok, "%s must be a defined type", name)
assert.Equal(t, wantVCPUs, vcpus)
memMiB, ok := DefaultMemoryMiB(name)
require.True(t, ok)
assert.Equal(t, int64(wantVCPUs*builtinGPUMemPerVCPUGB*1024), memMiB,
"every tier carries 4 GiB per vCPU")
assert.Equal(t, n, GPUCountForType(name))
assert.True(t, IsBuiltinGPUType(name))
})
}
}
}
// The prefix is reserved and self-announcing, following sys. and mig.
func TestIsBuiltinGPUType(t *testing.T) {
assert.True(t, IsBuiltinGPUType("gpu.8x4c"))
assert.True(t, IsBuiltinGPUType("gpu.1x2c"))
assert.False(t, IsBuiltinGPUType("g5.xlarge"))
assert.False(t, IsBuiltinGPUType("gpu"))
assert.False(t, IsBuiltinGPUType(""))
}
// gpu.* and mig.* name a shape and a profile, so neither carries a vendor.
func TestGPUVendorForType_BuiltinAndMIGCarryNoVendor(t *testing.T) {
assert.Empty(t, GPUVendorForType("gpu.8x4c"))
assert.Empty(t, GPUVendorForType("mig.1g.10gb"))
assert.Equal(t, "nvidia", GPUVendorForType("g5.xlarge"))
}
// A node's gpu.* types advertise whatever GPU it discovered, whichever AWS
// family that model maps to.
func TestGenerateGPUTypes_BuiltinFamilyReportsTheDiscoveredModel(t *testing.T) {
rtx3090 := GPUModel{
VendorID: "10de", DeviceID: "2204", Family: "g5",
Manufacturer: "NVIDIA", Name: "GeForce RTX 3090", MemoryMiB: 24576,
}
models := make([]GPUModel, 8)
for i := range models {
models[i] = rtx3090
}
types := GenerateGPUTypes(models, "x86_64")
it, ok := types["gpu.8x4c"]
require.True(t, ok, "a homogeneous 8-GPU node must offer gpu.8x4c")
assert.Equal(t, int64(32), *it.VCpuInfo.DefaultVCpus)
assert.Equal(t, int64(128*1024), *it.MemoryInfo.SizeInMiB)
require.Len(t, it.GpuInfo.Gpus, 1)
assert.Equal(t, int64(8), *it.GpuInfo.Gpus[0].Count)
assert.Equal(t, "GeForce RTX 3090", *it.GpuInfo.Gpus[0].Name)
assert.Equal(t, "NVIDIA", *it.GpuInfo.Gpus[0].Manufacturer)
assert.Equal(t, int64(24576), *it.GpuInfo.Gpus[0].MemoryInfo.SizeInMiB)
assert.Equal(t, int64(8*24576), *it.GpuInfo.TotalGpuMemoryInMiB)
// All three tiers, counts 1 through 8, with no gaps.
for n := 1; n <= MaxGPUsPerInstance; n++ {
for _, perGPU := range builtinGPUTiers {
name := fmt.Sprintf("%s%dx%dc", builtinGPUPrefix, n, perGPU)
assert.Contains(t, types, name)
}
}
}
// Two devices advertising the same name and VRAM are interchangeable, so a
// mixed-SKU node of one model (H100 SXM and PCIe differ only by PCI ID) still
// offers multi-GPU types.
func TestGenerateGPUTypes_SameNameAndVRAMIsHomogeneous(t *testing.T) {
types := GenerateGPUTypes([]GPUModel{NVIDIAh100sxm, NVIDIAh100pcie}, "x86_64")
assert.Contains(t, types, "gpu.2x4c")
assert.Contains(t, types, "p5.48xlarge")
}
// Handing one instance a mixture of models tends to make NCCL hang rather than
// fail, so a node that resolved more than one offers single-GPU types only.
func TestGenerateGPUTypes_MixedModelsOfferSingleGPUTypesOnly(t *testing.T) {
types := GenerateGPUTypes([]GPUModel{NVIDIAa10g, NVIDIAl40s}, "x86_64")
require.NotEmpty(t, types)
for name, it := range types {
require.Len(t, it.GpuInfo.Gpus, 1)
assert.Equal(t, int64(1), *it.GpuInfo.Gpus[0].Count,
"%s must not be offered on a node with more than one GPU model", name)
assert.Equal(t, 1, GPUCountForType(name))
}
// The single-GPU sizes of both families survive, and so does gpu.1x*.
assert.Contains(t, types, "g5.xlarge")
assert.Contains(t, types, "g6e.xlarge")
assert.Contains(t, types, "gpu.1x4c")
assert.NotContains(t, types, "gpu.2x4c")
assert.NotContains(t, types, "g5.48xlarge")
}
// MIG types stay at count 1: EC2 has no multi-slice type, and an accidental
// capability is awkward to withdraw once someone depends on it.
func TestGenerateMIGTypes_CountIsAlwaysOne(t *testing.T) {
profiles := []MIGProfileSpec{
{Name: "1g.10gb", MemoryMiB: 10240},
{Name: "3g.40gb", MemoryMiB: 40960},
{Name: "7g.80gb", MemoryMiB: 81920},
}
types := GenerateMIGTypes(profiles, "x86_64")
require.Len(t, types, len(profiles))
for name, it := range types {
require.Len(t, it.GpuInfo.Gpus, 1)
assert.Equal(t, int64(1), *it.GpuInfo.Gpus[0].Count, "%s", name)
assert.Equal(t, 1, GPUCountForType(name), "%s", name)
assert.Equal(t, *it.GpuInfo.Gpus[0].MemoryInfo.SizeInMiB, *it.GpuInfo.TotalGpuMemoryInMiB,
"%s: one slice is the whole allocation", name)
}
}
// A multi-GPU AWS size must report its real GPU count and the aggregate VRAM
// that follows from it.
func TestGenerateGPUTypes_MultiGPUAWSSizes(t *testing.T) {
models := make([]GPUModel, 8)
for i := range models {
models[i] = NVIDIAa10g
}
types := GenerateGPUTypes(models, "x86_64")
it, ok := types["g5.48xlarge"]
require.True(t, ok)
assert.Equal(t, int64(192), *it.VCpuInfo.DefaultVCpus)
assert.Equal(t, int64(768*1024), *it.MemoryInfo.SizeInMiB)
assert.Equal(t, int64(8), *it.GpuInfo.Gpus[0].Count)
assert.Equal(t, int64(8*24576), *it.GpuInfo.TotalGpuMemoryInMiB)
assert.Equal(t, "g5.48xlarge", aws.StringValue(it.InstanceType))
}
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_id-x-YmyORB.js","assets/compiler-runtime-D_vZfn79.js","assets/useQuery-2EKf7AyV.js","assets/useBaseQuery-Dqg0ViLa.js","assets/removable-Dwr9otKB.js","assets/query-BPr_oSEg.js","assets/useSuspenseQuery-Bc7E1P-f.js","assets/link-Dhy4xOXd.js","assets/useSelector-CRh8wCTv.js","assets/with-selector-DrM041uv.js","assets/cluster-config-xw_Q7X1e.js","assets/hex-encoding-B19xAT_7.js","assets/createLucideIcon-BQgzNCBR.js","assets/back-link-CjKy1JE5.js","assets/select-B01jpDaX.js","assets/useValueAsRef-F56m8WbP.js","assets/dist-BKgR2qk6.js","assets/button-BarTr35m.js","assets/createBaseUIEventDetails-DL-8egB9.js","assets/check-BO7W8OSs.js","assets/chevron-down-Dt4q6d24.js","assets/useValueChanged-B2TUO8IO.js","assets/useScrollLock-NX6x9Pv8.js","assets/usePositioner-DUsvphCt.js","assets/DirectionContext-C9rdm8xn.js","assets/useControlled-DwxdYHYK.js","assets/useCompositeListItem-CEsaEeXb.js","assets/rotate-cw-FTUcK_f-.js","assets/trash-DVab4pEz.js","assets/input-6kdUb_DH.js","assets/admin-context-CXmiyIcc.js","assets/sts-B5ZEON3v.js","assets/signed-fetch-B-FFW93q.js","assets/ec2-CWEplLq4.js","assets/queryOptions-Dfvzj6n2.js","assets/awsClient-b0hcP1Iz.js","assets/index.browser-C3WbWNjZ.js","assets/ec2-Ctto2xjU.js","assets/useMutation-6rdxVQpi.js","assets/mutation-BziiWz5u.js","assets/subnet-calculator-BlCTyGtY.js","assets/iam-CVg27y22.js","assets/commandBuilder-CoLHZwsC.js","assets/json-DsgWHaZY.js","assets/page-heading-vksh_67w.js","assets/admin-BUkrFIlI.js","assets/error-banner-DeOeVBKS.js","assets/field-c7GHI5st.js","assets/separator-B3CHPrlt.js","assets/textarea-n04EavUm.js","assets/alert-dialog-kCQ1W4Rz.js","assets/DialogTitle-DiInIGjk.js","assets/delete-confirmation-dialog-SdDN4LiF.js","assets/detail-row-DNBRlVeK.js","assets/state-badge-D1Z6q_OA.js","assets/ami-details-ChpQ4w1b.js","assets/gpu-instance-type-select-BD2r8UcO.js"])))=>i.map(i=>d[i]);
import{n as e,t}from"./lazyRouteComponent-CiZDS8hZ.js";import{lt as n}from"./cluster-config-xw_Q7X1e.js";import{a as r,c as i,s as a}from"./ec2-CWEplLq4.js";var o=e(`/_auth/ec2/(instances)/describe-instances/$id`)({loader:async({context:e,params:t})=>{let[n]=await Promise.all([e.queryClient.query({...a(t.id),staleTime:`static`}),e.queryClient.query({...i,staleTime:`static`})]),o=n.Reservations?.[0]?.Instances?.[0]?.ImageId;return await e.queryClient.query({...r(o),staleTime:`static`}),n},head:({loaderData:e})=>({meta:[{title:`${e?.Reservations?.[0]?.Instances?.[0]?.InstanceId??`Instance`} | EC2 | Mulga`}]}),component:t(()=>n(()=>import(`./_id-x-YmyORB.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56])),`component`)});export{o as t};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_id-BnUjyn0E.js","assets/compiler-runtime-D_vZfn79.js","assets/useQuery-2EKf7AyV.js","assets/useBaseQuery-Dqg0ViLa.js","assets/removable-Dwr9otKB.js","assets/query-BPr_oSEg.js","assets/useSuspenseQuery-Bc7E1P-f.js","assets/link-Dhy4xOXd.js","assets/useSelector-CRh8wCTv.js","assets/with-selector-DrM041uv.js","assets/cluster-config-xw_Q7X1e.js","assets/hex-encoding-B19xAT_7.js","assets/createLucideIcon-BQgzNCBR.js","assets/back-link-CjKy1JE5.js","assets/select-B01jpDaX.js","assets/useValueAsRef-F56m8WbP.js","assets/dist-BKgR2qk6.js","assets/button-BarTr35m.js","assets/createBaseUIEventDetails-DL-8egB9.js","assets/check-BO7W8OSs.js","assets/chevron-down-Dt4q6d24.js","assets/useValueChanged-B2TUO8IO.js","assets/useScrollLock-NX6x9Pv8.js","assets/usePositioner-DUsvphCt.js","assets/DirectionContext-C9rdm8xn.js","assets/useControlled-DwxdYHYK.js","assets/useCompositeListItem-CEsaEeXb.js","assets/rotate-cw-FTUcK_f-.js","assets/trash-DVab4pEz.js","assets/input-6kdUb_DH.js","assets/admin-context-CXmiyIcc.js","assets/sts-B5ZEON3v.js","assets/signed-fetch-B-FFW93q.js","assets/ec2-CWEplLq4.js","assets/queryOptions-Dfvzj6n2.js","assets/awsClient-b0hcP1Iz.js","assets/index.browser-C3WbWNjZ.js","assets/ec2-Ctto2xjU.js","assets/useMutation-6rdxVQpi.js","assets/mutation-BziiWz5u.js","assets/subnet-calculator-BlCTyGtY.js","assets/iam-CVg27y22.js","assets/commandBuilder-CoLHZwsC.js","assets/json-DsgWHaZY.js","assets/page-heading-vksh_67w.js","assets/admin-BUkrFIlI.js","assets/error-banner-DeOeVBKS.js","assets/field-c7GHI5st.js","assets/separator-B3CHPrlt.js","assets/textarea-n04EavUm.js","assets/alert-dialog-kCQ1W4Rz.js","assets/DialogTitle-DiInIGjk.js","assets/delete-confirmation-dialog-SdDN4LiF.js","assets/detail-row-DNBRlVeK.js","assets/state-badge-D1Z6q_OA.js","assets/ami-details-ChpQ4w1b.js","assets/gpu-instance-type-select-BD2r8UcO.js"])))=>i.map(i=>d[i]);
import{n as e,t}from"./lazyRouteComponent-CiZDS8hZ.js";import{lt as n}from"./cluster-config-xw_Q7X1e.js";import{a as r,c as i,s as a}from"./ec2-CWEplLq4.js";var o=e(`/_auth/ec2/(instances)/describe-instances/$id`)({loader:async({context:e,params:t})=>{let[n]=await Promise.all([e.queryClient.query({...a(t.id),staleTime:`static`}),e.queryClient.query({...i,staleTime:`static`})]),o=n.Reservations?.[0]?.Instances?.[0]?.ImageId;return await e.queryClient.query({...r(o),staleTime:`static`}),n},head:({loaderData:e})=>({meta:[{title:`${e?.Reservations?.[0]?.Instances?.[0]?.InstanceId??`Instance`} | EC2 | Mulga`}]}),component:t(()=>n(()=>import(`./_id-BnUjyn0E.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56])),`component`)});export{o as t};
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,7 +7,7 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Spinifex Control Panel" />
<script type="module" crossorigin src="/assets/index-CJ3qP1oV.js"></script>
<script type="module" crossorigin src="/assets/index-BrcYJ9RS.js"></script>
<link rel="modulepreload" crossorigin href="/assets/compiler-runtime-D_vZfn79.js">
<link rel="modulepreload" crossorigin href="/assets/removable-Dwr9otKB.js">
<link rel="modulepreload" crossorigin href="/assets/query-BPr_oSEg.js">
@@ -74,7 +74,7 @@
<link rel="modulepreload" crossorigin href="/assets/_knowledgeBaseId-7saNV50A.js">
<link rel="modulepreload" crossorigin href="/assets/_id-FFfcJrCW.js">
<link rel="modulepreload" crossorigin href="/assets/_id-Df06xbgH.js">
<link rel="modulepreload" crossorigin href="/assets/_id-DHGMf_p-.js">
<link rel="modulepreload" crossorigin href="/assets/_id-U4BZhcdn.js">
<link rel="modulepreload" crossorigin href="/assets/_id-DpCiHWsF.js">
<link rel="modulepreload" crossorigin href="/assets/_id-CXZivkIZ.js">
<link rel="modulepreload" crossorigin href="/assets/_id-UPy9yakk.js">
@@ -73,7 +73,7 @@ interface VMInfo {
node: string
launch_time: number
managed_by?: string
gpu?: VMGPUInfo
gpus?: VMGPUInfo[]
}
interface GetVMsOutput {
@@ -331,7 +331,7 @@ function InstanceDetail() {
<IamRolePanel instanceId={instance.InstanceId} />
{/* GPU */}
{isAdmin && <GpuDetailCard gpu={vmInfo?.gpu} />}
{isAdmin && <GpuDetailCard gpus={vmInfo?.gpus} />}
{/* Block Device Mappings */}
{instance.BlockDeviceMappings &&
@@ -6,8 +6,11 @@ import type { VMGPUInfo } from "@/queries/admin"
import { GpuDetailCard } from "./gpu-detail-card"
describe("GpuDetailCard", () => {
it("renders nothing when no gpu is attached", () => {
const { container } = render(<GpuDetailCard gpu={undefined} />)
it("renders nothing when no GPUs are attached", () => {
const { container, rerender } = render(<GpuDetailCard gpus={undefined} />)
expect(container).toBeEmptyDOMElement()
rerender(<GpuDetailCard gpus={[]} />)
expect(container).toBeEmptyDOMElement()
})
@@ -18,7 +21,7 @@ describe("GpuDetailCard", () => {
profile: "1g.24gb",
mdev_path: "/sys/bus/mdev/devices/abc-123",
}
render(<GpuDetailCard gpu={gpu} />)
render(<GpuDetailCard gpus={[gpu]} />)
expect(screen.getByText("GPU")).toBeInTheDocument()
expect(screen.getByText("RTX Pro 6000 Blackwell SE")).toBeInTheDocument()
@@ -36,10 +39,33 @@ describe("GpuDetailCard", () => {
vram_mib: 24_576,
pci_address: "0000:01:00.0",
}
render(<GpuDetailCard gpu={gpu} />)
render(<GpuDetailCard gpus={[gpu]} />)
expect(screen.getByText("PCIe passthrough")).toBeInTheDocument()
expect(screen.getByText("0000:01:00.0")).toBeInTheDocument()
expect(screen.queryByText("Profile")).not.toBeInTheDocument()
})
it("renders every attached GPU in attachment order", () => {
const gpus: VMGPUInfo[] = [
{
model: "RTX 3090",
vram_mib: 24_576,
pci_address: "0000:01:00.0",
},
{
model: "RTX 3090",
vram_mib: 24_576,
pci_address: "0000:02:00.0",
},
]
render(<GpuDetailCard gpus={gpus} />)
expect(screen.getByText("GPUs (2)")).toBeInTheDocument()
expect(screen.getByText("GPU 1")).toBeInTheDocument()
expect(screen.getByText("GPU 2")).toBeInTheDocument()
expect(screen.getByText("0000:01:00.0")).toBeInTheDocument()
expect(screen.getByText("0000:02:00.0")).toBeInTheDocument()
expect(screen.getAllByText("RTX 3090")).toHaveLength(2)
})
})
@@ -3,27 +3,43 @@ import { DetailRow } from "@/components/detail-row"
import { formatVRAMMiB } from "@/lib/utils"
import type { VMGPUInfo } from "@/queries/admin"
export function GpuDetailCard({ gpu }: { gpu?: VMGPUInfo }) {
if (!gpu) {
export function GpuDetailCard({ gpus }: { gpus?: VMGPUInfo[] }) {
if (!gpus?.length) {
return null
}
return (
<DetailCard>
<DetailCard.Header>GPU</DetailCard.Header>
<DetailCard.Content>
<DetailRow label="Model" value={gpu.model} />
<DetailRow label="VRAM" value={formatVRAMMiB(gpu.vram_mib)} />
<DetailRow
label="Attachment"
value={gpu.profile ? "MIG slice" : "PCIe passthrough"}
/>
{gpu.profile && <DetailRow label="Profile" value={gpu.profile} />}
{gpu.mdev_path && <DetailRow label="Mdev path" value={gpu.mdev_path} />}
{gpu.pci_address && (
<DetailRow label="PCI address" value={gpu.pci_address} />
)}
</DetailCard.Content>
<DetailCard.Header>
{gpus.length === 1 ? "GPU" : `GPUs (${gpus.length})`}
</DetailCard.Header>
{gpus.map((gpu, index) => (
<div
className="border-b last:border-b-0"
key={gpu.mdev_path ?? gpu.pci_address ?? `${gpu.model}-${index}`}
>
{gpus.length > 1 && (
<h3 className="bg-muted/30 px-4 py-2 text-sm font-medium">
GPU {index + 1}
</h3>
)}
<DetailCard.Content>
<DetailRow label="Model" value={gpu.model} />
<DetailRow label="VRAM" value={formatVRAMMiB(gpu.vram_mib)} />
<DetailRow
label="Attachment"
value={gpu.profile ? "MIG slice" : "PCIe passthrough"}
/>
{gpu.profile && <DetailRow label="Profile" value={gpu.profile} />}
{gpu.mdev_path && (
<DetailRow label="Mdev path" value={gpu.mdev_path} />
)}
{gpu.pci_address && (
<DetailRow label="PCI address" value={gpu.pci_address} />
)}
</DetailCard.Content>
</div>
))}
</DetailCard>
)
}
@@ -240,14 +240,17 @@ function NodesTable({ nodes }: { nodes: NodeInfo[] }) {
)
}
export function formatVMGPU(gpu: VMInfo["gpu"]): string {
if (!gpu) {
export function formatVMGPUs(gpus: VMInfo["gpus"]): string {
if (!gpus?.length) {
return "-"
}
if (gpu.profile) {
return `${gpu.profile} ${formatVRAMMiB(gpu.vram_mib)}`
}
return `${gpu.model} ${formatVRAMMiB(gpu.vram_mib)}`
return gpus
.map((gpu) =>
gpu.profile
? `${gpu.profile} ${formatVRAMMiB(gpu.vram_mib)}`
: `${gpu.model} ${formatVRAMMiB(gpu.vram_mib)}`,
)
.join(", ")
}
function VMsTable({ vms }: { vms: VMInfo[] }) {
@@ -281,7 +284,7 @@ function VMsTable({ vms }: { vms: VMInfo[] }) {
<td className="py-1.5 pr-4 font-mono">{vm.instance_type}</td>
<td className="py-1.5 pr-4">{vm.vcpu}</td>
<td className="py-1.5 pr-4">{formatMemory(vm.memory_gb)}</td>
<td className="py-1.5 pr-4 font-mono">{formatVMGPU(vm.gpu)}</td>
<td className="py-1.5 pr-4 font-mono">{formatVMGPUs(vm.gpus)}</td>
<td className="py-1.5 pr-4 font-mono">{vm.node}</td>
<td className="py-1.5">
{vm.launch_time > 0 ? formatAge(vm.launch_time) : "-"}
+3 -3
View File
@@ -27,7 +27,7 @@ type GPUInfo struct {
Slices []GPUSliceInfo `json:"slices,omitempty"`
}
// VMGPUInfo describes the GPU attached to a VM.
// VMGPUInfo describes one GPU attached to a VM.
type VMGPUInfo struct {
Model string `json:"model"`
VRAMMiB int64 `json:"vram_mib"`
@@ -93,8 +93,8 @@ type VMInfo struct {
// ManagedBy is the Spinifex platform component that owns this VM
// (e.g. "elbv2"). Empty for customer VMs. The UI uses this to filter
// system-managed resources out of customer-facing listings.
ManagedBy string `json:"managed_by,omitempty"`
GPU *VMGPUInfo `json:"gpu,omitempty"`
ManagedBy string `json:"managed_by,omitempty"`
GPUs []VMGPUInfo `json:"gpus,omitempty"`
// Health is a display label for instance health: "ok", "impaired",
// "recovering", or "-" for non-running VMs. CrashCount is the lifetime
// crash tally within the current restart window.