0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FastAPI 0.140.0でメモリ効率が改善されたらしいので試してみる。

0
Last updated at Posted at 2026-07-25

はじめに

2026年7月25日 FastAPI 0.140.0にてメモリ効率が改善されたとのこと。

公式
image.png

image.png

今回の 0.140.0 の改善は FastAPI 本体の依存性注入(Depends)まわりのメモリ削減#16049「Reduce memory usage in dependencies」)。pydantic-core(Rust)の改善とは別物。

  • 効果が出やすい負荷: Depends を多用する/ネストが深いアプリのメモリ使用量
  • 効きにくい負荷: 単純なエンドポイント、PDF / Pillow などネイティブ変換のメモリ

補足:Pydantic の Rust コア(pydantic-core)による JSON 検証・シリアライズの高速化は、これより前の 0.130.0 系#14962)で入っている。

試してみる。

環境

ハードウェア
OS Windows 11 wsl ubuntu 24.04
CPU Intel Core i7-10700F
メモリ 48GB DDR4

更新前の環境

  • FastAPI 0.139.2

更新後の環境

  • FastAPI 0.140.0

補足:計測は uvicorn を 1 プロセス--workers 指定なし)で起動。依存解決は CPU を使う処理なので、実質 1 コアで直列に捌く構成。後述の RPS/Latency はこの前提の数字。

まずは計測用の処理を実装

router.py
from typing import Any

from fastapi import APIRouter, Depends

from app.features.deps.deps import ROUTE_COUNT, graph_meta, root_dependency
from app.features.deps.schemas import DepsBenchResponse

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


def _to_response(root: dict[str, Any]) -> DepsBenchResponse:
    tip = int(root.get("tip", 0))
    return DepsBenchResponse(
        depth=graph_meta["depth"],
        width=graph_meta["width"],
        route_count=graph_meta["route_count"],
        leaf_sum=int(root.get("sum", tip)),
        chain_tip=tip,
    )


@router.get("/deps", response_model=DepsBenchResponse)
def read_deps(root: dict[str, Any] = Depends(root_dependency)) -> DepsBenchResponse:
    """ネストした Depends グラフを解決して返す(メイン計測用)。"""
    return _to_response(root)


def _register_mirror_routes() -> None:
    """同一グラフを参照するルートを増やし、アプリ構築時の Dependant 数を膨らませる。"""

    for index in range(ROUTE_COUNT):

        def make_endpoint(route_index: int) -> Any:
            def mirror(
                root: dict[str, Any] = Depends(root_dependency),
            ) -> DepsBenchResponse:
                return _to_response(root)

            mirror.__name__ = f"read_deps_mirror_{route_index}"
            mirror.__qualname__ = f"read_deps_mirror_{route_index}"
            return mirror

        router.add_api_route(
            f"/deps/r{index}",
            make_endpoint(index),
            methods=["GET"],
            response_model=DepsBenchResponse,
            include_in_schema=False,
            name=f"read_deps_mirror_{index}",
        )


_register_mirror_routes()

schemas.py
from pydantic import BaseModel


class DepsBenchResponse(BaseModel):
    depth: int
    width: int
    route_count: int
    leaf_sum: int
    chain_tip: int

ネストしたDI組み立て

deps.py
"""ネストした Depends を大量に組み立てる(dependency メモリ計測用)。"""

from __future__ import annotations

import inspect
from collections.abc import Callable
from typing import Any

from fastapi import Depends

CHAIN_DEPTH = 24
FANOUT_WIDTH = 8
ROUTE_COUNT = 40


def _with_depends_signature(
    name: str,
    param_deps: list[tuple[str, Callable[..., Any]]],
    body: Callable[..., Any],
) -> Callable[..., Any]:
    """各 Depends を個別パラメータとしてシグネチャに載せ、FastAPI に認識させる。"""
    parameters = [
        inspect.Parameter(
            param_name,
            kind=inspect.Parameter.KEYWORD_ONLY,
            default=Depends(dep),
        )
        for param_name, dep in param_deps
    ]

    def wrapper(**kwargs: Any) -> Any:
        return body(**kwargs)

    wrapper.__name__ = name
    wrapper.__qualname__ = name
    wrapper.__signature__ = inspect.Signature(parameters)  # type: ignore[attr-defined]
    return wrapper


def _make_leaf(index: int) -> Callable[[], int]:
    def leaf() -> int:
        return index

    leaf.__name__ = f"leaf_{index}"
    leaf.__qualname__ = f"leaf_{index}"
    return leaf


