Как стать автором
Обновить
Selectel
IT-инфраструктура для бизнеса

Делим неделимое в Kubernetes: шеринг GPU с помощью MIG и TimeSlicing

Уровень сложностиСложный
Время на прочтение21 мин
Количество просмотров9.1K

Привет, Хабр! На связи снова Антон, DevOps-инженер в отделе Data- и ML-продуктов Selectel. В предыдущей статье я рассказал о шеринге GPU и показал, как запустить несколько инстансов на одной видеокарте с помощью MIG. А в конце затронул тему с автомасштабированием инференс-серверов. Она оказалась актуальной, и я решил написать продолжение.

В этот раз посмотрим, как применять технологии шеринга в Kubernetes, а также разработаем прототип автомасштабируемой инференс-платформы за один вечер. Интересно? Тогда добро пожаловать под кат!

О чем поговорим:

Проблема шеринга ресурсов в Kubernetes
GPU-оператор
Подготовка рабочего окружения
Активируем MIG в Kubernetes
Автомасштабирование нашей платформы
Как обмануть GPU-ресурсы Kubernetes с помощью TimeSlicing
Заключение

Дисклеймер: для понимания данной статьи нужны навыки в работе с Helm-чартами и kubectl, а также знание Kubernetes. Если вы хотите погрузиться в эти технологии, изучайте курсы в Академии Selectel.


Проблема шеринга ресурсов в Kubernetes


Вспомним, что происходит, когда в Kubernetes мы хотим запустить под с приатаченной GPU.

Чтобы выделить GPU от Nvidia для конкретного пода, нужно использовать специальный ресурс — nvidia.com/gpu = N, где N — количество доступных видеокарт. По умолчанию это число нельзя поделить на проценты: нельзя написать, что поду нужна, например, половина видеокарты. Соответственно, один под забирает все ресурсы GPU.

Проблема заключается в том, что не всем подам нужна целая GPU. Например, если у вас A100, то выделять 40 ГБ на один инференс бывает излишне. Также не получится запустить несколько реплик одного сервиса, так как на каждую понадобится отдельный ресурс GPU — придется запастись партией видеокарт для такой имплементации.

Еще один кейс связан с обучением моделей на видеокартах. Допустим, у вас в команде несколько Data Science-специалистов, каждому из которых понадобилась GPU для обучения. При этом у вас дефицит видеокарт — если использовать нативный Kubernetes, то образуется очередь на использование ресурсов. Это связано с тем, что каждый инстанс JupyterLab захватывает ровно один ресурс GPU (если не указано больше).

Управлением ресурсами GPU занимается DaemonSet Nvidia Device Plugin. В целом, можно форкнуть этот проект и немного изменить логику присвоения ресурсов в специальном блоке кода. Но мы рассмотрим нативные решения от Nvidia.


GPU-оператор



Разбить GPU в Kubernetes можно вручную, но мы воспользуемся GPU-оператором. Это инструмент, который позволяет администраторам Kubernetes управлять узлами GPU точно так же, как узлами CPU в кластере. О том, как с ним работать, можно почитать в документации.

В нашем случае от оператора мы получаем возможность легко и просто делать разметку MIG в нодах с A100 и A30, а также дополнительно конфигурировать TimeSlicing, о котором я расскажу подробнее в конце статьи.

Принцип работы


Для нашей задачи рассмотрим основные демонсеты, которые используются оператором для работы с GPU в Kubernetes, — mig-manager и nvidia-device-plugin.


Mig-manager — это демонсет в Kubernetes, который следит за изменениями лейбла «nvidia.com/mig.config» на ноде, а затем применяет запрошенную пользователем конфигурацию MIG.

При изменении метки mig-manager сначала останавливает все модули графического процессора, включая плагин устройства, gfd и dcgm-экспортер. Затем останавливает все клиенты GPU на хосте, перечисленные в разделе client.yml (его можно расширять), если драйверы предварительно установлены. Наконец, mig-manager применяет реконфигурацию MIG и перезапускает модули GPU. Реконфигурация может включать перезагрузку узла, если это требуется для включения режима MIG.

Краткий алгоритм обработки запроса на деление GPU в K8S


1. Для конфигурирования MIG GPU-оператор использует два демонсета:

  • mig-manager — следит за лейблом nvidia.com/mig.config. Если его значение изменилось, то применяет конфигурацию MIG с помощью nvidia-smi на ноде с GPU. Конфигурацию (например, количество партиций, на которые нужно разбить видеокарту) берет из configmap.
  • nvidia-device-plugin — отвечает за ресурсы nvidia/gpu в Managed Kubernetes. Как только mig-manager применит новую конфигурацию MIG, а лейбл nvidia/mig.config.state станет success, nvidia-device-plugin изменит количество ресурсов на указанное в конфигурации.

