多租户 GPU 集群调度:云原生 AI 平台资源隔离与弹性分配的工程实践

多租户 GPU 集群调度:云原生 AI 平台资源隔离与弹性分配的工程实践

一、GPU 资源争抢与成本失控:AI 平台落地的核心瓶颈

在 AI 平台落地过程中,GPU 资源管理往往是最先暴露问题的环节。当多个团队共享同一批 GPU 服务器时,资源争抢导致训练任务排队、推理服务延迟飙升,而闲置时段又造成大量算力浪费。根据生产环境的监控数据,未经优化的 GPU 集群利用率通常只有 30%---40%,剩余 60% 的算力在等待中被白白消耗。

问题的根源在于:Kubernetes 原生的调度器仅支持 CPU/内存维度的资源分配,对 GPU 这种昂贵且不可压缩的资源缺乏细粒度的管控能力。一个训练任务独占整张 A100,但实际显存占用可能只有 40%;而另一个推理服务因为调度不到 GPU 而持续排队。这种"一边浪费、一边饥饿"的矛盾,正是多租户 GPU 调度需要解决的核心痛点。

二、GPU 资源隔离与调度的底层机制

2.1 Kubernetes GPU 调度的原生局限

Kubernetes 通过 Device Plugin 机制接入 GPU 资源,NVIDIA Device Plugin 将每张 GPU 注册为一个 nvidia.com/gpu 资源,调度器以整卡为最小分配单位。这意味着一个 Pod 要么独占一张 GPU,要么无法获得任何 GPU 资源。

graph TD A[Pod 请求 GPU] --> B{调度器检查} B -->|整卡可用| C[分配整张 GPU] B -->|整卡不可用| D[Pod Pending] E[GPU 显存占用 40%] --> F[剩余 60% 闲置] F --> D style D fill:#f96,stroke:#333 style F fill:#ff9,stroke:#333

2.2 多租户调度的三层架构

要实现 GPU 资源的高效共享,需要在三个层面建立隔离机制:

graph TB subgraph 调度层 S1[队列调度器<br/>Capactiy/Coscheduling] S2[弹性配额管理<br/>Elastic Quota] end subgraph 隔离层 I1[GPU 时间片<br/>MPS/Time-Slicing] I2[显存隔离<br/>GPU Memory隔离] end subgraph 运行层 R1[容器运行时<br/>NVIDIA Container Runtime] R2[监控采集<br/>DCGM Exporter] end S1 --> I1 S2 --> I2 I1 --> R1 I2 --> R2

调度层 负责决定哪个任务获得 GPU、分配多少份额;隔离层 确保同一张 GPU 上的多个任务互不干扰;运行层提供容器化的 GPU 访问能力与实时监控数据。

2.3 GPU 时间片与 MPS 两种共享模式

NVIDIA 提供了两种 GPU 共享机制,适用场景截然不同:

机制 原理 隔离级别 适用场景 性能损耗
Time-Slicing 时间片轮转,多个 CUDA Context 交替执行 软隔离 推理服务、低优先级任务 10%---30%
MPS(Multi-Process Service) 多进程共享同一 CUDA Context 硬隔离(显存) 训练任务、高吞吐场景 5%---15%

Time-Slicing 的实现更简单,但上下文切换开销较大;MPS 减少了切换开销,但要求所有共享进程使用相同的 GPU 计算模式,且不支持 CUDA 12.0 以上版本的某些特性。

三、多租户 GPU 调度的生产级实现

3.1 GPU 时间片共享配置

yaml 复制代码
# nvidia-device-plugin-config.yaml
# 配置 GPU 时间片共享,将 1 张物理 GPU 切分为 4 个逻辑 GPU
version: v1
flags:
  migStrategy: none
  failOnInitError: true
  deviceListStrategy: envvar
sharing:
  timeSlicing:
    resources:
      - name: nvidia.com/gpu
        replicas: 4
        # 每个 replicas 对应一个时间片单元
        # 调度时 Pod 可请求 nvidia.com/gpu: 1
        # 实际获得 1/4 物理 GPU 的计算能力
