Contents

一文搞懂 GPU 共享方案: NVIDIA Time Slicing

https://img.lixueduan.com/ai/cover/gpu-share-time-slicing.png

本文主要分享 GPU 共享方案,包括如何安装、配置以及使用,最后通过分析源码了 TimeSlicing 的具体实现。通过配置 TimeSlicing 可以实现 Pod 共享一块物理 GPU,以提升资源利用率。

1.为什么需要 GPU 共享、切分等方案?

开始之前我们先思考一个问题,为什么需要 GPU 共享、切分等方案?

或者说是另外一个问题:明明直接在裸机环境使用,都可以多个进程共享 GPU,怎么到 k8s 环境就不行了

推荐阅读前面几篇文章:这两篇分享了如何在各个环境中使用 GPU,在 k8s 环境则推荐使用 NVIDIA 提供的 gpu-operator 快速部署环境。

GPU 环境搭建指南:如何在裸机、Docker、K8s 等环境中使用 GPU

GPU 环境搭建指南:使用 GPU Operator 加速 Kubernetes GPU 环境搭建

这两篇则分析了 device-plugin 原理以及在 K8s 中创建一个申请 GPU 的 Pod 后的一些列动作,最终该 Pod 是如何使用到 GPU 的。

Kubernetes教程(二一)—自定义资源支持:K8s Device Plugin 从原理到实现

Kubernetes教程(二二)—在 K8S 中创建 Pod 是如何使用到 GPU 的:device plugin&nvidia-container-toolkit 源码分析

看完之后,大家应该就大致明白了。

资源感知

首先在 k8s 中资源是和节点绑定的,对于 GPU 资源,我们使用 NVIDIA 提供的 device-plugin 进行感知,并上报到 kube-apiserver,这样我们就能在 Node 对象上看到对应的资源了。

就像这样:

root@liqivm:~# k describe node gpu01|grep Capacity -A 7
Capacity:
  cpu:                128
  ephemeral-storage:  879000896Ki
  hugepages-1Gi:      0
  hugepages-2Mi:      0
  memory:             1056457696Ki
  nvidia.com/gpu:     8
  pods:               110

可以看到,该节点除了基础的 cpu、memory 之外,还有一个nvidia.com/gpu: 8 信息,表示该节点上有 8 个 GPU。

资源申请

然后我们就可以在创建 Pod 时申请对应的资源了,比如申请一个 GPU:

apiVersion: v1
kind: Pod
metadata:
  name: gpu-pod
spec:
  containers:
  - name: gpu-container
    image: nvidia/cuda:11.0-base   # 一个支持 GPU 的镜像
    resources:
      limits:
        nvidia.com/gpu: 1          # 申请 1 个 GPU
    command: ["nvidia-smi"]         # 示例命令,显示 GPU 的信息
  restartPolicy: OnFailure

apply 该 yaml 之后,kube-scheduler 在调度该 Pod 时就会将其调度到一个拥有足够 GPU 资源的 Node 上。

同时该 Pod 申请的部分资源也会标记为已使用,不会在分配给其他 Pod。

到这里,问题的答案就已经很明显的。

  • 1)device-plugin 感知到节点上的物理 GPU 数量,上报到 kube-apiserver
  • 2)kube-scheduler 调度 Pod 时会根据 pod 中的 Request 消耗对应资源

即:Node 上的 GPU 资源被 Pod 申请之后,在 k8s 中就被标记为已消耗了,后续创建的 Pod 会因为资源不够导致无法调度

实际上:可能 GPU 性能比较好,可以支持多个 Pod 共同使用,但是因为 k8s 中的调度限制导致多个 Pod 无法正常共享。

因此,我们才需要 GPU 共享、切分等方案。

2. 什么是 Time Slicing 方案

NVIDIA 提供的 Time-Slicing GPUs in Kubernetes 是一种通过 oversubscription(超额订阅) 来实现 GPU 共享的策略,这种策略能让多个任务在同一个 GPU 上进行,而不是每个任务都独占一个 GPU。

