newsmode
search
Меню
arrow_back Назад

FastAPI – Hamel’s Blog - Hamel Husain

auto_awesomeКраткое саммари

Hamel Husain рассматривает FastAPI как веб-фреймворк на Python для развёртывания прототипов ML-моделей. По его впечатлениям, для небольших моделей (менее 200 МБ) FastAPI отлично подходит и оказывается быстрее TF Serving, хотя и менее стабилен; для крупных моделей и промышленных нагрузок лучше использовать специализированные фреймворки вроде TF Serving или TorchServe. Автор показывает практический пример: загрузку модели в формате SavedModel, создание приложения FastAPI с эндпоинтами health-check и /predict с использованием Pydantic, запуск через Uvicorn и нагрузочное тестирование. Память расходуется линейно с числом воркеров Uvicorn, а при запуске нескольких воркеров на GPU нужно ставить переменную окружения TF_FORCE_GPU_ALLOW_GROWTH=true, чтобы избежать ошибок OOM. В его конкретном случае увеличение числа воркеров не дало эффекта — вероятно, из-за низкой задержки используемой модели.

FastAPI is a web framework for Python. People like to use this framework for serving prototypes of ML models.

FastAPI — это веб-фреймворк для Python. Его любят использовать для обслуживания прототипов ML-моделей.

Impressions