Если стратегия single, то изменит ресурс nvidia.com/gpu. Если стратегия mixed — заменит nvidia.com/gpu на конкретную партицию.

2. Демонсеты следят за метками на нодах. При процессе миграции конфигурации MIG используется лейбл nvidia.com/mig.config.state для отслеживания статуса:

  • pending — миграция в процессе, нужно подождать.
  • succeed — миграция выполнена успешно.
  • failed — миграция остановлена из-за ошибки.

Ошибка может быть из-за наличия работающих процессов в GPU. Поэтому миграцию необходимо проводить на не занятой GPU: оператор не может оставить «чужие процессы». Подробнее можно ознакомиться здесь.

Подготовка рабочего окружения


Для нашего исследования необходимо снова использовать определенную линейку GPU — A100 или A30. Selectel предоставляет почасовую аренду таких видеокарт, чем мы и воспользуемся. Развернем Managed Kubernetes:

1. Переходим в раздел Облачная платформа в панели управления.

2. Выбираем Kubernetes и нажимаем Создать кластер.

3. Выбираем пул ru-9a, базовый тип кластера и создаем группу нод:

  • Выбираем flavor с GPU A100.
  • Указываем размер диска.
  • Сохраняем изменения.

4. Далее можно добавить свой SSH-ключ и указать необходимую сеть.

5. Ждем. Как только кластер задеплоиться, через панель можно будет достать kubeconfig.


Процесс создания кластера Managed Kubernetes.

Также необходимо позаботиться о том, где мы будем хранить наши модели. Kubernetes позволяет указывать для подов внешние хранилища, например, через протокол NFS. Для этого отлично подойдет файловое хранилище Selectel (Selectel File Storage, SFS). Теперь его можно создать прямо из панели управления.

  1. Переходим в раздел Облачная платформа внутри панели управления.
  2. Выбираем Файловое Хранилище и нажимаем Cоздать.
  3. Выбираем пул ru-9a, указываем размер диска, тип NFS.
  4. После создания можно скопировать команду для подключения к файловому хранилищу. Из этой команды нам понадобится IP-адрес и путь до файлового хранилища.



Процесс создания файлового хранилища.

Файловое хранилище


Для запуска нашего инференса нужно скачать обученные модели из общего репозитория Nvidia в наше файловое хранилище.

Почему мы вообще используем SFS, а не хранилище внутри ноды (например, монтирование PV в локальную папку на ноде)? Все просто: при создании масштабируемой системы, вероятно, понадобится горизонтально масштабировать наши ноды в кластере. При использовании сетевого хранилища инференсы на разных нодах получат доступ к нашим моделям и не будут дублировать данные внутри себя.

Подключимся к ноде Kubernetes по SSH, чтобы подцепить файловое хранилище по протоколу NFS и положить туда наши модели:

sudo mkdir -p /mnt/nfs && sudo mount -vt nfs "10.222.1.60:/shares/share-3010a65e-124c-4ac8-b08e-a7b2eae0b78c" /mnt/nfs

Далее перейдем в смонтированную папку и скачаем наши модели. Загрузим репозиторий на виртуальную машину и подтянем заготовленные модели. Также скачаем ONNX densenet (с этой моделью работали в предыдущей статье) с помощью скрипта fetch_models:

git clone -b r23.05 https://github.com/triton-inference-server/server.git
cd server/docs/examples
./fetch_models.sh

Убедимся, что в FX есть необходимые файлы для загрузки весов моделей в наш инференс-сервер:

ls /mnt/nfs/server/docs/examples/ -l
ls /mnt/nfs/server/docs/examples/model_repository/ -l


Отлично — файловое хранилище готово для монтирования подов.

Устанавливаем чарт GPU-оператора


GPU-оператор устанавливает несколько компонентов, в том числе nvidia-device-plugin. По умолчанию в Managed Kubernetes плагин уже установлен на нодах с GPU, поэтому сначала удаляем дефолтный плагин:

kubectl delete daemonset/nvidia-device-plugin-daemonset -n kube-system

Далее устанавливаем GPU-оператор с помощью Helm:

helm install --wait --generate-name \
    -n kube-system --create-namespace \
    nvidia/gpu-operator \
    --set mig.strategy=single

Небольшие пояснения по поводу выбора стратегии Mig:

  • mig.strategy=single — используется только в случае, если на одной ноде подключены GPU с возможностью включения MIG. При этом доступны только конфигурации, которые делят видеокарты на равные части. Примеры конфигураций для GPU A100 и A30 можно посмотреть здесь (конфигурации для стратегии single начинаются с префикса all).
  • mig.strategy=mixed — используется в случаях, когда на одной ноде хотя бы одна GPU поддерживает MIG. Притом доступны любые конфигурации, поддерживаемые видеокартой. Пример такой конфигурации показан здесь.