虽然方案名称叫做 Time Slicing,但是device-plugin 的实现上和时间切片没有任何关系,实际上是一个 GPU 超卖方案。

为什么要叫Time Slicing(时间片)?

虽然实现上只是 oversubscription(超额订阅),但是名称中的 Time Slicing(时间片)指的是 GPU 本身的时间片调度。

例如:A、B、C 三个进程共享 GPU,三个进程同时把 CUDA 任务发射到 GPU 上去,GPU 并不会同时执行,而是采用时间片轮转调度的方式。

首先第一个时间片,A 任务被执行,接着第二个时间片,执行 B 任务,第三个时间片, C 任务将被执行。

时间片是依次进行轮转调度的,分别执行A、B、C中的任务。

效果

比如节点上只有一个物理 GPU,正常安装 GPU Operator 之后,device plugin 检测到该节点上有 1 个 GPU,上报给 kubelet,然后 kubelet 更新到 kube-apiserver,我们就可以在 Node 对象上看到了:

root@liqivm:~# k describe node gpu01|grep Capacity -A 7
Capacity:
  cpu:                128
  ephemeral-storage:  879000896Ki
  hugepages-1Gi:      0
  hugepages-2Mi:      0
  memory:             1056457696Ki
  nvidia.com/gpu:     1
  pods:               110

此时,创建一个 Pod 申请 1 个 GPU 之后,第二个 Pod 就无法使用了,因为 GPU 资源不足无法调度。

但是 Time Slicing 可以进行 oversubscription 设置,将 device-plugin 上报的 GPU 数量进行扩大。

比如将其数量放大 10 倍,device plugin 就会上报该节点有 1*10 = 10 个 GPU,最终 kube-apiserver 则会记录该节点有 10 个 GPU:

root@liqivm:~# k describe node gpu01|grep Capacity -A 7
Capacity:
  cpu:                128
  ephemeral-storage:  879000896Ki
  hugepages-1Gi:      0
  hugepages-2Mi:      0
  memory:             1056457696Ki
  nvidia.com/gpu:     10
  pods:               110

这样,就可以供 10 个 Pod 使用了。

当然了,Time Slicing 方案也有缺点:多个 Pod 之间没有内存或者故障隔离,完全的共享,能使用多少内存和算力全靠多个 Pod 自行竞争。

ps:就和直接在宿主机上多个进程共享一个 GPU 基本一致

3. Time Slicing Demo

Time Slicing 由于是 NVIDIA 的方案,因此使用起来比较简单,只需要在部署完成 GPU Operator 之后进行配置即可。

首先参考这篇文章完成 GPU Operator 的部署 –> GPU 环境搭建指南:使用 GPU Operator 加速 Kubernetes GPU 环境搭建

然后即可开始配置 TimeSlicing。

整体配置分为以下 3 个步骤:

  • 1)创建 TimeSlicing 配置

    • 根据官方文档描述,修改了 TimeSlicing 配置之后,device plugin Pod 不会自动重启,因此新的配置不会生效,需要手动重启对应 Pod。

    • kubectl rollout restart -n gpu-operator daemonset/nvidia-device-plugin-daemonset
      
  • 2)修改集群策略开启 Time Slicing,并指定让 device-plugin 使用第一步中创建的配置

    • 这里则是通过 Configmap 名称来指定
  • 3)(可选)给要使用 GPU TimeSlicing 的节点打上对应 label,实现不同 Node 使用不同策略

    • 比如不同节点上的 GPU 不同,那么可以根据 GPU 的算力或者内存情况设置不同的副本数以合理利用资源
    • 如果都是统一 GPU,则使用集群级别的统一配置即可

配置开启 TimeSlicing

创建 TimeSlicing 配置

使用一个单独的 Configmap 来存放 TimeSlicing 的配置。

这里使用集群级别的统一配置,配置文件 time-slicing-config-all.yaml 完整内容如下:

apiVersion: v1
kind: ConfigMap
metadata:
  name: time-slicing-config-all
data:
  any: |-
    version: v1
    flags:
      migStrategy: none
    sharing:
      timeSlicing:
        renameByDefault: false
        failRequestsGreaterThanOne: false
        resources:
          - name: nvidia.com/gpu
            replicas: 4    

