- BrainTools - https://www.braintools.ru -

Векторный поиск: деплой без GPU на Triton Inference Server

Перед каждым ML-разработчиком, после обучения [1] прекрасной модели, которая поднимет бизнесу метрики, а тебе годовую премию, возникает вопрос: а как ее задеплоить в сервисе, чтоб она начала работать?

А ты пошел отдыхать.

Векторный поиск: деплой без GPU на Triton Inference Server - 1

При этом модель должна не просто работать, а стабильно и быстро, плюс сервис должен иметь широкий набор инфраструктурных функций из коробки! С метриками, конечно же… Иначе как отследить ту невероятную пользу, которую наносит твоя модель? 

Все эти задачи решает NVIDIA Triton Inference Server – это опенсорс платформа для инференса моделей машинного обучения, и она успешно отвечает на все эти запросы. Что самое характерное, в некоторых случаях даже код писать не придется, песня же, а? 

Но обо всем по порядку, меня зовут Чернышев Стас, я ведущий ML-разработчик в команде поиска MAGNIT OMNI. Начнем с идеи и как мы ее воплотили в жизнь, а потом в технической секции залезем в конфиги и посмотрим, как это все настраивается.

Почему Triton?

До сих пор огромное количество моделей просто оборачивают в REST подобные фреймворки, ярким представителем которых является FastAPI, и это нормальное решение, многие с ним знакомы, хорошая документация, много туториалов. Но есть и минусы: у него отсутствуют специализированные инфраструктурные штуки из коробки:

  • gRPC

  • батчинг входных запросов

  • кэш

  • health check

  • метрики

Все это нужно писать вручную, разбираться, как работает, а затем поддерживать. Кроме того, если вы запустили свой сервис на CPU, то для перехода на GPU инференс нужно серьезно поработать с драйверами для вашего образа в Dockerfile. Но главный недостаток – скорость, на CPU даже инференс одного запроса занимал ~30 мс, что нас категорически не устраивало.

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

FastAPI

BentoML

Triton

Динамический батчинг

вручную

есть

есть

Кэш ответов

вручную

нет

есть (локальный / Redis)

hRPC

вручную

только в старых версиях API

есть

Метрики Prometheus

вручную

есть

есть

Инференс без Python

нет

нет

есть (нативные бэкенды)

BentoML удобен, но инференс происходит в Python-процессах со всеми вытекающими в виде GIL и накладных расходов на каждый запрос. Даже сами разработчики BentoML для ускорения предлагают запускать внутри него Triton. Ну и кэш-ответов: у нас три четверти запросов повторяются, так что кэш из коробки был обязательным пунктом.

Архитектура модели

Прежде чем рассказать, как мы деплоили модель, коротко расскажу, ЧТО мы деплоили :)

В качестве базовой модели эмбеддингов мы используем BERTA — компактную русскоязычную модель, полученную дистилляцией эмбеддингов FRIDA от SaluteDevices. 

Модель наследует от FRIDA систему префиксов — в частности, “search_query:” и “search_document:” для асимметричного поиска. По этому критерию мы разделили функционал на две независимых сервиса: эмбеддер запросов и документов. Для интересующихся оставлю ссылку тут – https://huggingface.co/sergeyzh/BERTA [2]

Делим на два сервиса

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

Эмбеддер запросов работает в онлайне, пользователь вводит запрос, а мы должны быстро вернуть его эмбеддинг. Поэтому первый приоритет – скорость ответа. 

Эмбеддинги товаров обновляются при изменении описания товара или модели векторного поиска – тут важнее пропускная способность.

Отказались от Python (почти)

На Python мы оставили только токенизацию — это маленькая модель на Python backend, которая добавляет префикс, зовет токенизатор и передает тензоры дальше. А само тело модели сконвертировали в ONNX и отдали нативному onnxruntime backend (полный список поддерживаемых бэкендов тут [3]). Кстати, это тот самый случай, когда код писать не придется: кладем в репозиторий моделей веса (model.onnx) и config.pbtxt рядом, остальное Triton сделает сам. Это сразу дало серьезное ускорение по сравнению со стандартной реализацией инференса:

Как получали эмбеддинг сразу после обучения:

from sentence_transformers import SentenceTransformer

model_name = "your_best_model_checkpoint"
model = SentenceTransformer(model_name)
query = "search_query: молоко ультрапастеризованное 2.5%"
embedding = model.encode(query)`

И еще одна деталь: модель обучена с matryoshka-лоссом, поэтому первые 192 координаты 768-мерного эмбеддинга сохраняют почти все качество. Наружу мы отдаем именно их, предварительно отнормировав. Вектор занимает в 4 раза меньше места в хранилище, и поиск кандидатов работает быстрее.

Теперь схема моделей в сервисе выглядит следующим образом:

Схема моделей в сервисе

Схема моделей в сервисе

Основная часть готова, осталось подобрать оптимальные параметры работы, об этом подробнее ниже.

Техническая секция: пишем конфиг

Первое, на что стоит обратить внимание [4], при настройке модели – встроенный кэш, он может быть локальным, а может использовать удаленный Redis.

Включается в config.pbtxt модели:

response_cache {
  enable: true
}

А при старте сервера задается общий размер кэша для всех моделей. Ниже пример, как задать такой параметр:

tritonserver --model-repository ... --cache-config local,size=3221225472

Для Redis вместо local указываем –cache-config redis,host=<host> и –cache-config redis,port=<port>.

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

Второе — это батчевание: можно “копить” запросы в батчи и делать параллельный инференс для элементов батча. Задается в config.pbtxt конфиге как-то так:

max_batch_size: 8
dynamic_batching {
  preferred_batch_size: [ 2, 3 ]
  max_queue_delay_microseconds: 3000  # сколько максимум ждем, собирая батч
}

Третье — это количество экземпляров модели. Triton Inference Server позволяет одним изменением параметра создавать два независимых экземпляра модели, в этом же блоке можно выбрать, на чем будет происходить инференс:

instance_group [
  {
    count: 2
    kind: KIND_CPU
  }
]

Отдельно хочу рассказать про параметры для onnxruntime backend.

parameters { key: "intra_op_thread_count" value: { string_value: "5" } }
parameters { key: "inter_op_thread_count" value: { string_value: "1" } }

intra_op_thread_count — сколько потоков onnxruntime использует для параллельного вычисления одного оператора (матричные умножения и т.п.), по сути — сколько ядер получит один экземпляр модели. 

inter_op_thread_count — потоки для параллельного выполнения независимых операторов графа; у нас режим выполнения последовательный, поэтому 1. Итого при двух экземплярах: 2 × 5 = 10 ядер под инференс из 12 доступных, остаток забирают токенизация и сам Triton. Важно! Если суммарно потоков окажется больше, чем фактически выделено или имеется, сервис начнет тротлить: latency вырастет, а следом подтянутся и ошибки [5] по таймаутам на клиентах.

Бенчмарки

В нашем случае описанное решение оказалось быстрее FastAPI приблизительно в 3 раза и чуть меньше — относительно Triton, но с инференсом целиком на Python backend.

Вообще же для сервиса запросов, задеплоенного на 12 ядрах и двух подах (=24 ядра), мы уже получили цифры, которых оказалось достаточно, чтобы не задействовать дорогостоящие решения на GPU:

Время

Concurrency

RPS

Avg (клиент), мс

р50, мс

р90, мс

р99, мс

Avg (сервер), мс

Cache Hit, %

Errors

8:45

10

212.8

47

41

58

64

12.6

57

0

8:47

20

424.3

47

42

61

74

13.6

65

0

8:49

30

614.3

48

43

64

87

19

72

0

8:51

40

800.6

50

44

68

90

22.9

75

0

8:53

50

983.1

50

45

73

95

27.4

76

0

8:55

60

1145.9

52

45

79

100

33.4

77

0

8:57

70

1282.5

54

45

87

115

43.9

78

0

8:59

80

1389.2

57

46

100

134

58.9

79

0

9:01

90

1483.4

60

46

118

150

87

79

0

9:03

100

1527.7

65

45

142

179

110

79

0

9:04

105

1571.1

66

45

149

188

124

79

0

Клиентские колонки замерялись через ghz с ноутбука и включают сетевой overhead. Avg (сервер) — средняя задержка запросов, дошедших до модели: кэш-хиты в нее не входят, они отдаются за ~0.007 мс. Поэтому при инференсе в 124 мс клиент в среднем видит 66 мс.

Тестирование эмбеддера документов проводилось на таких же двух подах с 12 ядрами. Тексты тут заметно длиннее — среднее описание товара ~136 символов против 1-3 слов в запросе, зато нет таких жестких требований по времени ответа:

Время

Concurrence

RPS

Avg (клиент), мс

р50, мс

р90, мс

р99, мс

Errors

15:45

70

119.5

579

562

897

1128

0

15:50

90

141.4

629

601

941

1170

0

15:54

110

150.7

719

703

1014

1529

0

15:58

130

149.8

854

845

1243

1598

0

16:02

150

146.5

1006

973

1467

1921

0

16:06

170

147.3

1132

1084

1634

2012

0

16:10

190

145.2

1283

1290

1820

2320

0

Как выбрать образ tritonserver или даже собрать самому под свои нужды

В зависимости от того, какими backend будем пользоваться, и на какой платформе (CPU или GPU) инференсить, подойдут разные версии образа. Они будут отличаться по весу и напрямую влиять на скорость поднятия сервиса — ведь его еще нужно спуллить из хранилища. Список преподготовленных образов можно найти в каталоге NGC [6]: полный -py3, минимальный -py3-min и другие.

Так же можно самому собрать образ в необходимом конфиге: клонируем репозиторий triton-inference-server/server [7] на ветке нужного релиза и запускаем скрипт build.py [8] с необходимыми флагами (все подробности — в документации по сборке [9]). Вот так выглядит пример сборки CPU-only образа под наш стек — python и onnxruntime бэкенды, gRPC + HTTP, метрики и локальный кэш:

git clone -b r26.02 https://github.com/triton-inference-server/server.git
cd server
./build.py -v 
    --enable-logging --enable-stats --enable-metrics --enable-cpu-metrics 
    --endpoint=grpc --endpoint=http 
    --backend=python --backend=onnxruntime 
    --cache=local

Что получилось в итоге

Сервис на обычных CPU-ядрах держит >1000 RPS с нормальной скоростью, кэш забирает на себя три четверти работы модели, а почти весь «инфраструктурный код» — это пара config.pbtxt. В нашем случае дорогие GPU так и не понадобились. Так что если ваша модель всё ещё завёрнута в FastAPI, тогда Triton идет к вам.

Модель задеплоена, метрики капают, невероятная польза наносится — можно наконец пойти отдыхать. Ну а годовая премия… посмотрим по метрикам.

P.S. В статье намеренно опущены подробные шаги по тому, как завернуть свою модель в Triton, постарался сфокусироваться на настройке и оптимизации. При первичном изучении технологии мне очень помогла эта статья на Хабре [10].

Автор: Magnit_OMNI

Источник [11]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/36019

URLs in this post:

[1] обучения: http://www.braintools.ru/article/5125

[2] https://huggingface.co/sergeyzh/BERTA: https://huggingface.co/sergeyzh/BERTA

[3] тут: https://github.com/triton-inference-server/backend

[4] внимание: http://www.braintools.ru/article/7595

[5] ошибки: http://www.braintools.ru/article/4192

[6] каталоге NGC: https://catalog.ngc.nvidia.com/orgs/nvidia/containers/tritonserver

[7] репозиторий triton-inference-server/server: https://github.com/triton-inference-server/server

[8] build.py: http://build.py

[9] документации по сборке: https://github.com/triton-inference-server/server/blob/main/docs/customization_guide/build.md

[10] эта статья на Хабре: https://habr.com/ru/articles/717890/

[11] Источник: https://habr.com/ru/companies/magnit/articles/1083418/?utm_campaign=1083418&utm_source=habrahabr&utm_medium=rss

www.BrainTools.ru

Rambler's Top100