Compare commits

...

4 Commits

Author SHA1 Message Date
Marcelo Trylesinski
8b383969ef Add fragmented body benchmark for chunked body accumulation
Sends 100KB in 390 x 256-byte chunks to exercise the body += path
that triggers O(n^2) allocation with bytes concatenation.
2026-03-15 16:53:46 +01:00
Marcelo Trylesinski
9b22e53e28 Use OIDC token and pin CodSpeed action to latest commit 2026-03-15 16:11:57 +01:00
Marcelo Trylesinski
b5e326ab0e Suppress mypy operator error on ASGI message body concatenation 2026-03-15 16:07:06 +01:00
Marcelo Trylesinski
90bd7126b2 Add CodSpeed benchmark suite for HTTP protocol hot paths 2026-03-15 16:04:55 +01:00
7 changed files with 348 additions and 2 deletions

34
.github/workflows/benchmark.yml vendored Normal file
View File

@ -0,0 +1,34 @@
---
name: CodSpeed
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
id-token: write
contents: read
jobs:
benchmarks:
name: Run benchmarks
runs-on: ubuntu-latest
steps:
- uses: "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
with:
python-version: "3.13"
- name: Install dependencies
run: scripts/install
shell: bash
- name: Run the benchmarks
uses: CodSpeedHQ/action@281164b0f014a4e7badd2c02cecad9b595b70537 # v4
with:
mode: instrumentation
run: uv run pytest tests/benchmarks/ --codspeed

1
.gitignore vendored
View File

@ -8,3 +8,4 @@ venv/
htmlcov/
site/
dist/
.codspeed/

View File