yaml 复制代码
# 推理服务 Deployment ------ 请求 1 个时间片单元
apiVersion: apps/v1
kind: Deployment
metadata:
  name: llm-inference
  namespace: team-a
spec:
  replicas: 2
  selector:
    matchLabels:
      app: llm-inference
  template:
    metadata:
      labels:
        app: llm-inference
    spec:
      containers:
        - name: inference
          image: llm-server:latest
          resources:
            limits:
              nvidia.com/gpu: 1  # 请求 1 个时间片单元(1/4 物理 GPU)
            requests:
              nvidia.com/gpu: 1
          env:
            - name: CUDA_VISIBLE_DEVICES
              valueFrom:
                fieldRef:
                  fieldPath: ""
            # 限制显存使用量,防止单个任务占用过多
            - name: GPU_MEMORY_LIMIT
              value: "8Gi"

3.2 弹性配额调度器实现

go 复制代码
// quota_scheduler.go
// 基于弹性配额的 GPU 调度器,支持配额借用与回收
package scheduler

import (
	"context"
	"fmt"
	"sync"
	"time"

	v1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/util/wait"
	"k8s.io/client-go/informers"
	"k8s.io/client-go/kubernetes"
	"k8s.io/klog/v2"
)

// ElasticQuota 定义租户的 GPU 配额
type ElasticQuota struct {
	TenantID    string
	MinGPU      int       // 保底 GPU 数量,任何情况下保证可用
	MaxGPU      int       // 最大 GPU 数量,可借用的上限
	UsedGPU     int       // 当前已使用 GPU 数量
	BorrowedGPU int       // 从其他租户借用的 GPU 数量
	Priority    float64   // 租户优先级权重,影响借用决策
	LastUsed    time.Time // 最后使用时间,用于空闲回收判断
}

// QuotaManager 管理所有租户的弹性配额
type QuotaManager struct {
	mu     sync.RWMutex
	quotas map[string]*ElasticQuota // tenantID -> ElasticQuota
	client kubernetes.Interface
}

func NewQuotaManager(client kubernetes.Interface) *QuotaManager {
	return &QuotaManager{
		quotas: make(map[string]*ElasticQuota),
		client: client,
	}
}

// TryBorrowGPU 尝试从空闲租户借用 GPU 资源
// 借用策略:优先从空闲时间最长的租户借用,且不超过其 MinGPU 保底量
func (qm *QuotaManager) TryBorrowGPU(tenantID string, requestGPU int) (int, error) {
	qm.mu.Lock()
	defer qm.mu.Unlock()

	quota, exists := qm.quotas[tenantID]
	if !exists {
		return 0, fmt.Errorf("tenant %s not found", tenantID)
	}

	// 计算可借用的最大数量:MaxGPU - UsedGPU
	availableBorrow := quota.MaxGPU - quota.UsedGPU
	if availableBorrow <= 0 {
		return 0, fmt.Errorf("tenant %s already at max capacity", tenantID)
	}

	// 实际借用数量取请求量和可用量的较小值
	borrowGPU := min(requestGPU, availableBorrow)

	// 从空闲租户回收资源
	borrowed := 0
	for _, other := range qm.quotas {
		if other.TenantID == tenantID {
			continue
		}
		// 只能借用超出保底量的空闲 GPU
		freeGPU := other.UsedGPU - other.MinGPU - other.BorrowedGPU
		if freeGPU <= 0 {
			continue
		}
		// 空闲超过 30 分钟的租户才允许被借用
		if time.Since(other.LastUsed) < 30*time.Minute {
			continue
		}
		take := min(freeGPU, borrowGPU-borrowed)
		other.UsedGPU -= take
		borrowed += take
		if borrowed >= borrowGPU {
			break
		}
	}

	quota.UsedGPU += borrowed
	quota.BorrowedGPU += borrowed
	quota.LastUsed = time.Now()

	klog.Infof("tenant %s borrowed %d GPU (requested %d)", tenantID, borrowed, requestGPU)
	return borrowed, nil
}

