Привет, Хабр! Я руковожу разработкой платформы Vision — это наша публичная платформа, которая предоставляет доступ к моделям компьютерного зрения и позволяет вам решать такие задачи, как распознавание лиц, номеров, объектов и целых сцен. И сегодня хочу на примере Vision рассказать, как реализовать быстрый высоконагруженный сервис, использующий видеокарты, как его разворачивать и эксплуатировать.

Что такое Vision?


По сути, это REST API. Пользователь формирует HTTP-запрос c фотографией и отправляет на сервер.

Допустим, нужно на снимке распознать лицо. Система его находит, вырезает, извлекает из лица какие-то свойства, сохраняет в базе и присваивает некий условный номер. Например, person42. Затем пользователь загружает следующую фотографию, на которой есть тот же самый человек. Система извлекает из его лица свойства, ищет по базе и возвращает условный номер, который был присвоен персоне изначально, т.е. person42.

Сегодня основные пользователи Vision — это различные проекты Mail.ru Group. Больше всего запросов приходит от Почты и Облака.


В Облаке у пользователей есть папки, в которые загружаются фотографии. Облако прогоняет файлы через Vision и группирует их по категориям. После этого пользователь может удобно листать свои фотографии. Например, когда хочется показать фотографии друзьям или родным, можно быстро найти нужные.

И Почта, и Облако — это очень крупные сервисы с многомиллионными аудиториями, поэтому Vision обрабатывает сотни тысяч запросов в минуту. То есть это классический высоконагруженный сервис, но с изюминкой: в нём есть и nginx, и веб-сервер, и база данных, и очереди, но на самом нижнем уровне этого сервиса находится inference — прогон изображений через нейронные сети. Именно прогон нейронных сетей занимает большую часть времени и требует ресурсов. Вычисление сетей состоит из последовательности матричных операций, которые на CPU выполняются обычно долго, зато отлично распараллеливаются на GPU. Для эффективного прогона сетей у нас используется кластер серверов с видеокартами.

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

Разработка сервиса


Время обработки одного запроса


Для системы с большой нагрузкой важное значение имеет время обработки одного запроса и пропускная способность системы. Высокая скорость обработки запросов обеспечивается, в первую очередь, правильным подбором архитектуры нейронной сети. В ML, как и в любых других задачах программирования, одни и те же задачи можно решать разными способами. Возьмём детектирование лиц: для решения этой задачи мы сначала взяли нейросети с архитектурой R-FCN. Они показывают достаточно высокое качество, но занимали порядка 40 мс на одном изображении, что нас не устраивало.Тогда мы обратились к архитектуре MTCNN и получили двукратный прирост скорости с незначительной потерей качества.

Иногда для оптимизации времени вычисления нейронных сетей бывает выгодно в проде осуществлять inference в другом фреймворке, не в том, котором проводилось обучение. Например, иногда имеет смысл конвертировать свою модель в NVIDIA TensorRT. Он применяет ряд оптимизаций и особенно хорош на достаточно сложных моделях. Например, он может каким-то образом переставить некоторые слои, объединить и даже выкинуть; результат при этом не изменится, а скорость вычисления inference возрастёт. Также TensorRT позволяет лучше управлять памятью и может после некоторых ухищрений сводить к вычислениям чисел с меньшей точностью, что тоже повышает скорость вычисления inference.

Загрузка видеокарты


Inference сети у нас осуществляется на GPU, видеокарта являются самой дорогой частью сервера, поэтому важно максимально эффективно ее использовать. Как понять, полностью мы загрузили GPU или можно увеличить нагрузку? На этот вопрос можно ответить, например, с помощью параметра GPU Utilization, в утилите nvidia-smi из стандартного пакета видеодрайвера. Данная цифра конечно не показывает сколько CUDA-ядер непосредственно загружено у видеокарты, а сколько простаивают, но она позволяет как-то оценить загрузку GPU. По опыту можно сказать, что хорошей является загрузка на 80-90 %. Если она загружена на 10-20 %, то это плохо, и потенциал ещё есть.

Важное следствие из этого наблюдения: нужно стараться организовать систему так, чтобы максимально загружать видеокарты. Кроме того, если у вас есть 10 видеокарт, каждая из которых загружена на 10-20 %, то, скорее всего, ту же самую задачу могут решить две видеокарты с высокой загрузкой.

Пропускная способность системы