@ -56,6 +56,7 @@ dev = [
"pytest==9.0.2",
"pytest-mock==3.15.1",
"pytest-xdist[psutil]==3.8.0",
"pytest-codspeed>=4.1.1",
"mypy==1.19.1",
"types-click==7.1.8",
"types-pyyaml==6.0.12.20250915",
@ -135,7 +136,7 @@ filterwarnings = [
parallel = true
source_pkgs = ["uvicorn", "tests"]
plugins = ["coverage_conditional_plugin"]
omit = ["uvicorn/workers.py", "uvicorn/__main__.py", "uvicorn/_compat.py"]
omit = ["uvicorn/workers.py", "uvicorn/__main__.py", "uvicorn/_compat.py", "tests/benchmarks/*"]
[tool.coverage.report]
precision = 2

View File

174
tests/benchmarks/http.py Normal file
View File

@ -0,0 +1,174 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, TypeAlias
from uvicorn._types import ASGIApplication, Scope
from uvicorn.config import Config
from uvicorn.lifespan.off import LifespanOff
from uvicorn.lifespan.on import LifespanOn
from uvicorn.protocols.http.h11_impl import H11Protocol
from uvicorn.server import ServerState
if TYPE_CHECKING:
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
from uvicorn.protocols.websockets.websockets_impl import WebSocketProtocol
from uvicorn.protocols.websockets.wsproto_impl import WSProtocol as _WSProtocol
WSProtocol: TypeAlias = WebSocketProtocol | _WSProtocol
HTTPProtocol: TypeAlias = H11Protocol | HttpToolsProtocol
SIMPLE_GET_REQUEST = b"\r\n".join([b"GET / HTTP/1.1", b"Host: example.org", b"", b""])
SIMPLE_POST_REQUEST = b"\r\n".join(
[
b"POST / HTTP/1.1",
b"Host: example.org",
b"Content-Type: application/json",
b"Content-Length: 18",
b"",
b'{"hello": "world"}',
]
)
LARGE_POST_REQUEST = b"\r\n".join(
[
b"POST / HTTP/1.1",
b"Host: example.org",
b"Content-Type: text/plain",
b"Content-Length: 100000",
b"",
b"x" * 100000,
]
)
HTTP10_GET_REQUEST = b"\r\n".join([b"GET / HTTP/1.0", b"Host: example.org", b"", b""])
CONNECTION_CLOSE_REQUEST = b"\r\n".join([b"GET / HTTP/1.1", b"Host: example.org", b"Connection: close", b"", b""])
START_POST_REQUEST = b"\r\n".join(
[
b"POST / HTTP/1.1",
b"Host: example.org",
b"Content-Type: application/json",
b"Content-Length: 18",
b"",
b"",
]
)
FINISH_POST_REQUEST = b'{"hello": "world"}'
BODY_CHUNK_SIZE = 256
FRAGMENTED_BODY_SIZE = 100_000
FRAGMENTED_POST_HEADERS = b"\r\n".join(
[
b"POST / HTTP/1.1",
b"Host: example.org",
b"Content-Type: application/octet-stream",
b"Content-Length: " + str(FRAGMENTED_BODY_SIZE).encode(),
b"",
b"",
]
)
FRAGMENTED_BODY_CHUNKS = [b"x" * BODY_CHUNK_SIZE] * (FRAGMENTED_BODY_SIZE // BODY_CHUNK_SIZE)
class MockTransport:
def __init__(self) -> None:
self.buffer = b""
self.closed = False
self.read_paused = False
def get_extra_info(self, key: Any) -> Any:
return {
"sockname": ("127.0.0.1", 8000),
"peername": ("127.0.0.1", 8001),
"sslcontext": False,
}.get(key)
def write(self, data: bytes) -> None:
self.buffer += data
def close(self) -> None:
self.closed = True
def pause_reading(self) -> None:
self.read_paused = True
def resume_reading(self) -> None:
self.read_paused = False
def is_closing(self) -> bool:
return self.closed
def clear_buffer(self) -> None:
self.buffer = b""
def set_protocol(self, protocol: asyncio.Protocol) -> None:
pass
class MockTimerHandle:
def __init__(
self, loop_later_list: list[MockTimerHandle], delay: float, callback: Callable[[], None], args: tuple[Any, ...]
) -> None:
self.loop_later_list = loop_later_list
self.delay = delay
self.callback = callback
self.args = args
self.cancelled = False
def cancel(self) -> None:
if not self.cancelled:
self.cancelled = True
self.loop_later_list.remove(self)
class MockLoop:
def __init__(self) -> None:
self._tasks: list[asyncio.Task[Any]] = []
self._later: list[MockTimerHandle] = []
def create_task(self, coroutine: Any) -> Any:
self._tasks.insert(0, coroutine)
return MockTask()
def call_later(self, delay: float, callback: Callable[[], None], *args: Any) -> MockTimerHandle:
handle = MockTimerHandle(self._later, delay, callback, args)
self._later.insert(0, handle)
return handle
async def run_one(self) -> Any:
return await self._tasks.pop()
class MockTask:
def add_done_callback(self, callback: Callable[[], None]) -> None:
pass
class MockProtocol(asyncio.Protocol):
loop: MockLoop
transport: MockTransport
timeout_keep_alive_task: asyncio.TimerHandle | None
ws_protocol_class: type[WSProtocol] | None
scope: Scope
def get_connected_protocol(
app: ASGIApplication,
http_protocol_cls: type[HTTPProtocol],
lifespan: LifespanOff | LifespanOn | None = None,
**kwargs: Any,
) -> MockProtocol:
loop = MockLoop()
transport = MockTransport()
config = Config(app=app, **kwargs)
lifespan = lifespan or LifespanOff(config)
server_state = ServerState()
protocol = http_protocol_cls(config=config, server_state=server_state, app_state=lifespan.state, _loop=loop) # type: ignore
protocol.connection_made(transport) # type: ignore[arg-type]
return protocol # type: ignore[return-value]

View File

@ -0,0 +1,108 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from tests.benchmarks.http import (
CONNECTION_CLOSE_REQUEST,
FINISH_POST_REQUEST,
FRAGMENTED_BODY_CHUNKS,
FRAGMENTED_POST_HEADERS,
HTTP10_GET_REQUEST,
LARGE_POST_REQUEST,
SIMPLE_GET_REQUEST,
SIMPLE_POST_REQUEST,
START_POST_REQUEST,
get_connected_protocol,
)
from tests.response import Response
from uvicorn._types import ASGIReceiveCallable, ASGISendCallable, Scope
if TYPE_CHECKING:
from tests.benchmarks.http import HTTPProtocol
pytestmark = [pytest.mark.anyio, pytest.mark.benchmark]
_plain_text_app = Response("Hello, world", media_type="text/plain")
_no_content_app = Response(b"", status_code=204)
_chunked_app = Response(b"Hello, world!", status_code=200, headers={"transfer-encoding": "chunked"})
async def _body_echo_app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None:
body = b""
while True:
message = await receive()
body += message.get("body", b"") # type: ignore[operator]
if not message.get("more_body", False):
break
headers = [(b"content-length", str(len(body)).encode())]
await send({"type": "http.response.start", "status": 200, "headers": headers})
await send({"type": "http.response.body", "body": body})
async def test_bench_simple_get(http_protocol_cls: type[HTTPProtocol]) -> None:
protocol = get_connected_protocol(_plain_text_app, http_protocol_cls)
protocol.data_received(SIMPLE_GET_REQUEST)
await protocol.loop.run_one()
async def test_bench_simple_post(http_protocol_cls: type[HTTPProtocol]) -> None:
protocol = get_connected_protocol(_plain_text_app, http_protocol_cls)
protocol.data_received(SIMPLE_POST_REQUEST)
await protocol.loop.run_one()
async def test_bench_large_post(http_protocol_cls: type[HTTPProtocol]) -> None:
protocol = get_connected_protocol(_plain_text_app, http_protocol_cls)
protocol.data_received(LARGE_POST_REQUEST)
await protocol.loop.run_one()
async def test_bench_pipelined_requests(http_protocol_cls: type[HTTPProtocol]) -> None:
protocol = get_connected_protocol(_plain_text_app, http_protocol_cls)
protocol.data_received(SIMPLE_GET_REQUEST * 3)
await protocol.loop.run_one()
await protocol.loop.run_one()
await protocol.loop.run_one()
async def test_bench_keepalive_reuse(http_protocol_cls: type[HTTPProtocol]) -> None:
protocol = get_connected_protocol(_plain_text_app, http_protocol_cls)
protocol.data_received(SIMPLE_GET_REQUEST)
await protocol.loop.run_one()
protocol.data_received(SIMPLE_GET_REQUEST)
await protocol.loop.run_one()
async def test_bench_chunked_response(http_protocol_cls: type[HTTPProtocol]) -> None:
protocol = get_connected_protocol(_chunked_app, http_protocol_cls)
protocol.data_received(SIMPLE_GET_REQUEST)
await protocol.loop.run_one()
async def test_bench_http10(http_protocol_cls: type[HTTPProtocol]) -> None:
protocol = get_connected_protocol(_plain_text_app, http_protocol_cls)
protocol.data_received(HTTP10_GET_REQUEST)
await protocol.loop.run_one()
async def test_bench_connection_close(http_protocol_cls: type[HTTPProtocol]) -> None:
protocol = get_connected_protocol(_plain_text_app, http_protocol_cls)
protocol.data_received(CONNECTION_CLOSE_REQUEST)
await protocol.loop.run_one()
async def test_bench_fragmented_body(http_protocol_cls: type[HTTPProtocol]) -> None:
protocol = get_connected_protocol(_plain_text_app, http_protocol_cls)
protocol.data_received(FRAGMENTED_POST_HEADERS)
for chunk in FRAGMENTED_BODY_CHUNKS:
protocol.data_received(chunk)
await protocol.loop.run_one()
async def test_bench_post_body_receive(http_protocol_cls: type[HTTPProtocol]) -> None:
protocol = get_connected_protocol(_body_echo_app, http_protocol_cls)
protocol.data_received(START_POST_REQUEST)
protocol.data_received(FINISH_POST_REQUEST)
await protocol.loop.run_one()

30
uv.lock generated
View File

@ -1284,6 +1284,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-codspeed"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
{ name = "pytest" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/98/ab/eca41967d11c95392829a8b4bfa9220a51cffc4a33ec4653358000356918/pytest_codspeed-4.3.0.tar.gz", hash = "sha256:5230d9d65f39063a313ed1820df775166227ec5c20a1122968f85653d5efee48", size = 124745, upload-time = "2026-02-09T15:23:34.745Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/64/800bdaeabd3eb126aff7e3e22dc45b2826305f61cbfd093284caf8d9ca01/pytest_codspeed-4.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2acecc4126658abebc683b38121adec405a46e18a619d49d6154c6e60c5deb2", size = 347077, upload-time = "2026-02-09T15:23:17.2Z" },
{ url = "https://files.pythonhosted.org/packages/f3/f1/d69707440829adab86d078d5f1c8c070df116b1624f8eae4ff36933ba612/pytest_codspeed-4.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:619120775e92a3f43fb4ff4c256a251b1554c904d95e2154a382484283f0388a", size = 342234, upload-time = "2026-02-09T15:23:18.407Z" },
{ url = "https://files.pythonhosted.org/packages/d9/15/ec0ac1f022173b3134c9638f2a35f21fbb3142c75da066d9e49e5a8bb4bd/pytest_codspeed-4.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbeff1eb2f2e36df088658b556fa993e6937bf64ffb07406de4db16fd2b26874", size = 347076, upload-time = "2026-02-09T15:23:19.989Z" },
{ url = "https://files.pythonhosted.org/packages/a5/e8/1fe375794ad02b7835f378a7bcfa8fbac9acadefe600a782a7c4a7064db7/pytest_codspeed-4.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:878aad5e4bb7b401ad8d82f3af5186030cd2bd0d0446782e10dabb9db8827466", size = 342215, upload-time = "2026-02-09T15:23:20.954Z" },
{ url = "https://files.pythonhosted.org/packages/09/58/50df94e9a78e1c77818a492c90557eeb1309af025120c9a21e6375950c52/pytest_codspeed-4.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527a3a02eaa3e4d4583adc4ba2327eef79628f3e1c682a4b959439551a72588e", size = 347395, upload-time = "2026-02-09T15:23:21.986Z" },
{ url = "https://files.pythonhosted.org/packages/e4/56/7dfbd3eefd112a14e6fb65f9ff31dacf2e9c381cb94b27332b81d2b13f8d/pytest_codspeed-4.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9858c2a6e1f391d5696757e7b6e9484749a7376c46f8b4dd9aebf093479a9667", size = 342625, upload-time = "2026-02-09T15:23:23.035Z" },
{ url = "https://files.pythonhosted.org/packages/7f/53/7255f6a25bc56ff1745b254b21545dfe0be2268f5b91ce78f7e8a908f0ad/pytest_codspeed-4.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34f2fd8497456eefbd325673f677ea80d93bb1bc08a578c1fa43a09cec3d1879", size = 347325, upload-time = "2026-02-09T15:23:23.998Z" },
{ url = "https://files.pythonhosted.org/packages/2e/f8/82ae570d8b9ad30f33c9d4002a7a1b2740de0e090540c69a28e4f711ebe2/pytest_codspeed-4.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df6a36a2a9da1406bc50428437f657f0bd8c842ae54bee5fb3ad30e01d50c0f5", size = 342558, upload-time = "2026-02-09T15:23:25.656Z" },
{ url = "https://files.pythonhosted.org/packages/b3/e1/55cfe9474f91d174c7a4b04d257b5fc6d4d06f3d3680f2da672ee59ccc10/pytest_codspeed-4.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bec30f4fc9c4973143cd80f0d33fa780e9fa3e01e4dbe8cedf229e72f1212c62", size = 347383, upload-time = "2026-02-09T15:23:26.68Z" },
{ url = "https://files.pythonhosted.org/packages/7f/3b/8fd781d959bbe789b3de8ce4c50d5706a684a0df377147dfb27b200c20c1/pytest_codspeed-4.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e6584e641cadf27d894ae90b87c50377232a97cbfd76ee0c7ecd0c056fa3f7f4", size = 342481, upload-time = "2026-02-09T15:23:27.686Z" },
{ url = "https://files.pythonhosted.org/packages/bb/0c/368045133c6effa2c665b1634b7b8a9c88b307f877fa31f1f8df47885b51/pytest_codspeed-4.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0d1f6ea594f29b745c634d66d5f5f1caa1c3abd2af82fea49d656038e8fc77", size = 353680, upload-time = "2026-02-09T15:23:28.726Z" },
{ url = "https://files.pythonhosted.org/packages/59/21/e543abcd72244294e25ae88ec3a9311ade24d6913f8c8f42569d671700bc/pytest_codspeed-4.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2f5bb6d8898bea7db45e3c8b916ee48e36905b929477bb511b79c5a3ccacda4", size = 347888, upload-time = "2026-02-09T15:23:30.443Z" },
{ url = "https://files.pythonhosted.org/packages/55/d9/b8a53c20cf5b41042c205bb9d36d37da00418d30fd1a94bf9eb147820720/pytest_codspeed-4.3.0-py3-none-any.whl", hash = "sha256:05baff2a61dc9f3e92b92b9c2ab5fb45d9b802438f5373073f5766a91319ed7a", size = 125224, upload-time = "2026-02-09T15:23:33.774Z" },
]
[[package]]
name = "pytest-mock"
version = "3.15.1"
@ -1696,6 +1722,7 @@ dev = [
{ name = "httpx" },
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-codspeed" },
{ name = "pytest-mock" },
{ name = "pytest-xdist", extra = ["psutil"] },
{ name = "ruff" },
@ -1724,7 +1751,7 @@ requires-dist = [
{ name = "pyyaml", marker = "extra == 'standard'", specifier = ">=5.1" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = ">=4.0" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32' and extra == 'standard'", specifier = ">=0.15.1" },
{ name = "watchfiles", marker = "extra == 'standard'", specifier = ">=0.13" },
{ name = "watchfiles", marker = "extra == 'standard'", specifier = ">=0.20" },
{ name = "websockets", marker = "extra == 'standard'", specifier = ">=10.4" },
]
provides-extras = ["standard"]
@ -1739,6 +1766,7 @@ dev = [
{ name = "httpx", specifier = "==0.28.1" },
{ name = "mypy", specifier = "==1.19.1" },
{ name = "pytest", specifier = "==9.0.2" },
{ name = "pytest-codspeed", specifier = ">=4.1.1" },
{ name = "pytest-mock", specifier = "==3.15.1" },
{ name = "pytest-xdist", extras = ["psutil"], specifier = "==3.8.0" },
{ name = "ruff", specifier = "==0.15.1" },