def _make_fanout_node(
    level: int,
    index: int,
    child_deps: list[Callable[..., Any]],
) -> Callable[..., dict[str, Any]]:
    param_deps = [(f"c{i}", dep) for i, dep in enumerate(child_deps)]

    def body(**kwargs: Any) -> dict[str, Any]:
        values = list(kwargs.values())
        total = 0
        for value in values:
            total += value if isinstance(value, int) else int(value.get("sum", 0))
        return {
            "level": level,
            "index": index,
            "sum": total,
            "width": len(values),
        }

    return _with_depends_signature(f"fanout_L{level}_{index}", param_deps, body)


def _make_chain_node(
    level: int,
    prev: Callable[..., Any],
    side_deps: list[Callable[..., Any]],
) -> Callable[..., dict[str, Any]]:
    param_deps = [("previous", prev), *[ (f"s{i}", dep) for i, dep in enumerate(side_deps) ]]

    def body(**kwargs: Any) -> dict[str, Any]:
        previous = kwargs["previous"]
        sides = [kwargs[f"s{i}"] for i in range(len(side_deps))]
        side_sum = 0
        for side in sides:
            side_sum += side if isinstance(side, int) else int(side.get("sum", 0))
        prev_val = previous if isinstance(previous, int) else int(previous.get("tip", 0))
        return {
            "level": level,
            "tip": prev_val + side_sum + level,
            "side_count": len(sides),
        }

    return _with_depends_signature(f"chain_L{level}", param_deps, body)


def build_dependency_graph() -> tuple[Callable[..., dict[str, Any]], dict[str, int]]:
    leaves = [_make_leaf(i) for i in range(FANOUT_WIDTH)]

    current_layer: list[Callable[..., Any]] = list(leaves)
    for level in range(1, 4):
        next_layer: list[Callable[..., Any]] = []
        for i in range(FANOUT_WIDTH):
            children = [
                current_layer[(i + offset) % len(current_layer)]
                for offset in range(min(3, len(current_layer)))
            ]
            next_layer.append(_make_fanout_node(level, i, children))
        current_layer = next_layer

    chain: Callable[..., Any] = current_layer[0]
    for level in range(4, CHAIN_DEPTH):
        sides = [
            current_layer[j % len(current_layer)]
            for j in range(min(FANOUT_WIDTH, len(current_layer)))
        ]
        chain = _make_chain_node(level, chain, sides)

    meta = {
        "depth": CHAIN_DEPTH,
        "width": FANOUT_WIDTH,
        "route_count": ROUTE_COUNT,
    }
    return chain, meta


root_dependency, graph_meta = build_dependency_graph()

計測

まずは旧バージョン 0.139.2 で計測
バージョン確認

docker compose exec api python -c "import fastapi; print(fastapi.__version__)"
curl -s http://127.0.0.1:8000/deps
echo

image.png

アイドルメモリ

docker stats --no-stream --format "{{.Name}} {{.CPUPerc}} {{.MemUsage}}" | grep '^api-api-1 '

image.png

image.png

CPUが2%未満になってから実行

for i in $(seq 1 5); do
  date -Iseconds
  docker stats --no-stream --format "{{.Name}} {{.CPUPerc}} {{.MemUsage}}" | grep '^api-api-1 '
  sleep 1
done | tee bench/mem_idle_0.140.0.txt

image.png

次に0.140.0で計測する

バージョン確認

image.png

アイドルメモリ

image.png

モニタリング

image.png

負荷計測(wrk)

Latency / RPS は wrk で計測(各バージョンで同一条件・複数回)。

# ウォームアップ
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://127.0.0.1:8000/deps

# 本計測:4スレッド・20コネクション・30秒
wrk -t4 -c20 -d30s --latency "http://127.0.0.1:8000/deps"

改善率比較(0.139.2 → 0.140.0)

計算:

  • メモリ削減率 = (旧 − 新) / 旧 × 100(大きいほど改善)
  • Latency / timeout 変化率 = (新 − 旧) / 旧 × 100(負なら短縮=改善)
  • RPS / 総リクエスト変化率 = (新 − 旧) / 旧 × 100(正なら改善)

サマリ表

指標 0.139.2(旧) 0.140.0(新) 差分 変化率 評価
アイドル RSS 1.581 GiB(1619 MiB) 771.5 MiB −847 MiB −52.3%(使用量) 改善(約半減)
Latency avg 615.16 ms 953.57 ms +338.41 ms +55.0% 悪化
Latency p50 614.93 ms 953.75 ms +338.82 ms +55.1% 悪化
Latency p99 676.66 ms 1.05 s +約 373 ms +55.2% 悪化
Requests/sec 34.28 20.72 −13.56 −39.6% 悪化
総リクエスト(30s) 965 584 −381 −39.5% 悪化
timeout 20 40 +20 +100% 悪化

