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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 89 additions & 21 deletions integration/vgpu_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"

Expand All @@ -21,21 +23,23 @@ import (
"github.com/stretchr/testify/require"
)

// TestVGPU is an integration test that verifies vGPU (SR-IOV mdev) support works.
// TestVGPU is an integration test that verifies vGPU (SR-IOV) support works
// on the host's framework: mdev or NVIDIA's vendor-specific VFIO.
//
// This test automatically detects vGPU availability and skips if:
// - No SR-IOV VFs are found in /sys/class/mdev_bus/
// - No vGPU framework (mdev or vendor VFIO) is discovered
// - No vGPU profiles are available
// - Not running as root (required for mdev creation)
// - Not running as root (required for sysfs vGPU assignment)
// - KVM is not available
//
// To run manually:
//
// sudo go test -v -run TestVGPU -timeout 5m ./integration/...
//
// Note: This test verifies mdev creation and PCI device visibility inside the VM.
// It does NOT test nvidia-smi or CUDA functionality since that requires NVIDIA
// guest drivers pre-installed in the image.
// Note: This test verifies vGPU assignment, release on stop, reacquisition on
// start, and PCI device visibility inside the VM. It does NOT test nvidia-smi
// or CUDA functionality since that requires NVIDIA guest drivers pre-installed
// in the image.
func TestVGPU(t *testing.T) {
t.Parallel()
if testing.Short() {
Expand Down Expand Up @@ -159,9 +163,18 @@ func TestVGPU(t *testing.T) {
instanceID = inst.Id
t.Logf("Instance created: %s", inst.Id)

// Verify mdev UUID was assigned
require.NotEmpty(t, inst.GPUMdevUUID, "Instance should have mdev UUID assigned")
t.Logf("mdev UUID: %s", inst.GPUMdevUUID)
// Verify the assignment matches the host's framework
require.NotEmpty(t, inst.GPUDevicePath, "Instance should have a vGPU device path assigned")
switch inst.GPUFramework {
case devices.VGPUFrameworkMdev:
require.NotEmpty(t, inst.GPUMdevUUID, "mdev instance should have a UUID assigned")
t.Logf("mdev UUID: %s", inst.GPUMdevUUID)
case devices.VGPUFrameworkVendorVFIO:
require.Empty(t, inst.GPUMdevUUID, "vendor VFIO instance should not have an mdev UUID")
t.Logf("vendor VFIO VF: %s", inst.GPUDevicePath)
default:
t.Fatalf("unexpected vGPU framework %q", inst.GPUFramework)
}

// Step 5: Check GPU resources AFTER creating instance
t.Run("ResourcesDecrementedAfterCreation", func(t *testing.T) {
Expand All @@ -180,12 +193,9 @@ func TestVGPU(t *testing.T) {
assert.Less(t, availableAfter, availableBefore, "available instances should decrease after creating VM")
})

// Step 6: Verify mdev was created in sysfs
t.Run("MdevCreated", func(t *testing.T) {
mdevPath := "/sys/bus/mdev/devices/" + inst.GPUMdevUUID
_, err := os.Stat(mdevPath)
assert.NoError(t, err, "mdev device should exist at %s", mdevPath)
t.Logf("mdev exists at: %s", mdevPath)
// Step 6: Verify the assignment exists in sysfs
t.Run("VGPUAssignedInSysfs", func(t *testing.T) {
assertVGPUAssigned(t, inst.GPUFramework, inst.GPUDevicePath)
})

// Step 7: Wait for guest agent to be ready
Expand Down Expand Up @@ -225,13 +235,68 @@ func TestVGPU(t *testing.T) {
require.NoError(t, err)

assert.Equal(t, profile, actualInst.GPUProfile, "GPU profile should match")
assert.NotEmpty(t, actualInst.GPUMdevUUID, "mdev UUID should be set")
t.Logf("Instance GPU: profile=%s, mdev=%s", actualInst.GPUProfile, actualInst.GPUMdevUUID)
assert.Equal(t, inst.GPUFramework, actualInst.GPUFramework, "framework should match")
assert.NotEmpty(t, actualInst.GPUDevicePath, "device path should be set")
if inst.GPUFramework == devices.VGPUFrameworkMdev {
assert.NotEmpty(t, actualInst.GPUMdevUUID, "mdev UUID should be set")
}
t.Logf("Instance GPU: profile=%s, framework=%s, device=%s", actualInst.GPUProfile, actualInst.GPUFramework, actualInst.GPUDevicePath)
})

t.Log("Step 10: Stopping instance to release the vGPU...")
_, err = instanceManager.StopInstance(ctx, inst.Id)
require.NoError(t, err, "stop should succeed")

t.Run("VGPUReleasedOnStop", func(t *testing.T) {
stopped, err := instanceManager.GetInstance(ctx, inst.Id)
require.NoError(t, err)
assert.Empty(t, stopped.GPUDevicePath, "assignment metadata should be cleared on stop")
assertVGPUReleased(t, inst.GPUFramework, inst.GPUDevicePath)
})

t.Log("Step 11: Starting instance to reacquire a vGPU...")
started, err := instanceManager.StartInstance(ctx, inst.Id, instances.StartInstanceRequest{})
require.NoError(t, err, "start should succeed")

t.Run("VGPUReacquiredOnStart", func(t *testing.T) {
require.NotEmpty(t, started.GPUDevicePath, "start should assign a vGPU")
assert.Equal(t, inst.GPUFramework, started.GPUFramework, "framework should match")
assertVGPUAssigned(t, started.GPUFramework, started.GPUDevicePath)
})

t.Log("✅ vGPU test PASSED!")
}

func assertVGPUAssigned(t *testing.T, framework devices.VGPUFramework, devicePath string) {
t.Helper()
switch framework {
case devices.VGPUFrameworkMdev:
_, err := os.Stat(devicePath)
assert.NoError(t, err, "mdev device should exist at %s", devicePath)
case devices.VGPUFrameworkVendorVFIO:
data, err := os.ReadFile(filepath.Join(devicePath, "nvidia", "current_vgpu_type"))
require.NoError(t, err, "VF should expose current_vgpu_type")
assert.NotEqual(t, "0", strings.TrimSpace(string(data)), "VF should have a vGPU type assigned")
default:
t.Fatalf("unexpected vGPU framework %q", framework)
}
}

func assertVGPUReleased(t *testing.T, framework devices.VGPUFramework, devicePath string) {
t.Helper()
switch framework {
case devices.VGPUFrameworkMdev:
_, err := os.Stat(devicePath)
assert.True(t, os.IsNotExist(err), "mdev device should be gone from %s", devicePath)
case devices.VGPUFrameworkVendorVFIO:
data, err := os.ReadFile(filepath.Join(devicePath, "nvidia", "current_vgpu_type"))
require.NoError(t, err, "VF should expose current_vgpu_type")
assert.Equal(t, "0", strings.TrimSpace(string(data)), "VF assignment should be released")
default:
t.Fatalf("unexpected vGPU framework %q", framework)
}
}

// checkVGPUTestPrerequisites checks if vGPU test can run.
// Returns (skipReason, profileName) - skipReason is empty if all prerequisites are met.
func checkVGPUTestPrerequisites() (string, string) {
Expand All @@ -245,10 +310,13 @@ func checkVGPUTestPrerequisites() (string, string) {
return "vGPU test requires root (sudo) for mdev creation", ""
}

// Check for vGPU mode (SR-IOV VFs present)
mode := devices.DetectHostGPUMode()
if mode != devices.GPUModeVGPU {
return "vGPU test requires SR-IOV VFs in /sys/class/mdev_bus/", ""
// Check for a vGPU framework (mdev or vendor VFIO)
framework, _, err := devices.DiscoverVGPU()
if err != nil {
return "vGPU test failed to discover vGPU framework: " + err.Error(), ""
}
if framework == devices.VGPUFrameworkNone {
return "vGPU test requires SR-IOV VFs with an mdev or vendor VFIO vGPU framework", ""
}

// Check for available profiles
Expand Down
46 changes: 23 additions & 23 deletions lib/devices/GPU.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ hypeman supports two GPU modes, automatically detected based on host configurati

| Mode | Description | Use Case |
|------|-------------|----------|
| **vGPU (SR-IOV)** | Virtual GPUs via mdev on SR-IOV VFs | Multi-tenant, shared GPU resources |
| **vGPU (SR-IOV)** | Virtual GPUs on SR-IOV VFs via mdev or vendor VFIO | Multi-tenant, shared GPU resources |
| **Passthrough** | Whole GPU VFIO passthrough | Dedicated GPU per instance |

The host's GPU mode is determined by the host driver configuration:
- If `/sys/class/mdev_bus/` contains VFs → vGPU mode
- If NVIDIA GPUs are available for VFIO → passthrough mode
- If `/sys/class/mdev_bus/` contains VFs → mdev vGPU mode
- If VFs expose `/sys/bus/pci/devices/<VF>/nvidia/current_vgpu_type` → vendor VFIO vGPU mode
- If NVIDIA GPUs are available for whole-device VFIO → passthrough mode

## vGPU Mode (Recommended)

vGPU mode uses NVIDIA's SR-IOV technology to create Virtual Functions (VFs), each capable of hosting an mdev (mediated device) representing a vGPU.
vGPU mode uses NVIDIA's SR-IOV technology to create Virtual Functions (VFs). Hosts on older kernels represent each vGPU as an mdev. Hosts using NVIDIA's vendor VFIO framework assign the profile directly to the VF through `current_vgpu_type`.

### How It Works

Expand Down Expand Up @@ -74,7 +75,7 @@ curl -X POST http://localhost:4973/instances \
}'
```

The response includes the assigned mdev UUID:
On an mdev host, the response also includes the assigned mdev UUID:

```json
{
Expand All @@ -87,19 +88,16 @@ The response includes the assigned mdev UUID:
}
```

### Ephemeral mdev Lifecycle
### Ephemeral vGPU Lifecycle

mdev devices are **ephemeral**: created on instance start, destroyed on instance delete.
vGPU assignments are created on instance start and released on stop or delete. Hypeman creates/removes an mdev on mdev hosts and writes the profile ID/`0` to `current_vgpu_type` on vendor VFIO hosts.

```
Instance Create → Create mdev → Attach to VM → Instance Running
Instance Delete → Stop VM → Destroy mdev → VF available again
Instance Create → Assign profile to VF → Attach VF to VM → Instance Running
Instance Stop/Delete → Release profile → VF available again
```

This ensures:
- **Security**: No VRAM data leakage between instances
- **Clean state**: Fresh vGPU for each instance
- **Automatic cleanup**: Orphaned mdevs cleaned up on server restart
Hypeman reconciles orphaned assignments on server restart while preserving devices held open by a running VMM.

## Passthrough Mode

Expand Down Expand Up @@ -256,7 +254,8 @@ If assignment cleanup fails, Hypeman retains the instance metadata so a compatib

1. Check host GPU mode detection:
```bash
ls /sys/class/mdev_bus/ # Should show VFs for vGPU mode
ls /sys/class/mdev_bus/
find /sys/bus/pci/devices -path '*/nvidia/current_vgpu_type'
```

2. Verify NVIDIA drivers are loaded on host:
Expand All @@ -280,17 +279,18 @@ curl -s http://localhost:4973/resources | jq '.gpu.profiles'
curl http://localhost:4973/instances/<id>/logs?source=app
```

### mdev creation fails
### vGPU assignment fails

1. Check if VFs are available:
```bash
ls /sys/class/mdev_bus/
```
Check the files for the framework detected on the host:

2. Verify mdev types:
```bash
cat /sys/class/mdev_bus/*/mdev_supported_types/*/available_instances
```
```bash
# mdev
cat /sys/class/mdev_bus/*/mdev_supported_types/*/available_instances

# vendor VFIO
cat /sys/bus/pci/devices/*/nvidia/creatable_vgpu_types
cat /sys/bus/pci/devices/*/nvidia/current_vgpu_type
```

## Performance Tuning

Expand Down
30 changes: 0 additions & 30 deletions lib/devices/gpu_mode.go

This file was deleted.

13 changes: 8 additions & 5 deletions lib/devices/mdev_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@
// No-op on macOS
}

// DiscoverVFs returns an empty list on macOS.
// SR-IOV Virtual Functions are not available on macOS.
func DiscoverVFs() ([]VirtualFunction, error) {
return []VirtualFunction{}, nil
// DiscoverVGPU reports no vGPU framework on macOS.
func DiscoverVGPU() (VGPUFramework, []VirtualFunction, error) {
return VGPUFrameworkNone, nil, nil
}

// ListGPUProfiles returns an empty list on macOS.
Expand All @@ -21,7 +20,7 @@
}

// ListGPUProfilesWithVFs returns an empty list on macOS.
func ListGPUProfilesWithVFs(vfs []VirtualFunction) ([]GPUProfile, error) {
func ListGPUProfilesWithVFs(framework VGPUFramework, vfs []VirtualFunction) ([]GPUProfile, error) {
return []GPUProfile{}, nil
}

Expand Down Expand Up @@ -51,11 +50,15 @@

func DestroyVGPU(ctx context.Context, assignment VGPUAssignment) error {
if assignment.Framework != VGPUFrameworkNone && assignment.Framework != VGPUFrameworkMdev {
return fmt.Errorf("unknown vGPU framework %q", assignment.Framework)

Check failure on line 53 in lib/devices/mdev_darwin.go

View workflow job for this annotation

GitHub Actions / test-darwin

undefined: fmt
}
return nil
}

func ReconcileVGPUs(ctx context.Context, protectedDevicePaths map[string]struct{}) error {
return nil
}

// ReconcileMdevs is a no-op on macOS.
func ReconcileMdevs(ctx context.Context, instanceInfos []MdevReconcileInfo) error {
return nil
Expand Down
32 changes: 10 additions & 22 deletions lib/devices/mdev_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,13 @@ func getCachedProfiles(firstVF string) []profileMetadata {
return cachedProfiles
}

// DiscoverVFs returns all SR-IOV Virtual Functions available for vGPU.
// These are discovered by scanning /sys/class/mdev_bus/ which contains
// VFs that can host mdev devices.
func DiscoverVFs() ([]VirtualFunction, error) {
// discoverMdevVFs returns all SR-IOV Virtual Functions available for vGPU,
// discovered by scanning /sys/class/mdev_bus/.
func discoverMdevVFs() ([]VirtualFunction, error) {
entries, err := os.ReadDir(mdevBusPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // No mdev_bus means no vGPU support
return nil, nil // No mdev_bus means no mdev vGPU support
}
return nil, fmt.Errorf("read mdev_bus: %w", err)
}
Expand Down Expand Up @@ -133,20 +132,9 @@ func DiscoverVFs() ([]VirtualFunction, error) {
return vfs, nil
}

// ListGPUProfiles returns available vGPU profiles with availability counts.
// Profiles are discovered from the first VF's mdev_supported_types directory.
func ListGPUProfiles() ([]GPUProfile, error) {
vfs, err := DiscoverVFs()
if err != nil {
return nil, err
}
return ListGPUProfilesWithVFs(vfs)
}

// ListGPUProfilesWithVFs returns available vGPU profiles using pre-discovered VFs.
// This avoids redundant VF discovery when the caller already has the list.
// Uses parallel sysfs reads for fast availability counting.
func ListGPUProfilesWithVFs(vfs []VirtualFunction) ([]GPUProfile, error) {
// listMdevGPUProfilesWithVFs returns available vGPU profiles using
// pre-discovered VFs. Uses parallel sysfs reads for fast availability counting.
func listMdevGPUProfilesWithVFs(vfs []VirtualFunction) ([]GPUProfile, error) {
if len(vfs) == 0 {
return nil, nil
}
Expand Down Expand Up @@ -305,7 +293,7 @@ func countAvailableForSingleProfile(freeVFsByParent map[string][]VirtualFunction

// findProfileType finds the internal type name (e.g., "nvidia-556") for a profile name (e.g., "L40S-1Q")
func findProfileType(profileName string) (string, error) {
vfs, err := DiscoverVFs()
vfs, err := discoverMdevVFs()
if err != nil || len(vfs) == 0 {
return "", fmt.Errorf("no VFs available")
}
Expand Down Expand Up @@ -531,7 +519,7 @@ func CreateMdev(ctx context.Context, profileName, instanceID string) (*MdevDevic
}

// Discover all VFs
vfs, err := DiscoverVFs()
vfs, err := discoverMdevVFs()
if err != nil {
return nil, fmt.Errorf("discover VFs: %w", err)
}
Expand Down Expand Up @@ -697,7 +685,7 @@ func ReconcileMdevs(ctx context.Context, instanceInfos []MdevReconcileInfo) erro
log := logger.FromContext(ctx)
_ = instanceInfos

vfs, err := DiscoverVFs()
vfs, err := discoverMdevVFs()
if err != nil {
return fmt.Errorf("discover managed VFs: %w", err)
}
Expand Down
Loading
Loading