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

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

В этой статье обсуждаются компромиссы между гибкостью и производительностью, скоростью разработки и долгосрочной надежностью в контексте разработки протоколов обслуживания машинного обучения. Мы также проиллюстрируем идеи с помощью одного из вариантов подачи, которые мы используем в Bumble Inc.

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

Быстрый старт для небольших работ

В настоящее время наиболее популярными протоколами являются REST на основе JSON. Эти протоколы обеспечивают легкий доступ к службам для самых разных сред. Кому-то достаточно простого веб-браузера, чтобы начать вызывать службу. Содержимое JSON можно редактировать без каких-либо проблем. Эти функции делают протоколы JSON RESTful очень популярными в общедоступных службах машинного обучения, а также в быстрых прототипах. У OpenAI есть хорошие примеры такого API. Чтобы выполнить вывод по текстовому предложению, достаточно выполнить следующую команду:

curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
     "model": "gpt-3.5-turbo",
     "messages": [{"role": "user", "content": "Why is REST so popular?"}],
     "temperature": 0.7
   }'

Напротив, бинарные протоколы часто требуют специализированных SDK для совершения вызовов и проверки трафика. Эта дополнительная техническая сложность может стать настоящим препятствием для пользователей, которые ищут совершенно новый крутой AI API. Часто выигрывают простые в использовании API.

Обслуживание числовых функций

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

Например, есть функция int32 с равномерно распределенными значениями. Текстовое представление значений этой функции займет в 3 раза больше места, чем двоичное int32. Чтобы проиллюстрировать это, давайте возьмем 1024 значения для функции и сохраним их в массиве numpy:

a = np.random.randint(np.iinfo(np.int32).max, dtype=np.int32, size=1024)

Объем памяти составляет около 4 КБ (4 байта на одну точку данных). Чтобы передать этот массив numpy в текстовом виде, целые значения сериализуются в строки. Длина строки является случайной величиной со средним значением 9,48 и стандартным отклонением 0,60 (статистика рассчитывается для выборок значений int32, преобразованных в строки). Размер передачи увеличивается до ~ 10 КБ:

(1024 utf-8 symbols * 9.48 avg length + 1023 separators) * 1 byte ~ 10.48 KB

Конечно, можно включить сжатие трафика, и разница может быть не столь критична, но все же сжатие потребляет процессор и способствует увеличению латентности.

Обслуживание двоичных файлов

Как правило, текстовые протоколы становятся менее эффективными по мере роста объема передаваемых данных. Это становится особенно проблематичным в службах компьютерного зрения и распознавания речи, которые передают много двоичных данных.

Если мы хотим включить изображение в сообщение JSON REST, двоичный файл должен быть закодирован в формате base64. Это, в свою очередь, увеличивает трафик в 1,5–2 раза по сравнению с некодированным размером изображения. Обходной путь для протоколов RESTful — передача изображения в виде двоичного MIME-типа (например, image/png). Если есть какие-либо другие входные параметры, они помещаются в POST multipart/form-data. Кроме того, они передаются как объект JSON в составном HTTP-запросе (application/json). Это делает протокол связи менее интуитивным и более громоздким.

Бинарный протокол был бы естественной альтернативой. Например, протоколы на основе protobuf позволяют просто сериализовать изображение JPEG в массив uint8 или байтов без потери гибкости передачи других данных внутри того же сообщения.

Валидация как часть протокола

Проверка входных данных в MLOps и разработка программного обеспечения в более широком смысле.

В информатике есть популярная фраза: Мусор на входе и мусор на выходе. Это выражает идею о том, что неправильный или некачественный ввод приведет к ошибочному выводу. Инженеры-программисты уделяют много внимания проверке правильности входных данных алгоритма. Неверные данные вызывают сбои в классическом программном обеспечении и делают приложения непригодными для использования. Напротив, неверные данные в системах ML часто приводят к снижению показателей качества без мгновенно видимых ошибок, что делает добавление проверки ввода на более ранних этапах разработки еще более важным.

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

Проверка ввода противостоит искажению обслуживания

В сообществе MLOps естественной и широко обсуждаемой причиной перекоса является простое, но фундаментальное изменение фонового распределения данных и меток. Существуют различные варианты этого в форме распределения данных/дрейфа концепций или ковариантного сдвига.

Однако смещение дистрибутива может произойти и из-за человеческих ошибок/ошибок в коде.

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

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

Глядя на текстовые и двоичные протоколы с этой точки зрения, мы видим, что связь на основе protobuf обычно безопаснее. Список функций ML может быть явно определен в протоколе.

message UserFeatures {
    uint8 age = 1,
    Int64 last_visited_timestamp = 2,
    bool is_verified = 3
}

Клиенты не смогут передать функцию, которая неизвестна службе, или установить для поля неправильный тип. Чтобы добиться того же в протоколе на основе JSON, нам нужно поддерживать схему JSON и запускать проверку объекта json по этой схеме для каждого вызова. Пример отдельной схемы проверки показан ниже:

{
 "type": "object",
 "required": [
  "age",
  "height",
  "is_verified"
 ],
 "properties": {
  "age": {
   "$id": "#root/age", 
   "type": "integer"
  },
  "height": {
   "$id": "#root/height", 
   "type": "number"
  },
  "is_verified": {
   "$id": "#root/is_verified", 
   "type": "boolean"
  }
 }
}

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

Пример компромисса KServe GRPC

Команда KServe предлагает третий вариант. KServe GRPC 2.0 разработан таким образом, что он остается относительно универсальным, но при этом обладает преимуществами производительности GRPC.

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

message ModelInferRequest
{
  // An input tensor for an inference request.
  message InferInputTensor
  {
    // The tensor name.
    string name = 1;
    // The tensor data type.
    string datatype = 2;
    // The tensor shape.
    repeated int64 shape = 3;
    ...

    // The tensor contents using a data-type format. This field must
    // not be specified if "raw" tensor contents are being used for
    // the inference request.
    InferTensorContents contents = 5;
  }
  ...

  // The input tensors for the inference.
  repeated InferInputTensor inputs = 5;
}

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

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

В Bumble Inc. KServe GRPC 2.0 используется в контексте обслуживания моделей через сервер Triton. Спецификации типа ввода определяются как часть конфигурации сервера. Однако добавление дополнительных настраиваемых правил проверки может оказаться сложной задачей. По соображениям производительности внутренние системы обычно выигрывают от уровня проверки на стороне клиента. Этот уровень обычно интегрируется в клиентские библиотеки API, распространяемые сопровождающими API.

Внедрение протокола KServe GRPC в Bumble Inc.

Поскольку команда Bumble Inc. перенесла многие процессы разработки и производства, связанные с машинным обучением, на платформу KubeFlow, KServe стал нашей платформой по умолчанию для развертывания новых сервисов машинного обучения.

KServe поддерживает протоколы REST и GRPC. Протокол REST является параметром по умолчанию в KServe для простоты, как обсуждалось в начале этой статьи. GRPC также поддерживается во многих обслуживающих средах выполнения, но всегда рассматривается как второстепенный вариант. Среда выполнения пользовательской модели KServe python добавила GRPC совсем недавно в 0.10.0.

Наша команда не ограничивается ни REST, ни GRPC, а старается выбрать лучшее соответствие для задачи. Как правило, когда служба получает большой трафик или зависит от множества входных функций, мы выбираем GRPC. Во всех остальных случаях предпочтительным вариантом является REST.

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

Включение GRPC

Сложная часть GRPC в Kubeflow — это сеть. Чтобы дать вам представление о том, как проксируются и маршрутизируются запросы в кластере Kubernetes, давайте рассмотрим наглядный пример. На диаграмме, показанной ниже, запрос фактически проходит несколько шагов, прежде чем достигнет веб-сервера KServe:

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

Различные входные порты для трафика GRPC и HTTP

Входные порты открыты для протоколов прикладного уровня. Если порт 80 указан как HTTP, это позволяет прокси-серверу проверять коды ответов HTTP и действовать соответствующим образом. Однако любой трафик GRPC, проходящий через 80, рассматривается как искаженный.

Для приема запросов GRPC необходимо открыть соответствующие порты шлюза:

apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: knative-ingress-gateway
  namespace: knative-serving
spec:
  servers:
  - port:
      number: 80
      name: http
      protocol: HTTP
    hosts:
    - "*"
  - port:
      number: 9010
      name: grpc
      protocol: GRPC
    hosts:
    - "*"

Все нижестоящие экземпляры прокси-сервера envoy работают на уровне TCP и не блокируют трафик GRPC.

Один порт на службу KServe

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

В частности, служба логического вывода KServe может направлять входящий трафик только на один порт. Это означает, что GRPC и REST не могут работать вместе в одном экземпляре службы на разных портах. Это ограничение вряд ли будет устранено в ближайшее время по целому ряду причин, связанных с сетью и техническим дизайном KServe. Если кому-то нужно несколько протоколов для службы, есть два варианта обхода:

  • мультиплексирование (два протокола обслуживаются через один порт) или
  • поддержка отдельных экземпляров сервисов, по одному на каждый протокол

К счастью, в большинстве случаев нам не нужны два протокола в одном сервисе, и достаточно иметь сервис только для GRPC или только для REST.

Запросы на входящие порты 80 и 9090 перенаправляются на порт 8080 контейнера kserve. По умолчанию KServe предоставляет 8080 как HTTP/1. Это не работает для GRPC, и порт должен быть явно объявлен как HTTP/2 (GRPC работает через HTTP2) в манифесте:

containers:
  - image: <...>
    name: kserve-container
    ports:
      - name: h2c // http or http2
        protocol: TCP
        containerPort: 8080

Реализация KServe GRPC

KServe предоставляет нам множество сред выполнения обслуживания. В Bumble Inc. мы придерживаемся двух вариантов, которые охватывают как быстрые прототипы, так и зрелые производственные варианты использования.

Когда мы делаем быстрое доказательство концепции (PoC), в котором используются компоненты только для Python, мы обычно создаем пользовательские модели KServe. В конце концов, PoC можно заменить удобной для производства системой. Чтобы облегчить этот переход, мы разрабатываем протокол, не зависящий от реализации, который помогает сделать переход между PoC и более зрелой реализацией плавным.

Имея это в виду, нам может понадобиться включить GRPC для службы-прототипа. Это достигается так же просто, как это:

server = kserve.ModelServer(enable_grpc=True, grpc_port=8080, http_port=8085)
server.start(models=[…])

Конечная точка HTTP не может быть отключена, но перемещается на случайный свободный порт.

Когда PoC проходит начальную проверку, мы разрабатываем перенос модели на сервер Triton Inference, который также поддерживает KServe GRPC. В большинстве случаев нам просто нужно изменить адрес конечной точки, чтобы он указывал на сервер Triton, никаких других изменений протокола не требуется.

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

Подведение итогов

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

Вопросы? Не стесняйтесь обращаться ко мне в LinkedIn: https://www.linkedin.com/in/andrei-potapkin-32873b2b/

Отказ от ответственности. Ни одна из внешних ссылок не одобрена Bumble Inc. и используется только в качестве примера и того, чем автор счел полезным поделиться.