Доступные конфигурации MIG для видеокарт A100 и A30 можно изучить в предыдущей статье.

После установки оператора GPU посмотрим лейблы ноды, которые относятся к Nvidia:

kubectl get node <node name> -o json | jq '.metadata.labels' | grep nvidia


Нас интересуют следующие лейблы:

  • nvidia.com/gpu.count — количество ресурсов GPU (сейчас указано значение 1 — следовательно, поду достанется целая видеокарта),
  • nvidia.com/gpu.product — убеждаемся что нода с GPU A100,
  • nvidia.com/mig.config — текущая конфигурация MIG по умолчанию (all-disabled означает, что MIG выключен),
  • nvidia.com/mig.config.state — статус разбиения MIG (устанавливается mig-manager),
  • nvidia.com/mig.strategy — стратегия распределения партиций, single.

Следующим этапом рекомендую проверить, что при отключенном MIG действует правило «один под — одна GPU». Для этого напишем манифест для нашего triton server, который использовали в прошлой статье.

Также обязательно укажем монтирование файлового хранилища с моделями на удаленном SFS-сервере. Для этого запустим две реплики нашего инференс-сервера:

apiVersion: apps/v1
kind: Deployment
metadata:
name: tritonserver
labels:
  app: tritonserver
spec:
replicas: 2
selector:
  matchLabels:
    app: tritonserver
template:
  metadata:
    labels:
      app: tritonserver
  spec:
    volumes:
    - name: models
      nfs:
        server: 10.222.1.60 # указываем ip адрес nfs сервера
        path: /shares/share-3010a65e-124c-4ac8-b08e-a7b2eae0b78c/server/docs/examples/model_repository # указываем папку монтирования
        readOnly: false
    containers:
      - name: tritonserver
        ports:
        - containerPort: 8000
          name: http-triton
        - containerPort: 8001
          name: grpc-triton
        - containerPort: 8002
          name: metrics-triton
        image: "nvcr.io/nvidia/tritonserver:23.05-py3"
        volumeMounts:
        - mountPath: /models
          name: models
        command: ["/bin/sh", "-c"]
        args: ["/opt/tritonserver/bin/tritonserver --model-repository=/models --allow-gpu-metrics=false"]
        resources:
          limits:
            nvidia.com/gpu: 1

Теперь можем убедиться, что один из контейнеров встал в очередь:


На нашей ноде доступен только один ресурс GPU. Соответственно, второй под будет ждать в очереди с сообщением:

0/1 nodes are available: 1 Insufficient nvidia.com/gpu. preemption: 0/1 nodes are available: 1 No preemption victims found for incoming pod.

Активируем MIG в Kubernetes


Пришло время активировать MIG в нашем кластере Kubernetes. На самом деле, в этом нет ничего сложного, так как мы используем GPU-оператор, который делает всю работу за нас. Для начала уберем мусор: удалим созданный triton server и перейдем к настройке MIG.

Подключимся к ноде по SSH и выведем nvidia-smi. Как видим, после установки оператора MIG выключен:


Каким образом можно включить MIG и сразу разделить видеокарту, согласно нужной конфигурацию? Все просто: необходимо в лейбл nvidia.com/mig.config прописать конфигурацию MIG, которую можно взять по ссылке.

Теперь попробуем разбить карту на семь равных частей:

kubectl label node <node name> nvidia.com/mig.config=all-1g.5gb --overwrite


Зайдем в логи mig-manager. Как видим, он зафиксировал изменение лейбла и начал свою работу. По сути, зашел на ноду и применил 19 конфигурацию MIG:


Далее повторно подключимся к ноде по SSH и выведем nvidia-smi:


Наша GPU успешно разбилась на семь партиций. Но что произошло с ресурсами в Kubernetes? Посмотрим, сколько стало доступно ресурсов GPU:

kubectl get node <node name> -o json | jq '.status.allocatable


Так как мы выбрали стратегию Single, в Kubernetes теперь доступно семь ресурсов GPU. Далее снова протестируем наш манифест с triton server. Попробуем запустить три реплики сразу и выведем их статус:


В логах пода видим, что сервисы успешно поднялись:


Посмотрим на занятые ресурсы GPU в ноде:

kubectl describe nodes/<node name>


Как видим, система использует три пода из семи. Убедимся, что видеопамять действительно занята на отдельных партициях MIG:


Все три процесса распределены по разным партициям MIG. Таким образом, можно запускать на одной GPU до 7 подов, если вы используете A100.

Так как мы создали deployment, теперь мы можем отскалировать triton server до семи подов:


Следующим этапом попробуем повторить наш demo-стенд с балансировщиком нагрузки из прошлой статьи, добавив к нему функционал автомасштабирования.

Балансировка нагрузки