// ReclaimBorrowedGPU 回收借用超时的 GPU 资源
// 当出借方需要资源时,强制回收借出的 GPU
func (qm *QuotaManager) ReclaimBorrowedGPU(ownerTenantID string, needGPU int) (int, error) {
	qm.mu.Lock()
	defer qm.mu.Unlock()

	owner, exists := qm.quotas[ownerTenantID]
	if !exists {
		return 0, fmt.Errorf("tenant %s not found", ownerTenantID)
	}

	reclaimed := 0
	for _, borrower := range qm.quotas {
		if borrower.TenantID == ownerTenantID || borrower.BorrowedGPU <= 0 {
			continue
		}
		// 从借用方回收 GPU,优先回收低优先级租户
		take := min(borrower.BorrowedGPU, needGPU-reclaimed)
		borrower.UsedGPU -= take
		borrower.BorrowedGPU -= take
		reclaimed += take
		if reclaimed >= needGPU {
			break
		}
	}

	owner.UsedGPU += reclaimed
	klog.Infof("tenant %s reclaimed %d GPU", ownerTenantID, reclaimed)
	return reclaimed, nil
}

func min(a, b int) int {
	if a < b {
		return a
	}
	return b
}

3.3 GPU 利用率监控与自动伸缩

go 复制代码
// gpu_autoscaler.go
// 基于 GPU 利用率的自动伸缩控制器
package autoscaler

import (
	"context"
	"math"
	"sync"
	"time"

	prometheus "github.com/prometheus/client_golang/api"
	promv1 "github.com/prometheus/client_golang/api/prometheus/v1"
)

// GPUMetrics GPU 监控指标
type GPUMetrics struct {
	GPUUtilization    float64   // GPU 计算利用率 (%)
	MemoryUtilization float64   // 显存利用率 (%)
	PowerUsage        float64   // 功耗 (W)
	Temperature       float64   // 温度 (°C)
	Timestamp         time.Time
}

// ScalingDecision 伸缩决策
type ScalingDecision struct {
	Namespace string
	DeployName string
	TargetReplicas int32
	Reason       string
}

// GPUAutoscaler GPU 自动伸缩器
type GPUAutoscaler struct {
	promClient   promv1.API
	mu           sync.Mutex
	decisions    map[string]*ScalingDecision
	scaleUpThreshold   float64 // 扩容阈值
	scaleDownThreshold float64 // 缩容阈值
	cooldownPeriod     time.Duration
	lastScaleTime      map[string]time.Time
}

func NewGPUAutoscaler(promURL string) *GPUAutoscaler {
	client, _ := prometheus.NewClient(prometheus.Config{
		Address: promURL,
	})
	return &GPUAutoscaler{
		promClient:         promv1.NewAPI(client),
		decisions:          make(map[string]*ScalingDecision),
		scaleUpThreshold:   80.0,
		scaleDownThreshold: 30.0,
		cooldownPeriod:     5 * time.Minute,
		lastScaleTime:      make(map[string]time.Time),
	}
}

// CalculateDesiredReplicas 根据当前 GPU 利用率计算期望副本数
// 采用线性扩缩策略,避免激进扩缩导致资源震荡
func (ga *GPUAutoscaler) CalculateDesiredReplicas(
	currentReplicas int32,
	avgUtilization float64,
) int32 {
	if avgUtilization > ga.scaleUpThreshold {
		// 利用率超过阈值,按比例扩容
		ratio := avgUtilization / ga.scaleUpThreshold
		desired := int32(math.Ceil(float64(currentReplicas) * ratio))
		// 单次扩容不超过当前副本数的 2 倍,防止过激扩容
		maxScale := currentReplicas * 2
		if desired > maxScale {
			desired = maxScale
		}
		return desired
	}

	if avgUtilization < ga.scaleDownThreshold {
		// 利用率低于阈值,按比例缩容
		ratio := avgUtilization / ga.scaleDownThreshold
		desired := int32(math.Ceil(float64(currentReplicas) * ratio))
		// 保底至少 1 个副本
		if desired < 1 {
			desired = 1
		}
		return desired
	}

	return currentReplicas
}