具体配置含义参考官方文档:about-configuring-gpu-time-slicing

  • data.<key>: 配置的名字,可以为不同 Node 设置单独配置,后续通过名称引用对应配置。
    • 后续开启 TimeSlicing 时则根据 key 指定使用不同配置
    • 这里我们使用集群统一配置,因此创建一个 key 即可
  • flags.migStrategy:配置开启时间片之后如何处理 MIG 设备,默认为 none
  • renameByDefault:是否对 GPU 资源改名。
    • 设置为 true 之后,会使用<resource-name>.shared 替代原本的 <resource-name>。例如 nvidia.com/gpu 会变成 nvidia.com/gpu.shared ,显式告知使用者这是共享 GPU。
    • 默认为 false,即不改资源类型名,不过 Node 上的 label 也会改,比如使用时间片之前是nvidia.com/gpu.product=Tesla-T4, 使用后就会变成nvidia.com/gpu.product=Tesla-T4-SHARED 这样依旧可以通过 nodeSelector 来限制 Pod 调度节点,来控制是否使用共享的 GPU
    • 推荐使用 fasle 即可
  • failRequestsGreaterThanOne:开启后,当 Pod 请求 1 个以上的 shared GPU 时直接报错 UnexpectedAdmissionError。这个字段是通过报错的方式告诉使用者,请求多个 shared GPU 并不会增加 Pod 对该共享 GPU 的占用时间
  • resources.name:要通过时间分片提供访问的资源类似,比如nvidia.com/gpu
  • resources.replicas:可共享访问的资源数量,比如这里指定的 4 也就是 1 个该类型的 GPU 可以供 4 个 Pod 共享访问,也就是最终 Pod 上看到的 GPU 数量是物理 GPU 数量的 4 倍。

将配置 Apply 到 gpu-operator 所在的 namespace

kubectl create -n gpu-operator -f time-slicing-config-all.yaml

修改集群策略

修改clusterpolicies.nvidia.com/cluster-policy 对象,让 device plugin 使用上一步创建的配置。

kubectl patch clusterpolicies.nvidia.com/cluster-policy \
    -n gpu-operator --type merge \
    -p '{"spec": {"devicePlugin": {"config": {"name": "time-slicing-config-all", "default": "any"}}}}'
  • name:time-slicing-config-all 指定了配置文件对应的 Configmap 名称
  • default:any:表示默认配置为这个 Configmap 中的 key 为 any 的配置

修改后 gpu-feature-discoverynvidia-device-plugin-daemonset pod 会重启,使用以下命令查看重启过程

kubectl get events -n gpu-operator --sort-by='.lastTimestamp'

验证 TimeSlicing 是否生效

查看 Node 上的 GPU 信息

首先查看一下 Node 信息,确认 TimeSlicing 生效了

kubectl describe node xxx

正常结果如下

...
Labels:
                  nvidia.com/gpu.count=4
                  nvidia.com/gpu.product=Tesla-T4-SHARED
                  nvidia.com/gpu.replicas=4
Capacity:
  nvidia.com/gpu: 16
  ...
Allocatable:
  nvidia.com/gpu: 16
  ...

增加了几个 label,

  • nvidia.com/gpu.product=Tesla-T4-SHARED
  • nvidia.com/gpu.replicas=4

根据nvidia.com/gpu.count=4 可知,节点上有 4 张 GPU,然后由于使用了时间片,且配置的nvidia.com/gpu.replicas=4 副本数为 4,因此最终节点上 device plugin 上报的 GPU 数量就是 4*4 = 16 个。

验证 GPU 能否正常使用

创建一个 Deployment 来验证,GPU 能否正常使用。

这里副本数指定为 5,因为集群里只有 4 张 GPU,如果 TimeSlicing 未生效,那么有一个 Pod 肯定会应为拿不到 GPU 资源而 pending。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: time-slicing-verification
  labels:
    app: time-slicing-verification