Необходимо создать сервис Kubernetes, чтобы клиенты могли отправлять запросы на triton server. За основу я взял статью от Nvidia, обновив манифесты до версий текущего года. Согласно схеме ниже, все запросы клиентов будут распределятся по нашим инференс-серверам через Load Balancer (балансировщик нагрузки):


Создадим сервис с типом Load Balancer. Также укажем порты, которые пробрасываем для протоколов HTTP и GRPC. Отдельно выделим порт для сбора метрик, показывающие насыщение наших сервисов (по ним мы сможем определить нагрузку на сервисы):

kubectl apply -f pod-sharing-example/service-triton.yaml

apiVersion: v1
kind: Service
metadata:
name: tritonserver
labels:
  app: tritonserver
spec:
selector:
  app: tritonserver
ports:
  - protocol: TCP
    port: 8000
    name: http
    targetPort: 8000
  - protocol: TCP
    port: 8001
    name: grpc
    targetPort: 8001
  - protocol: TCP
    port: 8002
    name: metrics
    targetPort: 8002
type: LoadBalancer

Проверим, что балансировщик поднялся (также балансировщик будет доступен в панели облака Selectel):

kubectl get svc


Супер — балансировщик запущен. Далее запустим контейнер-клиент из предыдущей статьи и проверим, что инференс развернут и доступен из интернета (внешний IP балансировщика доступен в поле external-ip). Для этого подключаемся внутрь контейнера и отправляем запрос на наш сервер. При этом выбираем модель для распознавания изображений — densenet_onnx.

docker run -it --rm nvcr.io/nvidia/tritonserver:23.05-py3-sdk
/workspace/install/bin/image_client -m densenet_onnx -c 3 -s INCEPTION -i http -u 5.188.81.52:8000 /workspace/images/mug.jpg

В результате, как и в предыдущей статье, моделька распознает, что изображено на картинке. Отправили картинку с кружкой кофе — получили такой результат:

# Inference should return the following
Image '/workspace/images/mug.jpg':
    15.346230 (504) = COFFEE MUG
    13.224326 (968) = CUP
    10.422965 (505) = COFFEEPOT

Мониторинг


Чтобы автоматически масштабировать количество инференс-серверов, необходимо опираться на обратную связь от сервисов — метрики нагрузки. Их можно собирать с помощью Prometheus.

Для начала установим инсталляцию Prometheus Operator, которая подробно описана в отдельной статье:

helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
helm search repo prometheus-community
kubectl create namespace monitoring
helm install kube-prom-stack prometheus-community/kube-prometheus-stack -n monitoring

Далее создадим PodMonitor, чтобы метрики из наших инференс-серверов triton попадали в Prometheus:

kubectl apply -f monitoring/pod-monitor-triton.yaml

apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
name: kube-prometheus-stack-tritonmetrics
namespace: monitoring
labels:
    release: kube-prom-stack
spec:
 selector:
    matchLabels:
       app: tritonserver
 namespaceSelector:
    matchNames:
       - default
 podMetricsEndpoints:
   - port: metrics-triton
     interval: 10s
     path: /metrics

Чтобы применить изменения, необходимо перезагрузить Prometheus:

kubectl rollout restart statefulset prometheus-kube-prom-stack-kube-prome-prometheus

В интерфейсе Prometheus (его можно пробросить через kubectl port forward) в таргетах можно увидеть все наши реплики инференс-сервера:


Отправим несколько тестовых запросов на наш инференс-сервер и посмотрим на метрику NVIDIA Triton — nv_inference_request_success. Количество успешных запросов должно отличаться от 0. При этом можно заметить, что на каждом поде указано разное количество запросов. Это связано с тем, что балансировщик нагрузки распределяет запросы по разным подам, чтобы уменьшить нагрузку на всю нашу платформу:


Также можем запросить агрегированную метрику по следующей формуле (в течение 30 секунд я отправлял запросы на сервер). Формула показывает среднее значение отношения длительности запросов и количества успешных запросов за последние 30 секунд. В дальнейшем по этому соотношению мы и будем управлять автомасштабированием:

avg(delta(nv_inference_queue_duration_us[30s])/(1+delta(nv_inference_request_success[30s])))

Готово — сбор метрик работает, балансировщик нагрузок настроен, MIG в Kubernetes активирован.

Другие полезные материалы:

   

Автомасштабирование нашей платформы


Prometheus Adapter


Теперь следует развернуть Prometheus Adapter, который умеет взаимодействовать с Kubernetes и самим Prometheus. Он поможет нам использовать собранные показатели для принятия решений о масштабировании. Адаптер собирает названия доступных метрик из Prometheus через регулярные промежутки времени, а затем предоставляет только те метрики, которые соответствуют определенным формам. Эти показатели предоставляются сервисом API и могут быть легко использованы HPA.