Впечатления

  • Model serving frameworks (TF Serving, TorchServe, etc) are probably the way to go for production / enterprise deployments, especially for larger models. They offer more features, and latency will be more predictable (even if slower). I think that for smaller models (< 200MB) FastAPI is fine.
  • It is super easy to get started with FastAPI.
  • I was able to confirm Sayak’s Benchmark where FastAPI is faster than TF Serving, but also less consistent overall. FastAPI is also more likely to fail, although I haven’t been able to cause that. In my experiments FastAPI was much faster for this small model, but this could change with larger models.
  • Memory is consumed linearly as you increase the number of Uvicorn workers. Model serving frameworks like TF-Serving seem to work more efficiently. You should be careful to set the environment variable TF_FORCE_GPU_ALLOW_GROWTH=true if you are running inference on GPUs. I think in many cases you would be doing inference on CPUs, so this might not be relevant most of the time.
  • FastAPI seems like it could be really nice on smaller models and scoped hardware where there is only one worker per node and you load balance across nodes (because you aren’t replicating the model with each worker).
  • Debugging FastAPI is amazing, as its pure python and you get a nice docs page at http://<IP>/docs that lets you test out your endpoints right on the page! The documentation for FastPI is also amazing.
  • If you want the request parameters to be sent in the body (as you often do with ML b/c you want to send data to be scored), you have to use Pydantic. This is very opinionated, but easy enough to use.
  • Фреймворки для обслуживания моделей (TF Serving, TorchServe и т. д.), вероятно, и есть верный путь для продакшена / корпоративных развёртываний, особенно для крупных моделей. Они предлагают больше возможностей, а задержка будет более предсказуемой (пусть и более медленной). Думаю, что для небольших моделей (< 200 МБ) FastAPI вполне подходит. Начать работать с FastAPI очень легко. Мне удалось подтвердить бенчмарк Sayak, где FastAPI быстрее, чем TF Serving, но при этом в целом менее стабилен. FastAPI также с большей вероятностью даёт сбой, хотя мне не удалось его вызвать. В моих экспериментах FastAPI был намного быстрее на этой небольшой модели, но с более крупными моделями ситуация может измениться. Память расходуется линейно по мере увеличения числа воркеров Uvicorn. Фреймворки для обслуживания моделей вроде TF-Serving, похоже, работают эффективнее. Следует не забыть установить переменную окружения TF_FORCE_GPU_ALLOW_GROWTH=true, если вы выполняете инференс на GPU. Думаю, во многих случаях вы будете выполнять инференс на CPU, так что бóльшую часть времени это может быть неактуально. FastAPI выглядит действительно удобным для небольших моделей и ограниченного оборудования, где на узле всего один воркер, а балансировка нагрузки идёт между узлами (потому что вы не реплицируете модель с каждым воркером). Отладка FastAPI — это нечто потрясающее: это чистый Python, и вы получаете удобную страницу документации по адресу http:///docs, которая позволяет прямо на странице протестировать ваши эндпоинты! Документация по FastAPI тоже великолепна. Если вы хотите, чтобы параметры запроса передавались в теле (как это часто бывает в ML, ведь вы хотите отправить данные для оценки), вам придётся использовать Pydantic. Это весьма навязанный подход, но достаточно простой в использовании.

    Load Model & Make Predictions

    Загрузка модели и получение предсказаний

    Going to use the model trained in the TF Serving tutorial. Furthermore, we are going to load this from the SavedModel format.

    Будем использовать модель, обученную в руководстве по TF Serving. Более того, мы загрузим её из формата SavedModel.

    # this cell is exported to a script from fastapi import FastAPI, status from pydantic import BaseModel from typing import List import tensorflow as tf import numpy as np def load_model(model_path='/home/hamel/hamel/notes/serving/tfserving/model/1'): "Load the SavedModel Object." sm = tf.saved_model.load(model_path) return sm.signatures["serving_default"] # this is the default signature when you save a model

    # this cell is exported to a script from fastapi import FastAPI, status from pydantic import BaseModel from typing import List import tensorflow as tf import numpy as np def load_model(model_path='/home/hamel/hamel/notes/serving/tfserving/model/1'): "Load the SavedModel Object." sm = tf.saved_model.load(model_path) return sm.signatures["serving_default"] # this is the default signature when you save a model

    # this cell is exported to a script def pred(model: tf.saved_model, data:np.ndarray, pred_layer_nm='dense_3'): """ Make a prediction from a SavedModel Object. `pred_layer_nm` is the last layer that emits logits. https://www.tensorflow.org/guide/saved_model """ data = tf.convert_to_tensor(data, dtype='int32') preds = model(data) return preds[pred_layer_nm].numpy().tolist()

    # this cell is exported to a script def pred(model: tf.saved_model, data:np.ndarray, pred_layer_nm='dense_3'): """ Make a prediction from a SavedModel Object. `pred_layer_nm` is the last layer that emits logits. https://www.tensorflow.org/guide/saved_model """ data = tf.convert_to_tensor(data, dtype='int32') preds = model(data) return preds[pred_layer_nm].numpy().tolist()

    Test Data

    Тестовые данные

    _, (x_val, _) = tf.keras.datasets.imdb.load_data(num_words=20000) x_val = tf.keras.preprocessing.sequence.pad_sequences(x_val, maxlen=200)[:2, :]

    _, (x_val, _) = tf.keras.datasets.imdb.load_data(num_words=20000) x_val = tf.keras.preprocessing.sequence.pad_sequences(x_val, maxlen=200)[:2, :]

    Make a prediction

    Делаем предсказание

    model = load_model() pred(model, x_val[:2, :])

    model = load_model() pred(model, x_val[:2, :])

    [[0.8761785626411438, 0.12382148206233978], [0.0009457750129513443, 0.9990542531013489]]

    [[0.8761785626411438, 0.12382148206233978], [0.0009457750129513443, 0.9990542531013489]]

    Build The FastApi App

    Создаём приложение FastAPI

    # this cell is exported to a script app = FastAPI() items = {} @app.on_event("startup") async def startup_event(): "Load the model on startup https://fastapi.tiangolo.com/advanced/events/" items['model'] = load_model() @app.get("/") def health(status_code=status.HTTP_200_OK): "A health-check endpoint" return 'Ok'

    # this cell is exported to a script app = FastAPI() items = {} @app.on_event("startup") async def startup_event(): "Load the model on startup https://fastapi.tiangolo.com/advanced/events/" items['model'] = load_model() @app.get("/") def health(status_code=status.HTTP_200_OK): "A health-check endpoint" return 'Ok'

    We want to send the data for prediction in the Request Body (not with path parameters). According the docs:

    Мы хотим отправлять данные для предсказания в теле запроса (а не через параметры пути). Согласно документации:

    FastAPI will recognize that the function parameters that match path parameters should be taken from the path, and that function parameters that are declared to be Pydantic models should be taken from the request body.

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

    # this cell is exported to a script class Sentence(BaseModel): tokens: List[List[int]] @app.post("/predict") def predict(data:Sentence, status_code=status.HTTP_200_OK): preds = pred(items['model'], data.tokens) return preds

    # this cell is exported to a script class Sentence(BaseModel): tokens: List[List[int]] @app.post("/predict") def predict(data:Sentence, status_code=status.HTTP_200_OK): preds = pred(items['model'], data.tokens) return preds

    Recap: the FastAPI App

    Резюме: приложение FastAPI

    Let’s look at main.py with all the pieces combined:

    Давайте посмотрим на main.py со всеми собранными воедино частями:

    #This is a hack for Quarto for generated scripts from IPython.display import display, Markdown code = !cat main.py display(Markdown('```{.python filename="main.py"}\n' + '\n'.join(code) + '\n```'))

    #This is a hack for Quarto for generated scripts from IPython.display import display, Markdown code = !cat main.py display(Markdown('```{.python filename="main.py"}\n' + '\n'.join(code) + '\n```'))

    main.py

    main.py

    # AUTOGENERATED! DO NOT EDIT! File to edit: index.ipynb. # %% auto 0 __all__ = ['app', 'items', 'load_model', 'pred', 'startup_event', 'health', 'Sentence', 'predict'] # %% index.ipynb 3 from fastapi import FastAPI, status from pydantic import BaseModel from typing import List import tensorflow as tf import numpy as np def load_model(model_path='/home/hamel/hamel/notes/serving/tfserving/model/1'): "Load the SavedModel Object." sm = tf.saved_model.load(model_path) return sm.signatures["serving_default"] # this is the default signature when you save a model # %% index.ipynb 4 def pred(model: tf.saved_model, data:np.ndarray, pred_layer_nm='dense_3'): """ Make a prediction from a SavedModel Object. `pred_layer_nm` is the last layer that emits logits. https://www.tensorflow.org/guide/saved_model """ data = tf.convert_to_tensor(data, dtype='int32') preds = model(data) return preds[pred_layer_nm].numpy().tolist() # %% index.ipynb 10 app = FastAPI() items = {} @app.on_event("startup") async def startup_event(): "Load the model on startup https://fastapi.tiangolo.com/advanced/events/" items['model'] = load_model() @app.get("/") def health(status_code=status.HTTP_200_OK): "A health-check endpoint" return 'Ok' # %% index.ipynb 12 class Sentence(BaseModel): tokens: List[List[int]] @app.post("/predict") def predict(data:Sentence, status_code=status.HTTP_200_OK): preds = pred(items['model'], data.tokens) return preds

    # AUTOGENERATED! DO NOT EDIT! File to edit: index.ipynb. # %% auto 0 __all__ = ['app', 'items', 'load_model', 'pred', 'startup_event', 'health', 'Sentence', 'predict'] # %% index.ipynb 3 from fastapi import FastAPI, status from pydantic import BaseModel from typing import List import tensorflow as tf import numpy as np def load_model(model_path='/home/hamel/hamel/notes/serving/tfserving/model/1'): "Load the SavedModel Object." sm = tf.saved_model.load(model_path) return sm.signatures["serving_default"] # this is the default signature when you save a model # %% index.ipynb 4 def pred(model: tf.saved_model, data:np.ndarray, pred_layer_nm='dense_3'): """ Make a prediction from a SavedModel Object. `pred_layer_nm` is the last layer that emits logits. https://www.tensorflow.org/guide/saved_model """ data = tf.convert_to_tensor(data, dtype='int32') preds = model(data) return preds[pred_layer_nm].numpy().tolist() # %% index.ipynb 10 app = FastAPI() items = {} @app.on_event("startup") async def startup_event(): "Load the model on startup https://fastapi.tiangolo.com/advanced/events/" items['model'] = load_model() @app.get("/") def health(status_code=status.HTTP_200_OK): "A health-check endpoint" return 'Ok' # %% index.ipynb 12 class Sentence(BaseModel): tokens: List[List[int]] @app.post("/predict") def predict(data:Sentence, status_code=status.HTTP_200_OK): preds = pred(items['model'], data.tokens) return preds

    Run The App

    Запускаем приложение

    We can run the app with the command:

    Мы можем запустить приложение командой:

    uvicorn main:app --host 0.0.0.0 --port 5701

    uvicorn main:app --host 0.0.0.0 --port 5701

  • main corresponds to the file main.py
  • app corresponds to the app object inside main.py - app = FastAPI()
  • --reload: makes the server restart if the code changes, for development only
  • main соответствует файлу main.py app соответствует объекту app внутри main.pyapp = FastAPI() --reload: заставляет сервер перезапускаться при изменении кода, только для разработки

    import requests, json def predict_rest(json_data, url='http://localhost:5701/predict'): json_response = requests.post(url, json={'tokens': json_data}) return json.loads(json_response.text)

    import requests, json def predict_rest(json_data, url='http://localhost:5701/predict'): json_response = requests.post(url, json={'tokens': json_data}) return json.loads(json_response.text)

    predict_rest(x_val.tolist())

    predict_rest(x_val.tolist())

    [[0.8761785626411438, 0.12382148206233978], [0.0009457750129513443, 0.9990542531013489]]

    [[0.8761785626411438, 0.12382148206233978], [0.0009457750129513443, 0.9990542531013489]]

    Load Test FastAPI

    Нагрузочное тестирование FastAPI

    It’s really fast

    Это действительно быстро

    from fastcore.parallel import parallel from functools import partial parallel_pred = partial(parallel, threadpool=True, n_workers=500)

    from fastcore.parallel import parallel from functools import partial parallel_pred = partial(parallel, threadpool=True, n_workers=500)

    sample_data = [x_val.tolist()] * 1000

    sample_data = [x_val.tolist()] * 1000

    %%time results = parallel_pred(predict_rest, sample_data)

    %%time results = parallel_pred(predict_rest, sample_data)

    CPU times: user 2.29 s, sys: 252 ms, total: 2.54 s Wall time: 2.38 s

    CPU times: user 2.29 s, sys: 252 ms, total: 2.54 s Wall time: 2.38 s

    Adding Uvicorn Workers

    Добавляем воркеры Uvicorn

    Uvicorn also has an option to start and run several worker processes. Nevertheless, as of now, Uvicorn’s capabilities for handling worker processes are more limited than Gunicorn’s. So, if you want to have a process manager at this level (at the Python level), then it might be better to try with Gunicorn as the process manager.

    Uvicorn также имеет возможность запускать и работать с несколькими процессами-воркерами. Тем не менее на данный момент возможности Uvicorn по управлению процессами-воркерами более ограничены, чем у Gunicorn. Поэтому, если вы хотите иметь менеджер процессов на этом уровне (на уровне Python), возможно, лучше попробовать использовать Gunicorn в качестве менеджера процессов.

    You can add Uvicorn workers with the --workers flag:

    Вы можете добавить воркеры Uvicorn с помощью флага --workers:

    uvicorn main:app --host 0.0.0.0 --port 5701 --workers 8

    uvicorn main:app --host 0.0.0.0 --port 5701 --workers 8

    GPUs

    GPU

    When I scaled up to 8 workers on a GPU, I got OOM errors. In order to avoid this you want to Limit GPU memory growth by settting the TF_FORCE_GPU_ALLOW_GROWTH to true:

    Когда я масштабировался до 8 воркеров на GPU, я получил ошибки OOM (нехватки памяти). Чтобы этого избежать, нужно ограничить рост использования памяти GPU, установив TF_FORCE_GPU_ALLOW_GROWTH в значение true:

    TF_FORCE_GPU_ALLOW_GROWTH=true uvicorn main:app --host 0.0.0.0 --port 5701 --workers 8

    TF_FORCE_GPU_ALLOW_GROWTH=true uvicorn main:app --host 0.0.0.0 --port 5701 --workers 8

    From the docs:

    Из документации:

    By default, TensorFlow maps nearly all of the GPU memory of all GPUs (subject to CUDA_VISIBLE_DEVICES) visible to the process.

    По умолчанию TensorFlow занимает почти всю память GPU всех GPU (с учётом CUDA_VISIBLE_DEVICES), видимых процессу.

    This means if you are running on GPUs and have > 1 worker, you will get OOM workers without setting this env variable!

    Это означает, что если вы работаете на GPU и у вас > 1 воркера, вы получите воркеры с ошибками OOM, если не задать эту переменную окружения!

    %%time results = parallel_pred(predict_rest, sample_data)

    %%time results = parallel_pred(predict_rest, sample_data)

    CPU times: user 2.26 s, sys: 294 ms, total: 2.55 s Wall time: 2.34 s

    CPU times: user 2.26 s, sys: 294 ms, total: 2.55 s Wall time: 2.34 s

    Scaling up workers didn’t have any effect in this particular instance. Could be because the low latency of the model I’m using doesn’t challenge the throughput enough.

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