spec:
  replicas: 2
  selector:
    matchLabels:
      app: time-slicing-verification
  template:
    metadata:
      labels:
        app: time-slicing-verification
    spec:
      tolerations:
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule
      hostPID: true
      containers:
        - name: cuda-sample-vector-add
          image: "nvcr.io/nvidia/k8s/cuda-sample:vectoradd-cuda11.7.1-ubuntu20.04"
          command: ["/bin/bash", "-c", "--"]
          args:
            - while true; do /cuda-samples/vectorAdd; done
          resources:
           limits:
             nvidia.com/gpu: 1

会启动 5 个 Pod,

查看情况

$ kubectl get pods
NAME                                         READY   STATUS    RESTARTS   AGE
time-slicing-verification-7cdc7f87c5-lkd9d   1/1     Running   0          23s
time-slicing-verification-7cdc7f87c5-rrzq7   1/1     Running   0          23s
time-slicing-verification-7cdc7f87c5-s8qwk   1/1     Running   0          23s
time-slicing-verification-7cdc7f87c5-xhmb7   1/1     Running   0          23s
time-slicing-verification-7cdc7f87c5-zsncp   1/1     Running   0          23s

5 个 Pod 都启动了,说明时间片时成功的。

随便查看一个 Pod 的日志

$ kubectl logs deploy/time-slicing-verification
Found 5 pods, using pod/time-slicing-verification-7cdc7f87c5-s8qwk
[Vector addition of 50000 elements]
Copy input data from the host memory to the CUDA device
CUDA kernel launch with 196 blocks of 256 threads
Copy output data from the CUDA device to the host memory
Test PASSED
Done
[Vector addition of 50000 elements]
Copy input data from the host memory to the CUDA device
CUDA kernel launch with 196 blocks of 256 threads
Copy output data from the CUDA device to the host memory
...

有 Test PASSED 则说明成功了。

说明 TimeSlicing 配置生效了。

4. 使用 Node 级别的单独配置

前面只创建了一个名称为 any 的配置,并在 clusterpolicy 中指明了使用该配置为默认配置,因此集群中的全部节点都会使用该配置来做时间片。

但是可能集群中不同节点上的 GPU 型号不同,因此需要共享分副本数可以调整,性能好的副本数就调大一点,性能差的就小一点。

本章主要记录怎么为不同的节点使用不同的配置。

实际上是为不同的 GPU 准备不同的配置。

创建时间片配置

同样的创建 TimeSlicing 配置,不过这次 Configmap 中写了两个配置,而且是以 GPU 型号命名的

apiVersion: v1
kind: ConfigMap
metadata:
  name: time-slicing-config-fine
data:
  a100-40gb: |-
    version: v1
    flags:
      migStrategy: mixed
    sharing:
      timeSlicing:
        resources:
        - name: nvidia.com/gpu
          replicas: 8
        - name: nvidia.com/mig-1g.5gb
          replicas: 2
        - name: nvidia.com/mig-2g.10gb
          replicas: 2
        - name: nvidia.com/mig-3g.20gb
          replicas: 3
        - name: nvidia.com/mig-7g.40gb
          replicas: 7    
  tesla-t4: |-
    version: v1
    flags:
      migStrategy: none
    sharing:
      timeSlicing:
        resources:
        - name: nvidia.com/gpu
          replicas: 4    

可以看到,分别对 A100 和 Tesla T4 这两种 GPU 做了配置。

  • a100-40gb:A100 支持 MIG,因此增加了 MIG 部分的配置,若没有则指定为 none 即可
    • 然后根据 MIG 实例分别指定不同的 replicas 数
  • tesla-t4:Tesla T4 GPU 性能比较差,因此 replicas 指定为 4 即可

将配置 Apply 到 gpu-operator 所在的 namespace

kubectl create -n gpu-operator -f time-slicing-config-all.yaml

修改集群策略

同样的,修改一下 cluster-policy 指定 device plugin 使用的 Configmap,这次与之前的区别在于,这里没有指定 default 配置

kubectl patch clusterpolicies.nvidia.com/cluster-policy \
    -n gpu-operator --type merge \
    -p '{"spec": {"devicePlugin": {"config": {"name": "time-slicing-config-fine"}}}}'