Для своего прототипа я выдал расширенный доступ к Kubernetes для Prometheus Adapter, что не советую делать в продакшен-системе. Я это сделал с помощью специальной команды:

kubectl create clusterrolebinding permissive-binding --clusterrole=cluster-admin --user=admin --user=kubelet --group=system:serviceaccounts

Для продакшен-системы вы можете настроить политику RBAC самостоятельно — наиболее подробно процесс описан в официальной документации Kubernetes и документации Selectel.

Определяем новую метрику, CustomMetric


Мы хотим мониторить агрегированную метрику наших сервисов, но для этого ее необходимо создать. Будем использовать среднее время запросов (avg_time_queue_us) в микросекундах, которое опишем в ConfigMap, чтобы HPA масштабировал реплики по этому таргету.

Составим формулу в нашем ConfigMap которая состоит из следующих метрик сервиса:

  • nv_inference_request_success[30] — число успешных запросов за последние 30 секунд,
  • nv_inference_queue_duration_us — длительность запросов в микросекундах.

Пользовательская метрика avg_time_queue_us, которую мы определим ниже, означает среднее время запроса за последние 30 секунд. На ее основе HPA решает, следует ли изменять количество реплик. В итоге наш ConfigMap выглядит следующим образом:

apiVersion: v1
kind: ConfigMap
metadata:
name: adapter-config
namespace: monitoring
data:
triton-adapter-config.yml: |
  rules:
  - seriesQuery: 'nv_inference_queue_duration_us{namespace="default",pod!=""}'
    resources:
      overrides:
        namespace: {resource: "namespace"}
        pod: {resource: "pod"}
    name:
      matches: "nv_inference_queue_duration_us"
      as: "avg_time_queue_us"
    metricsQuery: 'avg(delta(nv_inference_queue_duration_us{<<.LabelMatchers>>}[30s])/(1+delta(nv_inference_request_success{<<.LabelMatchers>>}[30s]))) by (<<.GroupBy>>)'

Деплоим Prometheus Adapter


Чтобы HPA мониторил кастомную метрику, мы должны создать Deployment, Service, и APIService для Prometheus Adapter. Далее будут представлены манифесты, которые необходимы для его работы. Ознакомимся сначала с каждым из них и далее задеплоим все вместе.

Ниже представлен код нашего Deployment, который ссылается на созданный ранее ConfigMap и Prometheus (укажите в нем правильный адрес --prometheus-url и название config-файла, который мы готовили выше):

apiVersion: apps/v1
kind: Deployment
metadata:
 name: triton-custom-metrics-apiserver
 namespace: monitoring
 labels:
   app: triton-custom-metris-apiserver
spec:
 replicas: 1
 selector:
  matchLabels:
    app: triton-custom-metrics-apiserver
 template:
  metadata:
    labels:
      app: triton-custom-metrics-apiserver
  spec:
    containers:
    - name: custom-metrics-server
      image: registry.k8s.io/prometheus-adapter/prometheus-adapter:v0.11.0
      args:
      - --cert-dir=/tmp
      - --prometheus-url=http://kube-prom-stack-kube-prome-prometheus:9090/
      - --metrics-relist-interval=30s
      - --config=/etc/config/triton-adapter-config.yml
      - --secure-port=6443
      ports:
      - name: main-port
        containerPort: 6443
      volumeMounts:
      - name: config-volume
        mountPath: /etc/config
        readOnly: false
    volumes:
    - name: config-volume
      configMap:
        name: adapter-config

Теперь создадим новый Service для нашего Deployment:

apiVersion: v1
kind: Service
metadata:
 name: triton-custom-metrics-api
 namespace: monitoring
spec:
 selector:
  app: triton-custom-metrics-apiserver
 ports:
 - port: 443
   targetPort: 6443


Далее создадим APIService, в котором определим API для доступа к новым метрикам. API будет доступно по адресу custom.metrics.k8s.io/v1beta1. Так HPA сможет достучаться до нашей кастомной метрики. Код ниже создаст сущность APIService, которую мы привяжем к нашему сервису:

apiVersion: apiregistration.k8s.io/v1
kind: APIService
metadata:
 name: v1beta1.custom.metrics.k8s.io
spec:
 groupPriorityMinimum: 100
 group: custom.metrics.k8s.io
 insecureSkipTLSVerify: true
 service:
   name: triton-custom-metrics-api
   namespace: monitoring
 version: v1beta1
 versionPriority: 5


Задеплоим наши сущности с помощью команды kubectl apply -f — и можно проверить вывод нашего API:

gpu-sharing-k8s git:(main) $ kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1" | jq                       
{
  "kind": "APIResourceList",
  "apiVersion": "v1",
  "groupVersion": "custom.metrics.k8s.io/v1beta1",
  "resources": [
    {
      "name": "pods/avg_time_queue_us",
      "singularName": "",
      "namespaced": true,
      "kind": "MetricValueList",
      "verbs": [
        "get"
      ]
    },
    {
      "name": "namespaces/avg_time_queue_us",
      "singularName": "",
      "namespaced": false,
      "kind": "MetricValueList",
      "verbs": [
        "get"
      ]
    }
  ]
}

Проверим, что наши поды действительно возвращают метрики:

gpu-sharing-k8s git:(main) $ kubectl get --raw /apis/custom.metrics.k8s.io/v1beta1/namespaces/default/pods/%2A/avg_time_queue_us | jq .
{
  "kind": "MetricValueList",
  "apiVersion": "custom.metrics.k8s.io/v1beta1",
  "metadata": {},
  "items": [
    {
      "describedObject": {
        "kind": "Pod",
        "namespace": "default",
        "name": "tritonserver-595c7df85f-bnplq",
        "apiVersion": "/v1"
      },
      "metricName": "avg_time_queue_us",
      "timestamp": "2023-08-17T15:21:53Z",
      "value": "399300m",
      "selector": null
    },
    {
      "describedObject": {
        "kind": "Pod",
        "namespace": "default",
        "name": "tritonserver-595c7df85f-gp5k9",
        "apiVersion": "/v1"
      },
      "metricName": "avg_time_queue_us",
      "timestamp": "2023-08-17T15:21:53Z",
      "value": "0",
      "selector": null
    },
    {
      "describedObject": {
        "kind": "Pod",
        "namespace": "default",
        "name": "tritonserver-595c7df85f-mpxhn",
        "apiVersion": "/v1"
      },
      "metricName": "avg_time_queue_us",
      "timestamp": "2023-08-17T15:21:53Z",
      "value": "4275m",
      "selector": null
    }
  ]
}

Как видим, под tritonserver-595c7df85f-mpxhn вернул метрику, после того, как я в течение 30 секунд посылал тестовые запросы. По этим метрикам мы и будем масштабировать наши реплики.

Horizontal Pod Autoscaling


HPA масштабирует наши реплики по следующей формуле:



  • R — необходимое количество реплик,
  • CR — текущее количество реплик,
  • CV — текущая метрика, среднее значение метрик со всех реплик,
  • DV — порог метрики.

Когда R отличается от CR, HPA увеличивает или уменьшает количество реплик, воздействуя на развертывание подов Kubernetes. По сути, всякий раз, когда соотношение между текущим значением метрики и таргетом больше одного, могут быть развернуты новые реплики.


Работа HPA заключается в том, что он говорит Deployment сколько нужно реплик в текущий момент времени.

Ниже представлен манифест HPA, где мы указываем наш Deployment, а также название метрики и ее порог, по которому HPA будет принимать решение об изменении количества реплик. Применим изменения также через команду kubectl apply -f.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
 name: tritonserver-hpa
spec:
 scaleTargetRef:
   apiVersion: apps/v1
   kind: Deployment
   name: tritonserver
 minReplicas: 1
 maxReplicas: 7
 metrics:
 - type: Pods
   pods:
     metric:
       name: avg_time_queue_us
     target:
       type: AverageValue
       averageValue: 50

Если клиенты начнут спамить запросами на инференс-сервер, HPA развернет больше реплик, а балансировщик автоматически начнет перенаправлять запросы на новые реплики. Тем самым мы снизим нагрузку на существующие.

Теперь запустим тест на пропускную способность из прошлой статьи, начиная с 60 конкурентных клиентов:

perf_client -i http -u triton-server -m densenet_onnx --concurrency-range 60:100

Средний показатель вырос до достаточно больших значений, HPA начал масштабировать наши сервисы до максимального числа:

kubectl describe hpa tritonserver-hpa


Но с MIG мы ограничены только масштабированием в семь реплик, так как сейчас мы поделили нашу GPU A100 на максимальное количество партиций. При этом важно помнить, одна реплика — это один ресурс GPU.

Но каким образом мы можем запускать больше реплик в Kubernetes? Давайте посмотрим, как немного обмануть его с помощью TimeSlicing.

Как обмануть GPU-ресурсы Kubernetes с помощью TimeSlicing


Рассмотрим деплой нашего сервиса на ноду с GPU Tesla T4. Такая видеокарта не поддерживает MIG, то есть разделить ее на несколько кусочков так, как мы делали ранее, не получится. Но Nvidia предоставляет еще один способ шеринга GPU в Kubernetes — TimeSlicing. С этой технологией мы не ограничены только GPU A100 и A30.

Добавим через панель управления еще одну группу нод с GPU Tesla T4. Для этого достаточно выбрать необходимый flavor и указать размер хранилища:


Далее увидим, что в нашем кластере добавилась новая группа нод:


Новая группа нод доступна через kubectl:


