Debido a la creciente importancia de la IA en nuestra pila, estamos utilizando extensivamente unidades de procesamiento gráfico (GPUs) en Kubernetes para ejecutar diversas cargas de trabajo de aprendizaje automático (ML). En este blog, describiré cómo hemos estado compartiendo GPUs entre pods durante los últimos 2 años para reducir drásticamente nuestro costo de infraestructura.
Al usar GPU de NVIDIA, necesita utilizar el plugin de dispositivo de NVIDIA para Kubernetes que declara un nuevo recurso personalizado, nvidia.com/gpu, que puede usar para asignar GPUs a los pods de Kubernetes. El problema con este enfoque es que no puede dividirlos entre múltiples aplicaciones (PODs) y las GPU son un recurso muy costoso.
```yaml apiVersion: v1 kind: Pod metadata: name: gpu-example spec: restartPolicy: OnFailure containers: - name: gpu-example image: eritikass/gpu-load-test imagePullPolicy: Always resources: limits: nvidia.com/gpu: 1 ```
¿Qué sucede cuando despliegas una GPU usando aplicaciones como esta? Kubernetes está usando el recurso nvidia.com/gpu para desplegar este pod en un nodo donde hay una GPU disponible.
Si te conectas (ssh) al nodo donde este pod está corriendo, y usas el comando nvidia-smi allí, puedes obtener un resultado similar a este.
La información visible aquí es que nuestro nodo tiene 1 NVIDIA Tesla T4 GPU con 15109MiB de memoria y estamos usando 104MiB de eso con un proceso (nuestro pod desplegado).
Internamente hay una variable muy importante que se le da a cada pod y la aplicación la está utilizando para saber qué GPUs usar.
# connect to pod kubectl exec -it pod/gpu-example -- bash #and check echo $NVIDIA_VISIBLE_DEVICES
En el ejemplo anterior, esta GPU - GPU-93955ff6-1bbe-3f6d-8d58-a2104edb62db - está siendo utilizada por la aplicación. Cuando tienes un nodo con múltiples GPUs, en realidad todos los pods pueden acceder a todas las GPUs, pero todas están usando esta variable para saber a qué GPU deben acceder.
Esta variable también tiene un valor "mágico": all. Usando esto puedes anular la asignación de GPU y dejar que tu aplicación sepa que puede usar cualquier (todas) las GPUs presentes en tu nodo.
Sabemos que nuestra aplicación de ejemplo está usando 104MiB y la GPU tiene un total de 15109MiB, por lo que en teoría podemos ajustarla 145 veces en la misma GPU.
En el siguiente ejemplo necesita tener un clúster de AWS EKS con 1 instancia de GPU (g4dn.xlarge), y con algunas modificaciones debería ser posible usarlo en cualquier clúster de Kubernetes con nodos de GPU de Nvidia disponibles.
```yaml apiVersion: apps/v1 kind: Deployment metadata: name: gpu-example spec: # En este ejemplo corremos 5 pods para fines de demostración. # # NB: los nodos aws g4 pueden correr un máximo de 29 pods debido a limitaciones de red, # https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-eni.html#AvailableIpPerENI replicas: 5 selector: matchLabels: app: gpu-example template: metadata: labels: app: gpu-example spec: # Usemos afinidad para asegurarnos de que los pod(s) en este despliegue # puedan ser asignados solo a nodos que estén utilizando instancias g4dn.xlarge. # De esta manera podemos asegurar que hay GPU disponible. affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: node.kubernetes.io/instance-type operator: In values: - g4dn.xlarge containers: - name: gpu-example image: eritikass/gpu-load-test env: # esto le informará a la aplicación cuda que use cualquier GPU disponible - name: NVIDIA_VISIBLE_DEVICES value: all ```
Si verificamos después de eso, podemos ver que los 5 pods están asignados al mismo nodo.
Y cuando verificamos nvidia-smi en este nodo, podemos ver que hay 5 procesos GPU en ejecución de esos pods.
Ahora que sabemos que múltiples pods que usan GPU pueden ejecutarse en el mismo nodo, necesitamos asegurarnos de que estén divididos entre nodos según la capacidad del nodo para manejar los requisitos del pod. Para esto podemos usar Recursos Personalizados para hacer saber a todos los nodos de GPU que tienen tanta memoria de GPU disponible para usar.
Usaremos DaemonSet para agregar un recurso de memoria de GPU personalizada (la nombramos veriff.com/gpu-memory) para añadir nodos con una GPU de NVIDIA adjunta. DaemonSet es un tipo especial de despliegue de Kubernetes que ejecutará 1 pod en algunos (o todos) los nodos del clúster, se usa con bastante frecuencia para desplegar cosas como recolectores de registros y herramientas de monitoreo. Nuestro DaemonSet tendrá nodeAffinity para asegurarse de que solo se ejecute en nodos g4dn.xlarge.
```yaml --- # # config maps that is holding the script that is run inside DaemonSet in all gpu nodes to set gpu memory # apiVersion: v1 kind: ConfigMap metadata: name: add-gpu-memory namespace: kube-system data: app.sh: | #!/bin/bash gpu_memory_value="15079Mi" timeout 240 kubectl proxy & sleep 3 curl --header "Content-Type: application/json-patch+json" \ --request PATCH \ --max-time 10 --retry 10 --retry-delay 2 \ --data "[{\"op\": \"add\", \"path\": \"/status/capacity/veriff.com~1gpu-memory\", \"value\": \"${gpu_memory_value}\"}]" \ "http://127.0.0.1:8001/api/v1/nodes/${K8S_NODE_NAME}/status" echo " * ${gpu_memory_value} of gpu memory added to ${K8S_NODE_NAME} (veriff.com/gpu-memory)" sleep infinity --- # # this DaemonSet will run in all g4dn.xlarge nodes and patching them to add them gpu memory custom resources # apiVersion: apps/v1 kind: DaemonSet metadata: name: add-gpu-memory namespace: kube-system spec: selector: matchLabels: name: add-gpu-memory template: metadata: labels: name: add-gpu-memory spec: tolerations: - key: "special" operator: "Exists" affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: node.kubernetes.io/instance-type operator: In values: - g4dn.xlarge serviceAccountName: add-gpu-memory containers: - name: add-gpu-memory image: bitnami/kubectl resources: limits: cpu: 40m memory: 50M requests: cpu: 1m memory: 1M volumeMounts: - mountPath: /app.sh name: code readOnly: true subPath: app.sh command: - bash - /app.sh env: # # this variable is used in script to patch nodes to know what node he is running # - name: "K8S_NODE_NAME" valueFrom: fieldRef: apiVersion: "v1" fieldPath: "spec.nodeName" priorityClassName: system-node-critical volumes: - name: code configMap: name: add-gpu-memory # # rbac permissions used by daemonset to patch nodes # --- apiVersion: v1 kind: ServiceAccount metadata: name: add-gpu-memory namespace: kube-system --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: add-gpu-memory namespace: kube-system rules: - apiGroups: - "*" resources: - nodes verbs: - get - list - apiGroups: - "*" resources: - nodes/status verbs: - patch --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: add-gpu-memory namespace: kube-system roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: add-gpu-memory subjects: - kind: ServiceAccount name: add-gpu-memory namespace: kube-system ```
Si verificas tu nodo usando “kubectl describe node/NAME” puedes ver que tiene el recurso veriff.com/gpu-memory disponible.
Ahora que sabemos que nuestro script para agregar memoria GPU funciona, escalemos nuestro clúster para que tengamos múltiples nodos GPU disponibles.
Para probarlo, modificaremos nuestro despliegue para usar este nuevo recurso.
```yaml apiVersion: apps/v1 kind: Deployment metadata: name: gpu-example spec: replicas: 6 selector: matchLabels: app: gpu-example template: metadata: labels: app: gpu-example spec: containers: - name: gpu-example image: eritikass/gpu-load-test env: # esto le informará a la aplicación cuda que use cualquier GPU disponible - name: NVIDIA_VISIBLE_DEVICES value: all resources: requests: veriff.com/gpu-memory: 104Mi limits: veriff.com/gpu-memory: 104Mi ```
Después de eso, es visible que los pods están distribuidos entre nodos.
Con recursos regulares como CPU y memoria, Kubernetes sabrá cuánto están usando realmente los pods (contenedores) y si alguien intenta usar más - Kubernetes lo restringirá. Sin embargo, con nuestro nuevo recurso personalizado, no hay ninguna salvaguarda real en su lugar que impida que algún pod “malévolo” no tome más memoria GPU de la declarada. Por lo tanto, debes tener mucho cuidado al establecer los recursos allí. Cuando los pods intentan usar más memoria GPU de la que hay disponible en un nodo, normalmente resulta en algunos accidentes muy feos.
En Veriff, resolvimos este problema con un monitoreo y alertas extensivas para el uso de nuestros nuevos Recursos Personalizados de GPU. También hemos desarrollado herramientas internamente para medir el uso de GPU en aplicaciones de ML bajo carga.
Todos los ejemplos de código de este post se pueden encontrar en https://github.com/Veriff/gpu-sharing-examples.