没有指定 default 时,device-plugin 则会根据 node 上的 label (nvidia.com/device-plugin.config)来获取要使用的配置。

为节点打 label

在节点上打上下面的 label,这样该节点上的 device plugin 就会根据该 label 的 value 来使用对应名字的配置了。

比如这里,就是有这个 label 的节点就使用名叫 tesla-t4 的配置。

kubectl label node <node-name> nvidia.com/device-plugin.config=tesla-t4

一般都是以 GPU 型号命名,然后给使用该 GPU 的节点都打上对应 label,这样便于查看。

5. 关闭 TimeSlicing

想关闭 TimeSlicing 配置也很简单,直接更新 集群策略 把 device plugin 下的 config 这一段去掉即可。

  devicePlugin:
    config:
      default: any
      name: time-slicing-config-all
    enabled: true
    env:
    - name: PASS_DEVICE_SPECS
      value: "true"
    - name: FAIL_ON_INIT_ERROR
      value: "true"

命令如下:

kubectl patch clusterpolicies.nvidia.com/cluster-policy -n gpu-operator --type json -p '[{"op": "remove", "path": "/spec/devicePlugin/config"}]'

然后重启一下 device-plugin pod

kubectl rollout restart -n gpu-operator daemonset/nvidia-device-plugin-daemonset

不出意外的话就关掉了,再次查看 Pod 信息,GPU 就变成了物理 GPU 数量,说明关闭成功。

kubectl get node xxx -oyaml
  addresses:
  - address: 172.18.187.224
    type: InternalIP
  - address: izj6c5dnq07p1ic04ei9vwz
    type: Hostname
  allocatable:
    cpu: "4"
    ephemeral-storage: "189889991571"
    hugepages-1Gi: "0"
    hugepages-2Mi: "0"
    memory: 15246720Ki
    nvidia.com/gpu: "1"
    pods: "110"

6. 源码分析

简单看下源码,分析 TimeSlicing 是怎么实现的。

首先是 device-plugin 可以接收的配置

// api/config/v1/config.go#L32
// Config is a versioned struct used to hold configuration information.
type Config struct {
	Version   string    `json:"version"             yaml:"version"`
	Flags     Flags     `json:"flags,omitempty"     yaml:"flags,omitempty"`
	Resources Resources `json:"resources,omitempty" yaml:"resources,omitempty"`
	Sharing   Sharing   `json:"sharing,omitempty"   yaml:"sharing,omitempty"`
}

这也就是我们在 clusterPolicy 中配置的:

apiVersion: v1
kind: ConfigMap
metadata:
  name: time-slicing-config-all
data:
  any: |-
    version: v1
    flags:
      migStrategy: none
    sharing:
      timeSlicing:
        renameByDefault: false
        failRequestsGreaterThanOne: false
        resources:
          - name: nvidia.com/gpu
            replicas: 4    

这里我们关注 resources 中的 replicas 参数,正是这个参数定义了 oversubscription(超额订阅) 的额度。

        resources:
          - name: nvidia.com/gpu
            replicas: 4

看下代码中是什么生效的

// internal/rm/device_map.go#L282