Официальная документация по настройке TimeSlicing с помощью оператора GPU доступна по ссылке.

Теперь необходимо создать ConfigMap, в котором мы укажем количество партиций для разбиения GPU. Для каждой из них TimeSlicing выделит свой квант времени (то есть конкурентные процессы будут сменять друг друга на разных процессах), при этом будет использоваться общая память GPU. Если один из процессов превысить лимит по видеопамяти и случится OutOfMemory Error (OOM), ошибка затронет все сервисы на GPU.

Далее интегрируем конфигурацию в наш Helm-релиз. Для этого проверим, что на Tesla T4 доступен только один ресурс nvidia.com/gpu:

kubectl get node <node name> -o json | jq '.status.allocatable


Вторым этапом применим следующий ConfigMap, в котором укажем, что нужно поделить GPU на 14 частей:

apiVersion: v1
kind: ConfigMap
metadata:
 name: time-slicing-config-fine
 namespace: kube-system
data:
 tesla-t4: |-
   version: v1
   flags:
     migStrategy: none
   sharing:
     timeSlicing:
       resources:
       - name: nvidia.com/gpu
         replicas: 14

Теперь обновляем наш релиз GPU-оператора, указав новый параметр:

helm upgrade --install --wait \
    -n kube-system <release-name> \
    nvidia/gpu-operator \
    -set devicePlugin.config.name=time-slicing-config-fine

Чтобы активировать TimeSlicing, необходимо добавить лейбл на ноду:

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

В логах nvidia-device-plugin можно увидеть следующее:

"deviceListStrategy": [
"envvar"
],
"deviceIDStrategy": "uuid",
"cdiAnnotationPrefix": "cdi.k8s.io/",
"nvidiaCTKPath": "/usr/bin/nvidia-ctk",
"containerDriverRoot": "/host"
}
},
"resources": {
"gpus": [
{
"pattern": "*",
"name": "nvidia.com/gpu"
}
],
"mig": [
{
"pattern": "*",
"name": "nvidia.com/gpu"
}
]
},
"sharing": {
"timeSlicing": {
"resources": [
{
"name": "nvidia.com/gpu",
"devices": "all",
"replicas": 14
}
]
}
}

Как видим из логов, плагину выданы новые инструкции на изменение количества реплик (sharing[‘timeslicing’]). Теперь можно проверить, сколько ресурсов nvidia.com/gpu стало доступно:


Супер — мы немного обманули Kubernetes. Теперь можно запускать до 14 инференсов на ноде с GPU Tesla T4. Однако мы все еще не застраховали себя от ошибки OOM.

Попробуем запустить наш Deployment на этой ноде с четырьмя репликами:

apiVersion: apps/v1
kind: Deployment
metadata:
name: tritonserver-teslat4
labels:
  app: tritonserver-teslat4
spec:
replicas: 4
selector:
  matchLabels:
    app: tritonserver-teslat4
template:
  metadata:
    labels:
      app: tritonserver-teslat4
  spec:
    volumes:
    - name: models
      nfs:
        server: 10.222.1.60
        path: /shares/share-3010a65e-124c-4ac8-b08e-a7b2eae0b78c/server/docs/examples/model_repository
        readOnly: false
    nodeSelector:
      nvidia.com/gpu.product: Tesla-T4-SHARED
    containers:
      - name: tritonserver-teslat4
        ports:
        - containerPort: 8000
          name: http-triton
        - containerPort: 8001
          name: grpc-triton
        - containerPort: 8002
          name: metrics-triton
        image: "nvcr.io/nvidia/tritonserver:23.05-py3"
        volumeMounts:
        - mountPath: /models
          name: models
        command: ["/bin/sh", "-c"]
        args: ["/opt/tritonserver/bin/tritonserver --model-repository=/models --allow-gpu-metrics=false"]
        resources:
          limits:
            nvidia.com/gpu: 1

kubectl get deployments


Видим, что поды поднялись — заглянем в логи одного из них, чтобы убедиться, что сервер работает. А также посмотрим загрузку GPU через nvidia-smi на ноде:


Видно, что поднялись четыре разных процесса на одной GPU, но они используют общую память. Если мы начнем увеличивать количество подов, то в какой то момент превысим лимит и все наши сервисы получат OOM.

Как решить проблему OOM?


Думаю, этот вопрос достоин отдельной статьи. Но могу дать краткую пару вариантов по решению проблемы с OOM при использовании TimeSlicing:

1. Ограничьте потребление памяти на уровне приложения. Например, если вы работаете с Tensorflow, достаточно повторить действия из документации.

2. Ограничьте потребление памяти на уровне runtime. Можно конвертировать модели в onnx runtime. Подробности ищите по ссылке.

3. Используйте CUDA Multi-Process Service (MPS). Скажу сразу: нативной поддержки от Nvidia еще нет. Но можно использовать форк nvidia-device-plugin.