Когда вы подаете картинку на вход нейросети, то обработка картинки сводится к разнообразным матричным операциям. Видеокарта — многоядерная система, а картинки на вход мы обычно подаем небольшого размера. Допустим, на нашей видеокарте 1 000 ядер, а картинки у нас 250 х 250 пикселей. По одиночке они не смогут загрузить все ядра из-за своего скромного размера. И если мы будем подавать такие картинки в модель по одной, то загрузка видеокарты не будет превышать 25 %.


Поэтому нужно загружать в inference сразу несколько картинок и формировать из них batch.


В этом случае загрузка видеокарты поднимается до 95 %, а вычисление inference займет время как для одной картинки.

А что делать, если в очереди нет 10 картинок, чтобы мы могли их объединить в батч? Можно немного подождать, например, 50-100 мс в надежде на то, что запросы придут. Эта стратегия носит название fix latency strategy (стратегия фиксированной задержки). Она позволяет объединять запросы от клиентов во внутреннем буфере. В итоге мы увеличиваем нашу задержку на некоторую фиксированную величину, но значительно увеличиваем пропускную способность системы.

Запуск inference


Модели мы обучаем на изображениях фиксированного формата и размера (например, 200 х 200 пикселей), но сервис должен поддерживать возможность загружать различные картинки. Поэтому все изображения прежде чем подавать на inference, нужно правильно подготовить (отресайзить, центрировать, нормировать, перевести во float и т.д.). Если все эти операции будут выполняться в процессе, который запускает inference, то его рабочий цикл будет выглядеть примерно так:



Какое-то время он тратит в процессоре, подготавливая входные данные, какое-то время ждет ответа от GPU. Лучше максимально уменьшить промежутки между inference, чтобы GPU меньше простаивала.



Для этого можно завести ещё один поток, или передать подготовку изображений другим серверам, без видеокарт, но с мощными процессорами.

Процесс, отвечающий за inference, по возможности должен заниматься только им: обращаться к общей памяти, забирать входные данные, сразу копировать их в память видеокарты и запускать inference.

Turbo Boost


Запуск нейросетей — операция, затратная по ресурсам не только GPU, но и процессора. Даже если у вас всё будет правильно организовано с точки зрения пропускной способности, и поток, который выполняет inference, уже ждет новых данных, на слабом процессоре вы просто не будете успевать насыщать этот поток новыми данными.

Многие процессоры поддерживают технологию Turbo Boost. Она позволяет увеличивать частоту работы процессора, однако не всегда включена по умолчанию. Стоит это проверить. Для этого в Linux есть утилита CPU Power: $ cpupower frequency-info -m.


Также у процессоров настраивается режим энергопотребления, его можно узнать такой командой CPU Power: performance.


В режиме powersave процессор может тротлить свою частоту и работать медленнее. Стоит зайти в BIOS и выбрать режим performance. Тогда процессор будет всё время работать на максимальной частоте.

Развёртывание приложения


Для развертывания приложения отлично подходит Docker, он позволяет запускать приложения на GPU внутри контейнера. Чтобы получить доступ к видеокартам, для начала вам понадобится установить драйвера для видеокарты на хост-систему — физический сервер. Затем, чтобы запустить контейнер, нужно проделать много ручной работы: правильно прокинуть видеокарты внутрь контейнера с правильно подобранными параметрами. После запуска контейнера еще необходимо будет внутри него установить видеодрайверы. И только после этого вы сможете пользоваться вашим приложением.



У этого подхода есть один нюанс. Серверы могут пропадать из кластера и добавляться. Возможна ситуация, когда на разных серверах будут разные версии драйверов, и они будут отличаться от версии, которая ставится внутри контейнера. В этом случае простой Docker сломается: приложение при попытке доступа к видеокарте получит ошибку driver version mismatch.



Как с этим бороться? Существует версия Docker от NVIDIA, благодаря которой пользоваться контейнером становится проще и приятнее. По заверениям самой NVIDIA и по практическим наблюдениям, накладные расходы на использование nvidia-docker около 1 %.

В этом случае драйвера нужно ставить только на хост-машину. При запуске контейнера, ничего прокидывать во внутрь не надо, и у приложения сразу появится доступ к видеокартам.



«Независимость» nvidia-docker от драйверов позволяет запускать контейнер из одного и того же образа на разных машинах, на которых установлены разные версии драйверов. Как это реализовано? В Docker есть такое понятие, как docker-runtime: это набор стандартов, который описывает, как контейнер должен общаться с ядром хостовой машины, как он должен запускаться и останавливаться, как взаимодействовать с ядром и драйвером. Начиная с определенной версии Docker есть возможность подменять этот runtime. Это и сделали в NVIDIA: они подменяют рантайм, ловят внутри обращения к видеодрайверу и преобразуют в обращения к видеодрайверу правильной версии.

Оркестрация


В качестве оркестратора мы выбрали Kubernetes. Он поддерживает много очень хороших функций, которые полезны для любой высоконагруженной системы. Например, autodiscovering позволяет сервисам обращаться друг к другу внутри кластера без сложных правил роутинга. Или fault tolerance — когда Kubernetes всегда держит наготове несколько контейнеров, и если с вашим что-то случилось, то Kubernetes тут же запустит новый контейнер.

Если у вас есть уже настроенный Kubernetes-кластер, то вам нужно не так много, чтобы начать использовать видеокарты внутри кластера:

  • относительно свежие драйверы
  • установленный nvidia-docker версии 2
  • docker runtime выставленный по умолчанию в `nvidia` в файле /etc/docker/daemon.json:
    "default-runtime": "nvidia"
  • Установленный плагин kubectl create -f https://githubusercontent.com/k8s-device-plugin/v1.12/plugin.yml

После того, как вы сконфигурировали свой кластер и установили device плагин, можете в качестве ресурса указывать видеокарту.



На что это влияет? Допустим, у нас есть две ноды, физические машины. На одной есть видеокарта, на другой нет. Kubernetes обнаружит машину с видеокартой и поднимет наш pod именно на ней.

Важно заметить, Kubernetes не умеет грамотно шарить видеокарту между подами. Если у вас имеется 4 видеокарты, и вам для запуска контейнера требуется 1 GPU, то вы сможете поднять не более 4 подов на вашем кластере.

Мы берем за правило 1 Pod = 1 Модель = 1 GPU.

Есть вариант запускать на 4 видеокартах больше инстансов, но мы не будем рассматривать его в этой статье, так как этот вариант не идет из коробки.

Если на проде должно крутиться сразу несколько моделей, удобно под каждую модель создать Deployment в Kubernetes. В его конфигурационном файле можно прописать количество подов под каждую модель, с учетом популярности модели. Если на модель приходит много запросов, то под нее соответственно нужно указать много подов, если мало запросов — мало подов. Суммарно количество подов должно равняться количеству видеокарт в кластере.

Рассмотрим интересный момент. Допустим у нас есть 4 видеокарты и 3 модели.


На двух первых видеокартах пусть поднимется inference модели распознавания лиц, еще на одной распознавание объектов и на другой распознавание автомобильных номеров.

Вы работаете, клиенты приходят и уходят, и однажды, например ночью, возникает ситуация, когда видеокарта с inference объектов просто не загружена, на неё приходит мизерное количество запросов, а видеокарты с распознаванием лиц перегружены. Хотелось бы потушить в этот момент модель с объектами и на ее месте запустить лица, чтобы разгрузить очереди.

Для автоматического масштабирования моделей по видеокартам существуют инструменты внутри Kubernetes — горизонтальное автомасштабирование подов (HPA, horizontal pod autoscaler).
Из коробки Kubernetes поддерживает автомасштабирование по загрузке процессора. Но в задаче с видеокартами будет гораздо разумнее для масштабирования использовать информацию о количестве задач к каждой модели.

Мы делаем так: складываем запросы к каждой модели в очередь. Когда запросы выполнены, мы их из этой очереди удаляем. Если мы успеваем быстро обрабатывать запросы к популярным моделям, то очередь не растет. Если же количество запросов к конкретной модели вдруг увеличивается, то очередь начинает расти. Становится понятно, что нужно добавлять видеокарты, которые будут помогать разгребать очередь.

Информацию об очередях мы прокидываем в HPA через Prometheus:



И затем делаем в кластере автомасштабирование моделей по видеокартам в зависимости от количества запросов к ним.

CI/CD


После того, как вы контейнировали приложение и завернули его в Kubernetes, вам остается буквально один шаг до вершины развития проекта. Можно добавить CI/CD, вот пример из нашего конвейера:



Здесь программист запушил новый код в мастер-ветку, после чего автоматически собирается Docker-образ с нашими демонами бэкендов, и прогоняются тесты. Если все галочки зеленые, то приложение заливается в тестовую среду. Если и в ней не будет никаких проблем, то далее можно без затруднений отправлять образ в эксплуатацию.

Заключение


В своей статье я затронул некоторые аспекты работы высоконагруженного сервиса, использующего GPU. Мы поговорили о способах уменьшения времени отклика сервиса, таких как:

  • подбор оптимальной архитектуры нейросети для уменьшения latency;
  • применения оптимизирующих фреймворков наподобие TensorRT.

Затронули вопросы увеличения пропускной способности:

  • использование батчинга картинок;
  • применение fix latency strategy, чтобы количество запусков inference уменьшалось, но каждый inference обрабатывал бы большее число картинок;
  • оптимизация data input pipeline с целью минимизации простоев GPU;
  • «борьба» с тротлингом процессора, вынос cpu-bound операций на другие серверы.

Взглянули на процесс развертывания приложения с GPU:

  • использование nvidia-docker внутри Kubernetes;
  • масштабирование на основе количества запросов и HPA (horizontal pod autoscaler).

Комментарии (13)


  1. tumbler
    25.10.2019 13:57
    +1

    Интересная статья. Вопрос: как вы "продукционализируете" модели? Например, алгоритмы из scikit-learn запускаются в питоне в 1 поток (имеется ввиду поток управления, numpy под капотом иногда параллелится по ядрам — думаю, того же tensorflow это тоже касается); пишете ли какие-то свои обвязки для распараллеливания? Используете ли готовые библиотеки? Как параллелится обработка входных данных?


    Другими словами, от исследовательского "я запилил модель в notebook" до "вот смотрите как у нас круто на проде" проходит (наверно) много времени и технической работы программистов по внедрению "ноутбука" в продакшн. Поделитесь опытом?


    1. o2gy Автор
      25.10.2019 16:42
      +2

      Да, результат работы исследователя — это файлик модели и скрипт, который ее запускает (на том же питоне или, скажем, луа). На этом этапе модель еще очень далека от продакшена. Как правило, скрипт принимает на вход картинку, а на выход выдает некий тензор или просто число.

      Чтобы эту модель было возможно (и удобно) эксплуатировать в продакшене, нужно, как минимум:
      — написать для нее «человеческое» HTTP API, которое будет возвращать вместо тензоров и весов названия классов («собака», «кошка»), вероятности или что-то подобное;
      — настроить очереди задач и мониторинги для новой модели;
      — если исследователь использует фреймворк, о котором vision еще не знает, то предстоит написать оболочку вокруг этого фреймворка и встроить ее в vision;
      — полностью переписать скрипт исследователя, т.к. vision для запуска нейросетей использует С++ код;

      Насчет фреймворков и оболочек над ними. Нейросетевых фреймворков существует довольно много — некоторые удобны для исследователей, у некоторых богаче набор слоев, у некоторых — быстрый инференс. Vision, навскидку, умеет работать с torch, pytorch, caffe, caffe2, tensorflow, но абстракции верхнего уровня мало что знают о конкретных реализациях — это позволяет иметь один и тот же код для работы с очередями задач, метриками, изображениями и прочей обвязкой.


  1. navion
    25.10.2019 14:34

    Стоит зайти в BIOS и выбрать режим performance. Тогда процессор будет всё время работать на максимальной частоте.

    Тем самым вы отключите Turbo Boost и процессор всегда будет работать на номинальной частоте. В общем случае лучше выставить такие настройки энергосбережения:
    Скрытый текст
    Power & Performance:
    CPU Power and Performance Policy: Balanced Performance
    Workload Configuration: Balanced

    Power & Performance > CPU P State Control:
    Enhanced Intel Speed Step: Enabled
    Intel Turbo Boost Technology: Enabled
    Energy Effecient Turbo: Disabled

    Power & Performance > CPU C State Control:
    CPU C-State: Enabled
    C1E Autopromote: Enabled
    Processor C3: Enabled
    Processor C6: Enabled

    System Acoustic and Performance Configuration:
    Set Fan Profile: Performance


    1. dslimp
      25.10.2019 20:12

      C-State настоятельно не рекомендую включать, по практике тех же HP будет плохо)


      1. navion
        25.10.2019 20:49

        C-стейты не любит всякий рилтайм, где критична равномерность выполнения кода, но там и P-стейтам не место.


    1. o2gy Автор
      26.10.2019 21:49

      Тем самым вы отключите Turbo Boost и процессор всегда будет работать на номинальной частоте.

      можете немного раскрыть мысль? почему Turbo Boost отключается в режиме performance?


      1. navion
        29.10.2019 14:46

        Maximum Performance отключает C- и P-стейты, так что у ЦП почти не остаётся запаса по TDP и Turbo Boost считается как при всех загруженных ядрах — на Skylake он хоть как-то работает, а с Broadwell было совсем грустно.

        Подробно описано в книжке, главное не пугайтесь слова VMware в названии, там половину занимает общая теория, которая применима везде.


  1. fkvf
    26.10.2019 09:32

    А можете сказать какие масштабы инсталляции? Сколько видеокарт? Сколько видеокарт на одном сервере? Как часто выходят из строя видеокарты? И какими вы пользуетесь?


    1. o2gy Автор
      26.10.2019 22:15

      В настоящее время от сотни до двух сотен серверов. На сервере в подавляющем большинстве случаев 4 видеокарты.
      Я немного сомневаюсь на счет того, что могу указать тут прямо конкретные модели видеокарт, поэтому просто скажу — nvidia.
      Насчет выхода из строя, примерно так: один раз за два года в одной видеокарте сломались вентиляторы.
      И иногда, примерно раз в полгода, на некоторых видеокартах возникает ситуация, когда любое обращение к GPU провоцирует ошибку наподобие «GPU is lost. Reboot the system to recover this GPU» — на такие ошибки, конечно, должен быть настроен мониторинг.

      PS: кстати, насчет конкретных моделей видеокарт. разные поколения GPU поддерживают разные наборы команд. поэтому, в том числе, приходится собирать нейросетевые фреймворки «руками», вручную указывая набор поддерживаемых архитектур.


      1. fkvf
        27.10.2019 13:36

        Спасибо за ответ! Наша инсталляция примерно в шесть раз меньше. И мы изучаем наиболее подходящую платформу для видеокарт.
        Пока устаканились на 4 или 8 видеокарт на сервер. Ну и да, тоже nvidia.


        А про мониторинг, есть ли у вас в открытом доступе обертка над nvidia-smi? Или, скажите а как и какие метрики снимаете?


        1. AndrewSu
          27.10.2019 16:25

          обертка над nvidia-smi

          https://pypi.python.org/pypi/gpustat


        1. o2gy Автор
          27.10.2019 18:08

          На самом деле, сама nvidia-smi — обертка над библиотекой libnvidia-ml.so, которая является частью cuda.
          Она позволяет делать довольно сложные запросы к железу, например — вывести всякие важные показатели в формате csv, повторять раз в секунду:

          $ nvidia-smi --query-gpu=index,timestamp,power.draw,clocks.sm,clocks.mem,clocks.gr,utilization.gpu,utilization.memory,temperature.gpu --format=csv -l 1
          
          0, 2019/10/27 17:58:48.593, 17.28 W, 135 MHz, 405 MHz, 135 MHz, 0 %, 0 %, 36
          0, 2019/10/27 17:58:49.596, 17.37 W, 135 MHz, 405 MHz, 135 MHz, 0 %, 0 %, 36
          

          вывод этой команды можно ловить однострочником и складывать в графит или другую БД с метриками )

          А мы пока что, по историческим причинам, используем libnvidia-ml.so напрямую — делать это тривиально, а библиотека прекрасно документирована. Выглядит примерно так (проверка ошибок опущена):
          #include <nvml.h>
          
          nvmlInit();
          
          int device_id = 0;
          nvmlDevice_t device;
          nvmlDeviceGetHandleByIndex(id, &device);
          
          nvmlTemperatureSensors_t sensors = NVML_TEMPERATURE_GPU;
          unsigned int t = 0;
          nvmlDeviceGetTemperature(device, sensors, &t);  // в t записывается температура GPU

          Снимаем, насколько помню, как минимум температуру, память и утилизацию.


          1. fkvf
            28.10.2019 18:24

            А обязательно ли иметь установленную графическую оболочку для этого?

            Например, у нас не получается управлять кулерами на видеокарте, без графики

            DISPLAY=:0 XAUTHORITY=/var/run/lightdm/root/:0 /usr/bin/nvidia-settings -a [gpu:0]/GpuFanControlState=1 -a [fan:0]/GPUTargetFanSpeed=100