[Перевод] Как мы подняли производительность Tensorflow Serving на 70%
Tensorflow стал стандартной платформой для машинного обучения (ML), популярной как в индустрии, так и в научных исследованиях. Создано множество свободных библиотек, инструментов и фреймворков для обучения и обслуживания моделей ML. Проект Tensorflow Serving помогает обслуживать модели ML в распределённой среде продакшна.
Наш сервис Mux использует Tensorflow Serving в нескольких частях инфраструктуры, мы уже обсуждали использование Tensorflow Serving в кодировании видео по заголовкам. Сегодня сосредоточимся на методах, которые улучшают задержку за счёт оптимизации как на сервере прогнозирования, так и на клиенте. Прогнозы модели обычно являются «онлайновыми» операциями (на критическом пути запроса приложения), поэтому основные цели оптимизации — обрабатывать большие объёмы запросов с максимально низкой задержкой.
Tensorflow Serving обеспечивает гибкую серверную архитектуру для развёртывания и обслуживания моделей ML. Как только модель обучена и готова к использованию для прогнозирования, Tensorflow Serving требует экспортировать её в совместимый (servable) формат.
Servable — это центральная абстракция, которая обёртывает объекты Tensorflow. Например, модель может быть представлена в виде одного или нескольких объектов Servable. Таким образом, Servable являются базовыми объектами, которые клиент использует для выполнения вычислений. Размер Servable имеет значение: меньшие модели занимают меньше места, используют меньше памяти и быстрее загружаются. для загрузки и обслуживания с помощью Predict API модели должны быть в формате SavedModel.
Tensorflow Serving объединяет основные компоненты для создания сервера gRPC/HTTP, который обслуживает несколько моделей ML (или несколько версий), предоставляет компоненты мониторинга и настраиваемую архитектуру.
Посмотрим на базовые метрику задержки в производительности прогнозирования со стандартными настройками Tensorflow Serving (без CPU-оптимизации).
Во-первых, загрузим последний образ с хаба TensorFlow Docker:
docker pull tensorflow/serving:latest
В этой статье все контейнеры запускаются на хосте с четырьмя ядрами, 15 ГБ, Ubuntu 16.04.
Экспорт модели Tensorflow в формат SavedModel
Когда модель обучается с помощью Tensorflow, выходные данные можно сохранить как переменные контрольные точки (файлы на диске). Вывод выполняется непосредственно путём восстановления контрольных точек модели или на зафиксированном формате frozen graph (двоичный файл).
Для Tensorflow Serving этот frozen graph нужно экспортировать в формат SavedModel. В документации Tensorflow есть примеры экспорта обученных моделей в формат SavedModel.
Tensorflow также предоставляет множество официальных и исследовательских моделей в качестве отправной точки для экспериментов, исследований или для продакшна.
В качестве примера будем использовать модель глубокой остаточной нейросети (ResNet) для классификации набора данных ImageNet из 1000 классов. Загрузите предобученную модель ResNet-50 v2
, а конкретно вариант Channels_last (NHWC) в SavedModel: как правило, он лучше работает на CPU.
Скопируйте каталог модели RestNet в следующую структуру:
models/
1/
saved_model.pb
variables/
variables.data-00000-of-00001
variables.index
Tensorflow Serving ожидает численно упорядоченную структуру каталогов для версионирования. В нашем случае каталог 1/
соответствует модели версии 1, которая содержит архитектуру модели saved_model.pb
со снимком весов модели (variables).
Загрузка и обработка SavedModel
Следующая команда запускает сервер модели Tensorflow Serving в контейнере Docker. Чтобы загрузить SavedModel, необходимо смонтировать каталог модели в ожидаемый каталог контейнера.
docker run -d -p 9000:8500 \
-v $(pwd)/models:/models/resnet -e MODEL_NAME=resnet \
-t tensorflow/serving:latest
Проверка логов контейнеров показывает, что ModelServer запущен и готов обслуживать запросы вывода для модели resnet
в конечных точках gRPC и HTTP:
...
I tensorflow_serving/core/loader_harness.cc:86] Successfully loaded servable version {name: resnet version: 1}
I tensorflow_serving/model_servers/server.cc:286] Running gRPC ModelServer at 0.0.0.0:8500 ...
I tensorflow_serving/model_servers/server.cc:302] Exporting HTTP/REST API at:localhost:8501 ...
Клиент прогнозирования
Tensorflow Serving определяет схему API в формате protocol buffers (protobufs). Реализации клиента gRPC для API прогнозирования упаковываются как питоновский пакет tensorflow_serving.apis
. Нам понадобится ещё питоновский пакет tensorflow
для служебных функций.
Установим зависимости для создания простого клиента:
virtualenv .env && source .env/bin/activate && \
pip install numpy grpcio opencv-python tensorflow tensorflow-serving-api
Модель ResNet-50 v2
ожидает на входе данные тензоров с плавающей запятой в форматированной структуре данных channels_last (NHWC). Следовательно, входное изображение считывается с помощью opencv-python и загружается в массив numpy (высота × ширина × каналы) как тип данных float32. Скрипт ниже создаёт заглушку клиента прогнозирования и загружает данные JPEG в массив numpy, преобразует их в tensor_proto, чтобы сделать запрос на прогнозирование по gRPC:
#!/usr/bin/env python
from __future__ import print_function
import argparse
import numpy as np
import time
tt = time.time()
import cv2
import tensorflow as tf
from grpc.beta import implementations
from tensorflow_serving.apis import predict_pb2
from tensorflow_serving.apis import prediction_service_pb2
parser = argparse.ArgumentParser(description='incetion grpc client flags.')
parser.add_argument('--host', default='0.0.0.0', help='inception serving host')
parser.add_argument('--port', default='9000', help='inception serving port')
parser.add_argument('--image', default='', help='path to JPEG image file')
FLAGS = parser.parse_args()
def main():
# create prediction service client stub
channel = implementations.insecure_channel(FLAGS.host, int(FLAGS.port))
stub = prediction_service_pb2.beta_create_PredictionService_stub(channel)
# create request
request = predict_pb2.PredictRequest()
request.model_spec.name = 'resnet'
request.model_spec.signature_name = 'serving_default'
# read image into numpy array
img = cv2.imread(FLAGS.image).astype(np.float32)
# convert to tensor proto and make request
# shape is in NHWC (num_samples x height x width x channels) format
tensor = tf.contrib.util.make_tensor_proto(img, shape=[1]+list(img.shape))
request.inputs['input'].CopyFrom(tensor)
resp = stub.Predict(request, 30.0)
print('total time: {}s'.format(time.time() - tt))
if __name__ == '__main__':
main()
Получив на входе JPEG, работающий клиент выдаст такой результат:
python tf_serving_client.py --image=images/pupper.jpg
total time: 2.56152906418s
Результирующий тензор содержит прогноз в виде целочисленного значения и вероятности признаков.
outputs {
key: "classes"
value {
dtype: DT_INT64
tensor_shape {
dim {
size: 1
}
}
int64_val: 238
}
}
outputs {
key: "probabilities"
...
Для единственного запроса такая задержка неприемлема. Но ничего удивительного: бинарник Tensorflow Serving по умолчанию рассчитан на самый широкий диапазон оборудования для большинства случаев использования. Наверное, вы заметили в логах стандартного контейнера Tensorflow Serving такие строки:
I external/org_tensorflow/tensorflow/core/platform/cpu_feature_guard.cc:141] Your CPU supports instructions that this TensorFlow binary was not compiled to use: AVX2 FMA
Это указывает на бинарник TensorFlow Serving, работающий на платформе CPU, для которой он не был оптимизирован.
Сборка оптимизированного бинарника
Согласно документации Tensorflow, рекомендуется компилировать Tensorflow из исходников со всеми оптимизациями, доступными для CPU на хосте, где будет работать двоичный файл. При сборке специальные флаги позволяют активировать наборы инструкций CPU для конкретной платформы:
Набор инструкций | Флаги |
---|---|
AVX | --copt=-mavx |
AVX2 | --copt=-mavx2 |
FMA | --copt=-mfma |
SSE 4.1 | --copt=-msse4.1 |
SSE 4.2 | --copt=-msse4.2 |
Все поддерживаемые процессором | --copt=-march=native |
Клонируйте Tensorflow Serving определённой версии. В нашем случае это 1.13 (последняя на момент публикации этой статьи):
USER=$1
TAG=$2
TF_SERVING_VERSION_GIT_BRANCH="r1.13"
git clone --branch="$TF_SERVING_VERSION_GIT_BRANCH" https://github.com/tensorflow/serving
В dev-образе Tensorflow Serving для сборки используется инструмент Basel. Настраиваем его на конкретные наборы инструкций CPU:
TF_SERVING_BUILD_OPTIONS="--copt=-mavx --copt=-mavx2 --copt=-mfma --copt=-msse4.1 --copt=-msse4.2"
Если памяти мало, ограничьте потребление памяти в процессе сборки флагом --local_resources=2048,.5,1.0
. Информацию по флагам см. в справке Tensorflow Serving и Docker, а также в документации Bazel.
Создайте рабочий образ на базе имеющегося:
#!/bin/bash
USER=$1
TAG=$2
TF_SERVING_VERSION_GIT_BRANCH="r1.13"
git clone --branch="${TF_SERVING_VERSION_GIT_BRANCH}" https://github.com/tensorflow/serving
TF_SERVING_BUILD_OPTIONS="--copt=-mavx --copt=-mavx2 --copt=-mfma --copt=-msse4.1 --copt=-msse4.2"
cd serving && \
docker build --pull -t $USER/tensorflow-serving-devel:$TAG \
--build-arg TF_SERVING_VERSION_GIT_BRANCH="${TF_SERVING_VERSION_GIT_BRANCH}" \
--build-arg TF_SERVING_BUILD_OPTIONS="${TF_SERVING_BUILD_OPTIONS}" \
-f tensorflow_serving/tools/docker/Dockerfile.devel .
cd serving && \
docker build -t $USER/tensorflow-serving:$TAG \
--build-arg TF_SERVING_BUILD_IMAGE=$USER/tensorflow-serving-devel:$TAG \
-f tensorflow_serving/tools/docker/Dockerfile .
ModelServer настраивается с помощью TensorFlow-флагов для поддержки параллелизма. Следующие параметры настраивают два пула потоков для параллельной работы:
intra_op_parallelism_threads
- управляет максимальным количеством потоков для параллельного выполнения одной операции;
- используется для распараллеливания операций, имеющих подоперации, которые по своей природе независимы.
inter_op_parallelism_threads
- управляет максимальным количеством потоков для параллельного выполнения независимых операций;
- операции на Tensorflow Graph, которые независимы друг от друга и, следовательно, могут выполняться в разных потоках.
По умолчанию для обоих параметров установлено 0
. Это означает, что система сама выбирает соответствующее число, которое чаще всего означает один поток на ядро. Однако параметр можно вручную изменять для многоядерного параллелизма.
Затем запустите контейнер Serving аналогично предыдущему, на этот раз с образом Docker, собранным из исходников, и с флагами оптимизации Tensorflow для конкретного процессора:
docker run -d -p 9000:8500 \
-v $(pwd)/models:/models/resnet -e MODEL_NAME=resnet \
-t $USER/tensorflow-serving:$TAG \
--tensorflow_intra_op_parallelism=4 \
--tensorflow_inter_op_parallelism=4
Логи контейнеров больше не должны показывать предупреждения о неопределённом CPU. Без изменения кода на одном и том же запросе прогнозирования задержка снижается примерно на 35,8%:
python tf_serving_client.py --image=images/pupper.jpg
total time: 1.64234706879s
Увеличение скорости в клиенте прогнозирования
Можно ли ещё ускориться? Мы оптимизировали серверную часть для нашего CPU, но задержка более 1 секунды по-прежнему кажется слишком большой.
Так получилось, что существенный вклад в задержку вносит загрузка библиотек tensorflow_serving
и tensorflow
. Каждый ненужный вызов tf.contrib.util.make_tensor_proto
тоже добавляет доли секунды.
Вы спросите: «Разве нам не нужны пакеты TensorFlow Python, чтобы фактически делать запросы прогнозирования к серверу Tensorflow?» На самом деле, реальной необходимости в пакетах tensorflow_serving
и tensorflow
нет.
Как отмечалось ранее, API прогнозирования Tensorflow определяются как протобуферы. Следовательно, две внешние зависимости можно заменить на соответствующие заглушки tensorflow
и tensorflow_serving
— и тогда не нужно вытягивать на клиенте всю (тяжёлую) библиотеку Tensorflow.
Для начала избавьтесь от зависимостей tensorflow
и tensorflow_serving
и добавьте пакет grpcio-tools
.
pip uninstall tensorflow tensorflow-serving-api && \
pip install grpcio-tools==1.0.0
Клонируйте репозитории tensorflow/tensorflow
и tensorflow/serving
и скопируйте в клиентский проект следующие файлы protobuf:
tensorflow/serving/
tensorflow_serving/apis/model.proto
tensorflow_serving/apis/predict.proto
tensorflow_serving/apis/prediction_service.proto
tensorflow/tensorflow/
tensorflow/core/framework/resource_handle.proto
tensorflow/core/framework/tensor_shape.proto
tensorflow/core/framework/tensor.proto
tensorflow/core/framework/types.proto
Скопируйте эти файлы protobuf в каталог protos/
с сохранением оригинальных путей:
protos/
tensorflow_serving/
apis/
*.proto
tensorflow/
core/
framework/
*.proto
Для простоты, prediction_service.proto можно упростить для реализации только Predict RPC, чтобы не скачивать вложенные зависимости других RPC, указанных в службе. Вот пример упрощённого prediction_service.прото
.
Создайте питоновские реализации gRPC с помощью grpcio.tools.protoc
:
PROTOC_OUT=protos/
PROTOS=$(find . | grep "\.proto$")
for p in $PROTOS; do
python -m grpc.tools.protoc -I . --python_out=$PROTOC_OUT --grpc_python_out=$PROTOC_OUT $p
done
Теперь весь модуль tensorflow_serving
можно удалить:
from tensorflow_serving.apis import predict_pb2
from tensorflow_serving.apis import prediction_service_pb2
… и заменить сгенерированными протобуферами из protos/tensorflow_serving/apis
:
from protos.tensorflow_serving.apis import predict_pb2
from protos.tensorflow_serving.apis import prediction_service_pb2
Библиотека Tensorflow импортируется для использования вспомогательной функции make_tensor_proto
, которая нужна для обёртывания объекта python/numpy в качестве объекта TensorProto.
Таким образом, мы можем заменить следующую зависимость и фрагмент кода:
import tensorflow as tf
...
tensor = tf.contrib.util.make_tensor_proto(features)
request.inputs['inputs'].CopyFrom(tensor)
импортом протобуферов и построением объекта TensorProto:
from protos.tensorflow.core.framework import tensor_pb2
from protos.tensorflow.core.framework import tensor_shape_pb2
from protos.tensorflow.core.framework import types_pb2
...
# ensure NHWC shape and build tensor proto
tensor_shape = [1]+list(img.shape)
dims = [tensor_shape_pb2.TensorShapeProto.Dim(size=dim) for dim in tensor_shape]
tensor_shape = tensor_shape_pb2.TensorShapeProto(dim=dims)
tensor = tensor_pb2.TensorProto(
dtype=types_pb2.DT_FLOAT,
tensor_shape=tensor_shape,
float_val=list(img.reshape(-1)))
request.inputs['inputs'].CopyFrom(tensor)
Полный скрипт Python здесь. Запустите обновлённый начальный клиент, который делает запрос прогнозирования для оптимизированного Tensorflow Serving:
python tf_inception_grpc_client.py --image=images/pupper.jpg
total time: 0.58314920859s
На следующей диаграмме показана время выполнения прогноза в оптимизированной версии Tensorflow Serving по сравнению со стандартной, в течение 10 запусков:
Средняя задержка уменьшилась примерно в 3,38 раза.
Tensorflow Serving можно настроить для обработки большого объёма данных. Оптимизация пропускной способности обычно выполняется для «автономной» пакетной обработки, где жёсткие границы задержки не являются строгим требованием.
Пакетная обработка на стороне сервера
Как указано в документации, пакетная обработка на стороне сервера изначально поддерживается в Tensorflow Serving.
Компромиссы между задержкой и пропускной способностью определяются параметрами пакетной обработки. Они позволяют достичь максимальной пропускной способности, на которую способны аппаратные ускорители.
Чтобы включить пакетирование, установите флаги --enable_batching
и --batching_parameters_file
. Параметры устанавливаются в соответствии с SessionBundleConfig. Для систем на CPU установите num_batch_threads
на количество доступных ядер. Для GPU подходящие параметры см. здесь.
По заполнении целого пакета на стороне сервера запросы на выдачу объединяются в один большой запрос (тензор), и отправляются в сессию Tensorflow объединенным запросом. В такой ситуации по-настоящему задействуется параллелизм CPU/GPU.
Некоторые общие случаи применения пакетной обработки Tensorflow:
- Использование асинхронных клиентских запросов для заполнения пакетов на стороне сервера
- Ускорение пакетной обработки за счёт переноса компонентов графа модели на CPU/GPU
- Поочередное обслуживание запросов от нескольких моделей с одного сервера
- Пакетная обработка настоятельно рекомендуется для «автономной» обработки большого количества запросов
Пакетная обработка на стороне клиента
Пакетная обработка на стороне клиента группирует несколько входящих запросов в один.
Поскольку модель ResNet ожидает ввода в формате NHWC (первое измерение — количество входов), мы можем объединить несколько входных изображений в один запрос RPC:
...
batch = []
for jpeg in os.listdir(FLAGS.images_path):
path = os.path.join(FLAGS.images_path, jpeg)
img = cv2.imread(path).astype(np.float32)
batch.append(img)
...
batch_np = np.array(batch).astype(np.float32)
dims = [tensor_shape_pb2.TensorShapeProto.Dim(size=dim) for dim in batch_np.shape]
t_shape = tensor_shape_pb2.TensorShapeProto(dim=dims)
tensor = tensor_pb2.TensorProto(
dtype=types_pb2.DT_FLOAT,
tensor_shape=t_shape,
float_val=list(batched_np.reshape(-1)))
request.inputs['inputs'].CopyFrom(tensor)
Для пакета из N изображений выходной тензор в ответе будет содержать результаты прогнозирования для такого же количества входов. В нашем случае N = 2:
outputs {
key: "classes"
value {
dtype: DT_INT64
tensor_shape {
dim {
size: 2
}
}
int64_val: 238
int64_val: 121
}
}
...
Несколько слов о графических процессорах.
Процесс обучения натурально использует распараллеливание на GPU, поскольку построение глубоких нейронных сетей требует массивных вычислений для достижения оптимального решения.
Но для вывода результатов распараллеливание не так очевидно. Зачастую можно ускорить вывод нейросети на GPU, но требуется тщательно выбрать и протестировать оборудование, провести углубленный технический и экономический анализ. Аппаратное распараллеливание более ценно для пакетной обработки «автономных» выводов (массивных объёмов).
Прежде чем переходить на GPU, рассмотрите бизнес-требования с тщательным анализом затрат (денежные, операционные, технические) на сулимую выгоду (уменьшение задержки, высокая пропускная способность).