// updateDeviceMapWithReplicas returns an updated map of resource names to devices with replica
// information from the active replicated resources config.
func updateDeviceMapWithReplicas(replicatedResources *spec.ReplicatedResources, oDevices DeviceMap) (DeviceMap, error) {
	devices := make(DeviceMap)

	// Begin by walking replicatedResources.Resources and building a map of just the resource names.
	names := make(map[spec.ResourceName]bool)
	for _, r := range replicatedResources.Resources {
		names[r.Name] = true
	}

	// Copy over all devices from oDevices without a resource reference in TimeSlicing.Resources.
	for r, ds := range oDevices {
		if !names[r] {
			devices[r] = ds
		}
	}

	// Walk shared Resources and update devices in the device map as appropriate.
	for _, resource := range replicatedResources.Resources {
		r := resource
		// Get the IDs of the devices we want to replicate from oDevices
		ids, err := oDevices.getIDsOfDevicesToReplicate(&r)
		if err != nil {
			return nil, fmt.Errorf("unable to get IDs of devices to replicate for '%v' resource: %v", r.Name, err)
		}
		// Skip any resources not matched in oDevices
		if len(ids) == 0 {
			continue
		}

		// Add any devices we don't want replicated directly into the device map.
		for _, d := range oDevices[r.Name].Difference(oDevices[r.Name].Subset(ids)) {
			devices.insert(r.Name, d)
		}

		// Create replicated devices add them to the device map.
		// Rename the resource for replicated devices as requested.
		name := r.Name
		if r.Rename != "" {
			name = r.Rename
		}
		for _, id := range ids {
			for i := 0; i < r.Replicas; i++ {
				annotatedID := string(NewAnnotatedID(id, i))
				replicatedDevice := *(oDevices[r.Name][id])
				replicatedDevice.ID = annotatedID
				replicatedDevice.Replicas = r.Replicas
				devices.insert(name, &replicatedDevice)
			}
		}
	}

	return devices, nil
}

核心部分如下:

for _, id := range ids {
  for i := 0; i < r.Replicas; i++ {
    annotatedID := string(NewAnnotatedID(id, i))
    replicatedDevice := *(oDevices[r.Name][id])
    replicatedDevice.ID = annotatedID
    replicatedDevice.Replicas = r.Replicas
    devices.insert(name, &replicatedDevice)
  }
}

可以看到,这里是双层 for 循环,对 device 数量进行了一个复制的操作,这样每张 GPU 都可以被使用 Replicas 次了。

其他属性都没变,只是把 deviceID 进行了处理,便于区分

// NewAnnotatedID creates a new AnnotatedID from an ID and a replica number.
func NewAnnotatedID(id string, replica int) AnnotatedID {
	return AnnotatedID(fmt.Sprintf("%s::%d", id, replica))
}

然后在真正挂载时则进行 split 拿到 id 和 replicas 信息

// Split splits a AnnotatedID into its ID and replica number parts.
func (r AnnotatedID) Split() (string, int) {
	split := strings.SplitN(string(r), "::", 2)
	if len(split) != 2 {
		return string(r), 0
	}
	replica, _ := strconv.ParseInt(split[1], 10, 0)
	return split[0], int(replica)
}

至此,我们就分析完了 TimeSlicing 的具体实现,其实很简单,就是根据配置的 replicas 参数对 device plugin 感知到的设备进行复制,并在 DeviceID 使用特定格式进行标记便于区分。

7. 小结

本文主要分享了 NVIDIA Time Slicing 这个 GPU 共享方案,包括即实现原理,以及配置和使用方式。

最后通过分析源码的方式探索了 TimeSlicing 的代码实现。

为什么需要 GPU 共享、切分?

在 k8s 中使用默认 device plugin 时,GPU 资源和物理 GPU 是一一对应的,导致一个物理 GPU 被一个 Pod 申请后,其他 Pod 就无法使用了。

为了提高资源利用率,因此我们需要 GPU 共享、切分等方案。

什么是 TimeSlicing?

TimeSlicing 是一种通过 oversubscription(超额订阅) 来实现 GPU 共享的策略,这种策略能让多个任务在同一个 GPU 上进行,而不是每个任务都独占一个 GPU。

如何开启 TimeSlicing

  • 1)创建 TimeSlicing 配置

    • 可以是集群统一配置,也可以是 Node 级别的配置,主要根据不同节点上的 GPU 进行配置
    • 如果集群中所有节点 GPU 型号都一致,则使用集群统一配置即可,若不一致则根据 节点上的 GPU 性能修改配置

    2)修改 cluster-policy,增加 TimeSlicing 相关配置

作为这两个步骤之后,TimeSlicing 就开启了,再次查看 Node 信息时会发现 GPU 数量变多了。

TimeSlicing 实现原理

根据配置的 replicas 参数对 device plugin 感知到的设备进行复制,并在 DeviceID 使用特定格式进行标记便于区分。

最后在贴一下相关文章,推荐阅读: