Basics – Hamel’s Blog - Hamel Husain
Заметки Hamel Husain о развёртывании моделей через TensorFlow Serving на примере трансформера для классификации отзывов IMDB. Автор обучает простую модель (657 758 параметров, точность ~86% на валидации), сохраняет её в формате SavedModel с двумя версиями и показывает, как запускать Docker-контейнер tensorflow/serving с монтированием каталога моделей. Описано версионирование моделей через конфигурационные файлы (model_config_list), позволяющее отдавать несколько версий одновременно и опрашивать конфиг каждые 60 секунд. Приведены примеры запросов через REST API (POST на /v1/models/model:predict) и gRPC с использованием Protocol Buffers, которые эффективнее для крупных нагрузок. Результаты обоих способов сравниваются с прямым вызовом model.predict через np.allclose и совпадают с точностью до 1e-4.
В этих заметках используется код отсюда и этот туториал по tf serving.
Создаём модель
Я не хотел использовать готовый файл модели из туториала по tfserving, поэтому создаю новую модель с нуля.
import tensorflow as tf from tensorflow import keras from tensorflow.keras import layers import os from train import get_model vocab_size = 20000 # Берём только топ-20k слов maxlen = 200 # Берём только первые 200 слов каждого отзыва embed_dim = 32 # Размер эмбеддинга для каждого токена num_heads = 2 # Число attention-голов ff_dim = 32 # Размер скрытого слоя в feed forward сети внутри трансформера
(x_train, y_train), (x_val, y_val) = keras.datasets.imdb.load_data(num_words=vocab_size) print(len(x_train), "Training sequences") print(len(x_val), "Validation sequences") x_train = keras.preprocessing.sequence.pad_sequences(x_train, maxlen=maxlen) x_val = keras.preprocessing.sequence.pad_sequences(x_val, maxlen=maxlen)
25000 Training sequences 25000 Validation sequences
get_model определён здесь
model = get_model(maxlen=maxlen, vocab_size=vocab_size, embed_dim=embed_dim, num_heads=num_heads, ff_dim=ff_dim)
Следует аккуратно указывать dtype для входного слоя, чтобы валидация API tfserving работала корректно. Вот так:
inputs = layers.Input(shape=(maxlen,), dtype='int32')
model.summary()
Model: "model" _________________________________________________________________ Layer (type) Output Shape Param # ================================================================= input_1 (InputLayer) [(None, 200)] 0 token_and_position_embeddin (None, 200, 32) 646400 g (TokenAndPositionEmbeddin g) transformer_block (Transfor (None, 200, 32) 10656 merBlock) global_average_pooling1d (G (None, 32) 0 lobalAveragePooling1D) dropout_2 (Dropout) (None, 32) 0 dense_2 (Dense) (None, 20) 660 dropout_3 (Dropout) (None, 20) 0 dense_3 (Dense) (None, 2) 42 ================================================================= Total params: 657,758 Trainable params: 657,758 Non-trainable params: 0 _________________________________________________________________
Обучение модели
model.compile(optimizer="adam", loss="sparse_categorical_crossentropy", metrics=["accuracy"]) history = model.fit( x_train, y_train, batch_size=32, epochs=2, validation_data=(x_val, y_val) )
Epoch 1/2 782/782 [==============================] - 49s 58ms/step - loss: 0.3977 - accuracy: 0.8056 - val_loss: 0.2856 - val_accuracy: 0.8767 Epoch 2/2 782/782 [==============================] - 19s 24ms/step - loss: 0.1962 - accuracy: 0.9258 - val_loss: 0.3261 - val_accuracy: 0.8608
Сохранение модели
Модели tensorflow можно сериализовать в формат SavedModel с помощью tf.saved_model.save(...). Формат описан здесь. Мы сохраняем две версии модели, чтобы обсудить возможности TF Serving по отдаче нескольких версий модели.
!rm -rf ./model
def save_model(model_version, model_dir="./model"): model_export_path = f"{model_dir}/{model_version}" tf.saved_model.save( model, export_dir=model_export_path, ) print(f"SavedModel files: {os.listdir(model_export_path)}") save_model(model_version=1) save_model(model_version=2)
WARNING:absl:Found untraced functions such as embedding_layer_call_fn, embedding_layer_call_and_return_conditional_losses, embedding_1_layer_call_fn, embedding_1_layer_call_and_return_conditional_losses, multi_head_attention_layer_call_fn while saving (showing 5 of 26). These functions will not be directly callable after loading.
INFO:tensorflow:Assets written to: ./model/1/assets
INFO:tensorflow:Assets written to: ./model/1/assets
SavedModel files: ['fingerprint.pb', 'variables', 'assets', 'saved_model.pb']
WARNING:absl:Found untraced functions such as embedding_layer_call_fn, embedding_layer_call_and_return_conditional_losses, embedding_1_layer_call_fn, embedding_1_layer_call_and_return_conditional_losses, multi_head_attention_layer_call_fn while saving (showing 5 of 26). These functions will not be directly callable after loading.
INFO:tensorflow:Assets written to: ./model/2/assets
INFO:tensorflow:Assets written to: ./model/2/assets
SavedModel files: ['fingerprint.pb', 'variables', 'assets', 'saved_model.pb']
Версионирование модели делается путём сохранения её в каталог с целым числом. По умолчанию обслуживаться будет каталог с наибольшим числом. Это можно изменить с помощью конфигурационных файлов.
!ls model/
1 2
Валидация схемы API
Вывод приведённой ниже команды покажет схему и форму входа, а также форму выхода API, которое мы создадим в tfserving.
Флаги ниже — это в основном шаблонные настройки. Я пока не до конца понимаю, что на самом деле означает signature.
!saved_model_cli show --dir ./model/2 --tag_set serve --signature_def serving_default
The given SavedModel SignatureDef contains the following input(s): inputs['input_1'] tensor_info: dtype: DT_INT32 shape: (-1, 200) name: serving_default_input_1:0 The given SavedModel SignatureDef contains the following output(s): outputs['dense_3'] tensor_info: dtype: DT_FLOAT shape: (-1, 2) name: StatefulPartitionedCall:0 Method name is: tensorflow/serving/predict
Запуск Docker-контейнера
Документация TFServing настойчиво рекомендует использовать docker. Но вместо этого можно использовать CLI tensorflow_model_server, который как раз и упакован в Docker-контейнер. Вот что говорит их документация:
Самый простой и прямой способ использования TensorFlow Serving — через Docker-образы. Мы настоятельно рекомендуем этот путь, если только у вас нет специфических требований, которые не покрываются запуском в контейнере.
СОВЕТ: Это также самый простой способ заставить TensorFlow Serving работать с поддержкой GPU.
Стоит взглянуть на Dockerfile для TFServing:
ENV MODEL_BASE_PATH=/models RUN mkdir -p ${MODEL_BASE_PATH} # Единственная обязательная вещь — имя модели, чтобы различать endpoints ENV MODEL_NAME=model RUN echo '#!/bin/bash \n\n\ tensorflow_model_server --port=8500 --rest_api_port=8501 \ --model_name=${MODEL_NAME} --model_base_path=${MODEL_BASE_PATH}/${MODEL_NAME} \ "$@"' > /usr/bin/tf_serving_entrypoint.sh \ && chmod +x /usr/bin/tf_serving_entrypoint.sh
это означает, что по умолчанию он ищет в /models/model. Это нужно учитывать при монтировании локальной директории модели в контейнер.
Допустим, моя локальная модель находится по пути /home/hamel/hamel/notes/serving/tfserving/model. Вот как тогда запускать Docker-контейнер:
docker run -p 8500:8500 \ --mount type=bind,source=/home/hamel/hamel/notes/serving/tfserving/model,target=/models/model \ --net=host -t tensorflow/serving
TFServing на GPU
См. заметку об использовании GPU в TF Serving.
Однако включать GPU имеет смысл, вероятно, только если вы собираетесь включить батчинг или если одно предсказание само по себе требует много ресурсов GPU (как Stable Diffusion).
Тестирование API
Согласно документации, статус нашей модели можно посмотреть так:
GET http://host:port/v1/models/${MODEL_NAME}, что для нас выглядит так:
curl https://localhost:8501/v1/models/model
! curl http://localhost:8501/v1/models/model
{ "model_version_status": [ { "version": "2", "state": "AVAILABLE", "status": { "error_code": "OK", "error_message": "" } } ] }
Обратите внимание, что по умолчанию отображается наибольший номер версии. Доступ к разным версиям модели можно получить через разные endpoints и подсунув подходящие конфигурационные файлы.
Версионирование модели
Модели, которые вы сохраняете в каталог, имеют номер версии — например, наша модель сохранена по пути home/hamel/hamel/notes/serving/tfserving/model с подкаталогами версий 1 и 2.
!ls /home/hamel/hamel/notes/serving/tfserving/model
1 2
По умолчанию TF Serving всегда отдаёт модель с наибольшим номером версии. Однако это можно изменить с помощью конфигурации сервера моделей. Можно также отдавать сразу несколько версий модели, навешивать метки на модели и т. д. Это, наверное, одна из самых полезных особенностей TF Serving. Вот несколько конфигов, позволяющих отдавать несколько версий одновременно:
%%writefile ./model/models.config model_config_list { config { name: 'model' base_path: '/models/model/' model_platform: 'tensorflow' model_version_policy: {all: {}} } }
Overwriting ./model/models.config
Если нужно задать конкретные модели для отдачи, можно указать имена версий вместо all, как здесь:
%%writefile ./model/models-specific.config model_config_list { config { name: 'model' base_path: '/models/model/' model_platform: 'tensorflow' model_version_policy { specific { versions: 1 versions: 2 } } } }
Overwriting ./model/models-specific.config
Чтобы сервер читал конфигурационные файлы, при запуске контейнера нужно передать дополнительные флаги:
docker run \ --mount type=bind,source=/home/hamel/hamel/notes/serving/tfserving/model,target=/models/model \ --net=host \ -t tensorflow/serving \ --model_config_file=/models/model/models-specific.config \ --model_config_file_poll_wait_seconds=60
Флаг --model_config_file_poll_wait_seconds=60 указывает серверу проверять наличие нового конфигурационного файла по этому пути каждые 60 секунд. Это необязательно, но, скорее всего, хорошая идея, чтобы можно было менять конфиг без перезагрузки сервера.
Чтобы обратиться к конкретной версии модели, нужно отправить запрос на
http://host:port/v1/models/${MODEL_NAME}[/versions/${VERSION}|/labels/${LABEL}]:predict. Например, для версии 1 endpoint будет http://localhost:8501/v1/models/model/versions/1:predict.
Если версия не важна и нужна просто наибольшая, можно использовать общий endpoint без указания версии — он по умолчанию отдаёт наибольшую версию:
http://localhost:8501/v1/models/model:predict
Проверить, что все версии доступны для отдачи, можно так:
! curl http://localhost:8501/v1/models/model/versions/2
{ "model_version_status": [ { "version": "2", "state": "AVAILABLE", "status": { "error_code": "OK", "error_message": "" } } ] }
! curl http://localhost:8501/v1/models/model/versions/1
{ "model_version_status": [ { "version": "1", "state": "AVAILABLE", "status": { "error_code": "OK", "error_message": "" } } ] }
TF Serving по умолчанию не делает все версии доступными — только последнюю (с наибольшим номером). Чтобы сразу было доступно несколько версий, нужно указать конфигурационный файл. В продакшене, вероятно, стоит использовать метки, чтобы URL оставались единообразными.
Запрос на предсказание
REST
Пора сделать запрос на предсказание. Сначала попробуем REST API; согласно документации, endpoint выглядит так. Обратите внимание, что v1 здесь — просто захардкоженная версия самого tfServing, а не версия модели:
POST http://host:port/v1/models/${MODEL_NAME}[/versions/${VERSION}|/labels/${LABEL}]:predict
import json, requests import numpy as np sample_data = x_val[:2, :] data = json.dumps( {"signature_name": "serving_default", "instances": sample_data.tolist()} ) url = "http://localhost:8501/v1/models/model:predict" # для версии 1 было бы "http://localhost:8501/v1/models/model/versions/1:predict" def predict_rest(json_data, url): json_response = requests.post(url, data=json_data) response = json.loads(json_response.text) rest_outputs = np.array(response["predictions"]) return rest_outputs rest_outputs = predict_rest(data, url)
rest_outputs
array([[0.94086391, 0.05913605], [0.00317052, 0.99682945]])
model_outputs = model.predict(sample_data)
1/1 [==============================] - 0s 210ms/step
Сравним это с выходом нашей модели. Достаточно близко :)
assert np.allclose(rest_outputs, model_outputs, rtol=1e-4)
gRPC
Формат полезной нагрузки для grpc использует Protocol Buffers, которые сжимаются лучше, чем JSON, что может уменьшить задержку. Это даёт эффект при больших размерах payload, например для изображений. У gRPC есть какая-то двунаправленная потоковая передача, тогда как REST — это просто модель ответ/запрос. Я не знаю, что это значит. gRPC использует более новый протокол HTTP, чем REST. Я не знаю, что это значит.
import grpc # Создаём канал, который будет подключён к gRPC-порту контейнера channel = grpc.insecure_channel("localhost:8500")
from tensorflow_serving.apis import predict_pb2, prediction_service_pb2_grpc stub = prediction_service_pb2_grpc.PredictionServiceStub(channel)
# Получаем ключ serving_input loaded_model = tf.saved_model.load(model_export_path) input_name = list( loaded_model.signatures["serving_default"].structured_input_signature[1].keys() )[0]
input_name
'input_1'
def predict_grpc(data, input_name, stub): # Создаём gRPC-запрос для предсказания request = predict_pb2.PredictRequest() # Указываем имя модели — в данном случае "model" request.model_spec.name = "model" # Указываем, какая сигнатура используется для формирования gRPC-запроса # здесь — дефолтная "serving_default" request.model_spec.signature_name = "serving_default" # Подставляем входные данные # tf.make_tensor_proto превращает тензор TensorFlow в Protobuf-тензор request.inputs[input_name].CopyFrom(tf.make_tensor_proto(data)) # Отправляем gRPC-запрос на TF Server result = stub.Predict(request) return result sample_data = tf.convert_to_tensor(x_val[:2, :], dtype='int32') grpc_outputs = predict_grpc(sample_data, input_name, stub)
Разбираем gRPC-ответ
Можно увидеть все поля, которые содержит gRPC-ответ. В нашей ситуации имя последнего слоя модели будет тем ключом, под которым лежат предсказания, — в данном случае это dense_3.
grpc_outputs
outputs { key: "dense_3" value { dtype: DT_FLOAT tensor_shape { dim { size: 2 } dim { size: 2 } } float_val: 0.9408639073371887 float_val: 0.059136051684617996 float_val: 0.0031705177389085293 float_val: 0.9968294501304626 } } model_spec { name: "model" version { value: 2 } signature_name: "serving_default" }
Имя последнего слоя модели можно получить и так:
loaded_model.signatures["serving_default"].structured_outputs
{'dense_3': TensorSpec(shape=(None, 2), dtype=tf.float32, name='dense_3')}
Изменение формы ответа
shape = [x.size for x in grpc_outputs.outputs['dense_3'].tensor_shape.dim] grpc_preds = np.reshape(grpc_outputs.outputs['dense_3'].float_val, shape) grpc_preds
array([[0.94086391, 0.05913605], [0.00317052, 0.99682945]])
Предсказания достаточно близки. Не знаю, почему они не совпадают в точности.
assert np.allclose(model_outputs, grpc_preds,rtol=1e-4)