四、多租户 GPU 调度的架构权衡

4.1 时间片共享 vs MPS 的取舍

时间片共享的部署成本最低,只需修改 Device Plugin 配置即可生效,但上下文切换带来的性能损耗在高负载场景下不可忽视。MPS 的性能更优,却引入了额外的运维复杂度:MPS Server 进程需要独立管理,且当任何一个共享进程崩溃时,同一 Context 下的所有进程都会受影响。

在生产环境中,推荐采用混合策略:推理服务使用时间片共享(对延迟不敏感、可容忍 10%---20% 的性能损耗),训练任务使用 MPS(对吞吐量敏感、需要更低的切换开销)。

4.2 弹性配额的公平性问题

弹性配额允许租户借用空闲资源,但引入了"回收风险"------当资源出借方突然需要 GPU 时,借用方的任务可能被强制驱逐。这对长时间运行的训练任务来说是不可接受的。

解决方案是为训练任务设置 nvidia.com/gpu-exclusive: "true" 标注,调度器会将其排除在借用资源之外,确保训练任务独占 GPU 直到完成。推理服务则可以安全地使用借用资源,因为其无状态特性使得 Pod 驱逐后可以快速恢复。

4.3 监控与可观测性的开销

DCGM Exporter 采集 GPU 指标时会产生约 2%---3% 的 GPU 开销,在极致性能场景下可能需要降低采集频率或使用采样策略。建议训练集群使用 30 秒采集间隔,推理集群使用 10 秒间隔,在精度与开销之间取得平衡。

五、总结

多租户 GPU 调度的核心挑战在于:在资源利用率最大化与租户隔离保障之间找到平衡点。通过时间片共享与 MPS 的混合部署,可以在推理与训练两种场景下分别获得最优的共享策略;弹性配额机制让空闲资源得到充分利用,同时通过保底配额确保关键任务不受影响;基于 GPU 利用率的自动伸缩则让资源分配动态适配负载变化。

落地建议分三步推进:第一步,部署 NVIDIA Device Plugin 的时间片共享,将推理服务的 GPU 利用率从 30% 提升到 70% 以上;第二步,引入弹性配额调度器,实现跨团队的资源借用与回收;第三步,接入 GPU 自动伸缩,根据实时利用率动态调整推理服务副本数。每一步都建立在前一步稳定运行的基础上,避免一次性引入过多变量导致排障困难。

相关推荐
小爷毛毛_卓寿杰1 小时前
我把一个 3B 模型塞进了 Xinference,然后它干掉了 DeepSeek V3.2
人工智能·开源·github
秦先生在广东1 小时前
Agent 闭环才是真正的护城河:Anthropic “300 个 Agent“ 背后被忽视的秘密
人工智能
Bigfish_coding1 小时前
前端转agent-【python】- 14 记忆系统优化:摘要与遗忘
人工智能
Bigfish_coding1 小时前
前端转agent-【python】-13 Ollama Python流式输出教程:stream=True 与 async 实践
人工智能
字节跳动数据库3 小时前
文章分享——相似函数处理方法
人工智能·后端·程序员
Bigfish_coding3 小时前
前端转agent-【python】-12 LangChain 入门实战:RAG + LCEL 链式调用
人工智能
程序员cxuan4 小时前
读懂 Claude Code 架构分析系列,第一篇,开始!
人工智能·后端·架构
饼干哥哥4 小时前
扣子3.0测评:我让 Codex 和 Claude Code 住同一个桌面,结果它们打架了!
人工智能·开源·代码规范
Token炼金师5 小时前
IP-Adapter:解耦交叉注意力如何让扩散模型看见图像
人工智能
Bigfish_coding5 小时前
前端转agent-【python】-11 LangGraph 高级特性:时间旅行与人工介入
人工智能