На прошлой неделе вышла бета-версия нового FastAPI 0.100-beta1, а это значит что? Правильно, пришло время performance-тестов!

Изменения

Главное изменение в новой версии FastAPI - это переход на новую версию библиотеки Pydantic v2.0b3 - вся логика валидации была переписана на языке Rust. Для Pydantic обещают увеличение производительности в 5-50x раз! Ну что же, посмотрим, как это скажется на скорости FastAPI в целом. Других изменений в версии 0.100-beta1 в release notes не указано.

Для Pydantic же

Подготовка тестового стенда

Мы, веб-девелоперы, нелюбим CRUD-ды, вот на нем давайте и протестируем. Чтобы хотя бы попытаться приблизиться к реальному приложению, на каждый запрос клиента будет работать с SQLAlchemy моделью, обращаясь к базе данных.

Весь основной код доступен на гитхабе, здесь приведу основные моменты:

У модели сделаем тройку полей даты-времени, два текстовых поля, одно поля bool, ну и конечно же id:

Код модели
# commons/models.py
from datetime import datetime

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.types import Text
from sqlalchemy.sql import func


class Base(DeclarativeBase):
    pass


class Post(Base):
    __tablename__ = "posts"

    id: Mapped[int] = mapped_column(primary_key=True)
    created_at: Mapped[datetime] = mapped_column(server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(onupdate=func.now())
    published_at: Mapped[datetime] = mapped_column(nullable=False)
    title: Mapped[str] = mapped_column(Text)
    content: Mapped[str] = mapped_column(Text)
    is_deleted: Mapped[bool] = mapped_column(nullable=False, default=False)

Что? Ещё не видели новый-стильный-молодёжный стиль SQLAlchemy под названием mapping_styles ? Тогда скорее к документации. В целом, снова изменения были внесены для того, чтобы наши любимые IDEшки не ругались, когда в аттрибут объекта типа Column мы пытаемся записать какие-то данные не Column, а к примеру int, str и так далее.

Схема Pydantic v1 - стандартная модель Pydantic:

Код схемы
# commons/schemas.py
from datetime import datetime
from enum import StrEnum

from pydantic import BaseModel, validator


class PostOut(BaseModel):
    id: int
    published_at: datetime
    updated_at: datetime
    title: str
    content: str
    is_published: bool | None = None

    @validator("is_published", always=True)
    def compute_is_published(cls, v, values, **kwargs):
        return datetime.utcnow() >= values["published_at"]

    class Config:
        orm_mode = True


class PostsOut(BaseModel):
    posts: list[PostOut]


class PostIn(BaseModel):
    title: str
    content: str
    published_at: datetime


class Order(StrEnum):
    ASC = "asc"
    DESC = "desc"

Из интересного тут - только вычисления поля is_published "на лету", то есть - при отдаче клиенту.

Для тестов сделаем три конечные точки, одна из них - на запись постов в БД, другая - на чтение постов из БД, третья - чисто синтетический тест скорости:

Код роутинга
# api/posts/router.py
from fastapi import APIRouter, Depends, Response, status, HTTPException
from sqlalchemy.orm import Session

from commons.database import get_db
from commons import schemas
from crud import posts


router = APIRouter(tags=["posts"])


@router.get("/posts")
def get_posts(
    per_page: int = 10,
    page: int = 0,
    order: schemas.Order = schemas.Order.DESC,
    session: Session = Depends(get_db),
) -> schemas.PostsOut:
    return schemas.PostsOut(posts=posts.get(per_page, per_page*page, order, session))


@router.get("/posts_synthetic")
def posts_synthetic(
    per_page: int = 10,
) -> schemas.PostsOut:
    return schemas.PostsOut(
        posts=[
            schemas.PostOut(
                id=i,
                published_at=datetime(2023, 6, 30, 12, 0, 0),
                updated_at=datetime(2023, 6, 30, 12, 0, 0),
                title="Статья",
                content="Съешь ещё этих мягких французских булок, да выпей же чаю.",
            )
            for i in range(per_page)
        ]
    )


@router.post("/posts")
def create_post(
    post_in: schemas.PostIn, session: Session = Depends(get_db)
) -> schemas.PostOut:
    post = posts.create(post_in, session)
    return post

В полном соответствии с документацией, я отдаю работу по преобразованию моделей из SQLAlchemy в конечный ответ клиенту на плечи FastAPI.

Операции по работе с БД я сократил до минимума, без обновления и удаления:

Код работы с БД
# crud/posts.py
from datetime import datetime
from typing import Sequence
from sqlalchemy import insert, select, update, desc, asc
from sqlalchemy.orm import Session
from sqlalchemy import exc
from commons.schemas import PostIn, Order

from commons.models import Post


def get(limit: int, offset: int, order: Order, session: Session) -> Sequence[Post]:
    q = (
        select(Post)
        .where(Post.is_deleted == False)
        .order_by(
            desc(Post.published_at) if order is Order.DESC else asc(Post.published_at)
        )
        .limit(limit)
        .offset(offset)
    )
    return session.execute(q).scalars().all()


def create(post_in: PostIn, session: Session) -> Post:
    q = (
        insert(Post)
        .values(
            updated_at=datetime.utcnow(),
            published_at=post_in.published_at,
            title=post_in.title,
            content=post_in.content,
            is_deleted=False,
        )
        .returning(Post)
    )
    post = session.execute(q).scalar_one()
    session.commit()
    return post

Изменения при переходе на Pydantic 2

Изменения мажорной версии несут с собой изменения в интерфейсах, поэтому для нашей версии приложения, работающей на версии FastAPI 0.100.0-beta1 + Pydantic 2 тоже потребуются изменения. Быстро пролистав Migration Guide, для своего тестового приложения мне пришлось внести следующие изменения:

  • Обновление зависимостей. Вот тут меня ждал сюрприз - оказывается, в версии Pydantic 2 они решили вынести знакомые многим BaseSettings в отдельную библиотеку pydantic-settings! А она требует в зависимостях typing-extensions<4.0.0, когда новая версия алхимии 2.0.17 требует typing-extensions>=4.2.0 ... Хорошо, что в моем маленьком CRUDе всего одна переменная, так что поставили os.getenv и забыли - но в больших приложениях это может украсть много нервов. UPDATE: после релиза pydantic 2 вышла так же pydantic-settings==2.0.0, в которой данный конфликт зависимостей исправлен.

  • В конфигурации модели Pydantic orm_mode работает, но предупреждает, что название изменилось на from_attributes. Меняем.

  • always=True в модели Pydantic теперь не работает, но зато появился долгожданный декоратор computed_field - теперь вычисляемое свойство выглядит намного приличней:

class PostOut(BaseModel):
    ...

    @computed_field
    @property
    def is_published(self) -> bool:
        return datetime.utcnow() >= self.published_at

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

Тестирование производительности

А теперь перейдём к вишенке на торте - самим тестам. Для этого я написал скрипт test.sh, который:

  • запускает БД, запускает приложение, тестирует клиент с помощью утилиты ab (Apache benchmark) для приложения на версии FastAPI 0.98.0

  • сносит всё командой docker compose down -v

  • повторяет первый пункт для приложения на версии FastAPI 0.100-beta1

Сами запросы представляют собой 1000 записей POST /posts и 1000 чтений первых 100 постов GET /posts?per_page=100, количество одновременно выполняемых запросов (параметр c) = 10

Так как я не являюсь уважаемым магистром bash, то вывод данных со скрипта у меня несколько корявый, вам же приведу уже обработанные данные (везде брал средний показатель из трёх прогонов, выполняемых тестом):

fastapi 0.98.0

fastapi 0.100.0-beta1

READ r/s

126.90

371.19

READ r/s syntetic

172.57

1203.18

WRITE r/s

342.11

352.65

MEM USAGE BEFORE

72.44MiB

85.95MiB

MEM USAGE AFTER

85.95MiB

98.91MiB

Выводы:

  • Главное - скорость отдачи первых 100 постов увеличилась в x2,92 раза! Тут как раз помогает то, что скорость обращения к БД не так сильно играет роль при большом количестве повторяющихся запросов. А вот скорость фреймворка оказывает сильное влияние.

  • При чисто синтетическом тесте без обращения к БД скорость возрасла в ~7 раз!

  • Скорость записи, которая в основном зависит от скорости БД - увеличилась, но несущественно.

  • Но за всё нужно платить - потребление оперативно памяти увеличилось примерно на 15%.

Заключение

Я остался доволен большим увеличением скорости FastAPI. В текущий момент у Pydantic V2 на гитхабе открыто 7 issue, 4 из которых - о статических анализаторах кода, в целом - не много.
При желании провести дополнительные тесты - очень легко форкнуть репозиторий и внести изменения, так что welcome!

Исходный код приложения:
github.com

UPDATE.
Во время написания статьи Pydantic2 вышел из бета стадии в стабильную, а так же вышла версия FastAPI 0.100.0-beta2, использующая стабильную версию Pydantic2.

Комментарии (7)


  1. shpaker
    03.07.2023 04:58

    Но за всё нужно платить - потребление оперативно памяти увеличилось примерно на 15%.

    Не самый очевидный момент. Это пайдантик теперь прожорливей?


    1. NewSouth Автор
      03.07.2023 04:58

      Да. Думаю, причина в том, что приходится из Python вызывать функции из другого языка программирования - Rust, ведь https://github.com/pydantic/pydantic-core переписан на него.


      1. shpaker
        03.07.2023 04:58

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


  1. Ryav
    03.07.2023 04:58

    Ситуация с BaseSettings — боль. Подождём совместимости зависимостей.


    1. shpaker
      03.07.2023 04:58

      Почему боль? Ну будет плюс одна зависимость - вроде ничего страшного :)


      1. Ryav
        03.07.2023 04:58

        Там конфликты. Ну точнее были :)


    1. NewSouth Автор
      03.07.2023 04:58
      +4

      Проверил, после релиза Pydantic теперь можно установить pydantic-settings==2.0.0 вместе с последней алхимией == 2.0.17. Боль была временной, внесу update в статью