メモリ改善の内訳

項目
旧(0.139.2) 1.581 × 1024 = 1618.9 MiB
新(0.140.0) 771.5 MiB
削減量 1618.9 − 771.5 = 847.4 MiB
削減率 847.4 / 1618.9 ≈ 52.3%
新 / 旧 771.5 / 1618.9 ≈ 47.7%(旧の半分弱)

結果

  1. メモリ削減は明確
    ネスト Depends を大量に載せたアプリで、アイドル RSS が 1.581 GiB → 771.5 MiB(52.3% 削減)。公式 PR の狙い(dependency メモリ)と一致する。

  2. 今回の wrk ではスループットは上がっていない
    0.140.0 の方が Latency が長く(+55%)、RPS は下がった(−39.6%)。#16049 はメモリ最適化であり、この極端な dependency グラフの解決速度まで保証するものではない。timeout も増えている。

検証対象 結果
0.140.0 の dependency メモリ改善 再現できた(アイドル RSS 52.3% 削減
wrk の RPS / latency 改善 今回の負荷では観測されず、むしろ悪化(理由は後述)

「メモリを食う Depends だらけの API」を抱えているなら 0.140.0 への更新は効きやすい。スループット改善を期待するなら、別指標・別負荷(例: 0.130.0 前後の巨大 JSON)で測る必要がある。

なぜ悪化したか

悪化した原因:#16049 によるトレードオフ

0.140.0 はメモリを減らすためにキャッシュを捨て、その分だけリクエスト処理が重くなった

これまで(0.139.2)FastAPI は、Depends を解析した結果(「この関数は async か?」「必要な scope は?」など)を一度計算したら覚えておく仕組みだった(@cached_property)。速いが、覚えておくぶんメモリを食う。

0.140.0(#16049)は、この「覚えておく」をやめた。

0.139.2(旧) 0.140.0(新)
Depends の解析結果 1回計算して保持 毎リクエスト計算し直す
メモリ 多い(保持するため) 少ない(−52%)
リクエストの重さ 軽い(計算済み) 重い(毎回計算)

つまり 「メモリ ↔ 速度」のトレードオフ

なぜ今回はこんなに遅くなったのか

「毎回計算し直す」コストは、Depends のネストが深いほど膨らむ。今回のベンチはそこを極端にした設定:

  • CHAIN_DEPTH=24(24段のネスト)・FANOUT_WIDTH=8・ルート40本
  • → 1リクエストで数千ノードを毎回解析し直す = Latency +55%

つまり「捨てたキャッシュが一番効いていた形」をわざと作ったベンチ。改善の悪い面だけが最大化されて出た数字であり、一般的な API には当てはまらない極端な例。

補足:RPS と Latency は「別々の悪化」ではない

今回は接続数を固定した負荷(-c20)なので、RPS ≒ 接続数 ÷ Latency の関係で決まる。

  • 0.139.2: 20 ÷ 0.615s ≒ 34(実測 34.3)
  • 0.140.0: 20 ÷ 0.953s ≒ 21(実測 20.7)

RPS 低下と Latency 増加は同じ事実の裏表。「1リクエストが重くなった」の一点に集約される(uvicorn 1プロセス=実質1コアで捌いているのも効いている)。

実運用で起こる可能性があるか

ほぼ起こらない。

  • 普通の API は Depends のネストが浅い(数段程度)。毎回計算し直しても誤差レベルで、速度差は体感できない。
  • 今回のような24段ネストは現実では作らない。
  • 逆に メモリ削減(−52%)は多くのアプリで効く。特に Depends を多用する大きめのアプリほど恩恵が大きい。

→ ほとんどの環境で 0.140.0 へのアップデートはプラス。速度が落ちて見えるのは、今回のような極端なネストを組んだ特殊ケースだけ。

まとめ

  • 遅くなった原因は #16049 のメモリ↔速度トレードオフ(キャッシュ廃止でメモリ半減、代わりに毎回計算で重くなった)。
  • 今回の数字は 深いネスト Depends を意図的に作ったベンチマークテストなので大きく出ただけ。RPS と Latency の悪化は同じ現象。
  • 実運用ではほぼ問題にならず、メモリ削減の恩恵の方が大きい。

今回の検証用リポジトリ

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?