4. Используйте MIG, тогда OOM будет ограничена конкретной партицией. Да, у вас будет ограничение в семь частей GPU, но при желании его можно обойти. Давайте познакомимся с этим решением поближе.

Коллаборация века, или TimeSlicing в MIG



Сейчас мы посмотрим, каким образом можно «впихнуть» в шеринг GPU по MIG шеринг GPU по TimeSlicing.

Попробуем разбить нашу ноду A100 через mixed-стратегию. Например, возьмем десятую конфигурацию, в которой две части 1g.5gb и по одной 2g.10gb и 3g.20gb. И с помощью TimeSlicing увеличим количество ресурсов (replicas) в каждой партиции.

Для этого необходимо снова обратиться к ConfigMap, который мы использовали для TimeSlicing с Tesla T4. Суть точно такая же: мы вводим разбиение по количеству реплик на каждый ресурс, при этом явно указываем ресурсы для каждой партиции MIG.

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

Теперь обновим оператор GPU, настроив стратегию MIG и добавив новый ConfigMap:

helm upgrade --install --wait \                                                                 
    -n kube-system gpu-operator-1691669985 \
    nvidia/gpu-operator \
    --set mig.strategy=mixed --set devicePlugin.config.name=time-slicing-config-mig

Укажем для nvidia-device-plugin, что мы будем использовать config a100-40gb из нашего ConfigMap:

kubectl label node <node name> nvidia.com/device-plugin.config=a100-40gb --overwrite

Применим десятую конфигурацию MIG, разбив A100 на разные партиции, которые указывали выше:

kubectl label node <node name> nvidia.com/mig.config=all-balanced --overwrite

Далее выведем доступные ресурсы и посмотрим на результат:

kubectl get node <node name> -o json | jq '.status.allocatable


Как видим, нам стало доступно больше ресурсов, так как мы добавили к каждой партиции TimeSlicing. Удалось получить восемь реплик 1g.5gb, три 2g.10gb и четыре 3g.20gb.

Проверим, что теперь мы можем деплоить больше семи реплик нашего инференса на видеокарту A100 с включенным MIG. Задеплоим triton server, слегка модифицировав уже знакомый манифест Deployment. Обратите внимание на секцию с ресурсами, на партицию 1g.5gb:

apiVersion: apps/v1
kind: Deployment
metadata:
name: tritonserver-timeslicing-mig
labels:
  app: tritonserver-timeslicing-mig
spec:
replicas: 8
selector:
  matchLabels:
    app: tritonserver-timeslicing-mig
template:
  metadata:
    labels:
      app: tritonserver-timeslicing-mig
  spec:
    volumes:
    - name: models
      nfs:
        server: 10.222.1.60
        path: /shares/share-3010a65e-124c-4ac8-b08e-a7b2eae0b78c/server/docs/examples/model_repository
        readOnly: false
    containers:
      - name: tritonserver-timeslicing-mig
        ports:
        - containerPort: 8000
          name: http-triton
        - containerPort: 8001
          name: grpc-triton
        - containerPort: 8002
          name: metrics-triton
        image: "nvcr.io/nvidia/tritonserver:23.05-py3"
        volumeMounts:
        - mountPath: /models
          name: models
        command: ["/bin/sh", "-c"]
        args: ["/opt/tritonserver/bin/tritonserver --model-repository=/models --allow-gpu-metrics=false"]
        resources:
          limits:
            nvidia.com/mig-1g.5gb: 1

Готово — на партиции nvidia.com/mig-1g.5gb запустилось ровно восемь реплик:


Таким образом, обойти ограничение в семь реплик можно. Однако важно помнить про OOM, потому что используя TimeSlicing мы рискуем превысить лимит, но в рамках уже одной партиции.

Заключение


В этой статье мы изучили, как можно использовать MIG и TimeSlicing в Kubernetes, а также показали реальный пример инференс-сервера с автомасштабированием. Мы познакомились с оператором GPU, который позволяет достаточно просто динамически управлять шерингом видеокарт в Kubernetes. И напоследок — разобрали варианты решения проблемы с OOM.

Готовая платформа с шерингом GPU


Недавно мы имплементировали автоматическую разметку MIG и TimeSlicing в нашей ML-платформе, что позволило запускать дополнительные параллельные Jupyter-инстансы на одной видеокарте, а также очереди экспериментов в ClearML. Но об этом уже в следующей статье — следите за обновлениями в нашем блоге на Хабре!

Все примеры из статьи есть в нашем GitHub-репозитории. Делайте форк и предлагайте свои улучшения!
Теги:
Хабы:
Всего голосов 41: ↑40 и ↓1+55
Комментарии10

Публикации

Информация

Сайт
slc.tl
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия
Представитель
Влад Ефименко