Skip to content

Commit 3667a10

Browse files
authored
Merge pull request #3549 from benoitc/feature/optional-http-parser
Optimize ASGI performance with fast parser integration
2 parents 2cc3850 + 3568af1 commit 3667a10

38 files changed

Lines changed: 3650 additions & 763 deletions
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
#!/usr/bin/env python
2+
"""
3+
Benchmark comparing HTTP parser implementations.
4+
5+
Compares:
6+
- WSGI Python parser vs Fast parser (gunicorn_h1c)
7+
- ASGI Python parser vs Fast parser (gunicorn_h1c)
8+
9+
Usage:
10+
python benchmarks/http_parser_benchmark.py
11+
"""
12+
13+
import io
14+
import time
15+
import statistics
16+
from typing import NamedTuple
17+
18+
from gunicorn.config import Config
19+
from gunicorn.http.message import Request, _check_fast_parser
20+
from gunicorn.http.unreader import IterUnreader
21+
22+
23+
# Check if fast parser is available
24+
try:
25+
import gunicorn_h1c
26+
FAST_AVAILABLE = True
27+
except ImportError:
28+
FAST_AVAILABLE = False
29+
print("WARNING: gunicorn_h1c not installed. Fast parser benchmarks will be skipped.")
30+
print("Install with: pip install gunicorn_h1c\n")
31+
32+
33+
class BenchmarkResult(NamedTuple):
34+
name: str
35+
iterations: int
36+
total_time: float
37+
avg_time_us: float
38+
min_time_us: float
39+
max_time_us: float
40+
requests_per_sec: float
41+
42+
43+
# Test requests of varying complexity
44+
SIMPLE_REQUEST = b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"
45+
46+
MEDIUM_REQUEST = b"""POST /api/users HTTP/1.1\r
47+
Host: api.example.com\r
48+
Content-Type: application/json\r
49+
Content-Length: 42\r
50+
Accept: application/json\r
51+
Authorization: Bearer token123\r
52+
X-Request-ID: abc-123-def-456\r
53+
\r
54+
"""
55+
56+
COMPLEX_REQUEST = b"""POST /api/v2/resources/items HTTP/1.1\r
57+
Host: api.example.com\r
58+
Content-Type: application/json; charset=utf-8\r
59+
Content-Length: 1024\r
60+
Accept: application/json, text/plain, */*\r
61+
Accept-Language: en-US,en;q=0.9,fr;q=0.8\r
62+
Accept-Encoding: gzip, deflate, br\r
63+
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ\r
64+
X-Request-ID: 550e8400-e29b-41d4-a716-446655440000\r
65+
X-Correlation-ID: 7f3d8c2a-1b4e-4a6f-9c8d-2e5f6a7b8c9d\r
66+
X-Forwarded-For: 203.0.113.195, 70.41.3.18, 150.172.238.178\r
67+
X-Forwarded-Proto: https\r
68+
X-Real-IP: 203.0.113.195\r
69+
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\r
70+
Cache-Control: no-cache, no-store, must-revalidate\r
71+
Pragma: no-cache\r
72+
Cookie: session=abc123; preferences=dark_mode\r
73+
If-None-Match: "etag-value-here"\r
74+
If-Modified-Since: Wed, 21 Oct 2024 07:28:00 GMT\r
75+
\r
76+
"""
77+
78+
79+
def create_wsgi_config(use_fast: bool) -> Config:
80+
"""Create a config for WSGI parsing."""
81+
cfg = Config()
82+
cfg.set('http_parser', 'fast' if use_fast else 'python')
83+
return cfg
84+
85+
86+
def benchmark_wsgi_parser(request_data: bytes, cfg: Config, iterations: int) -> BenchmarkResult:
87+
"""Benchmark WSGI parser."""
88+
times = []
89+
parser_type = cfg.http_parser
90+
91+
for _ in range(iterations):
92+
# Create fresh unreader for each iteration
93+
unreader = IterUnreader(iter([request_data]))
94+
95+
start = time.perf_counter()
96+
req = Request(cfg, unreader, ('127.0.0.1', 8000), req_number=1)
97+
end = time.perf_counter()
98+
99+
times.append(end - start)
100+
101+
# Verify parsing worked
102+
assert req.method is not None
103+
104+
total_time = sum(times)
105+
avg_time = statistics.mean(times)
106+
min_time = min(times)
107+
max_time = max(times)
108+
109+
return BenchmarkResult(
110+
name=f"WSGI {parser_type}",
111+
iterations=iterations,
112+
total_time=total_time,
113+
avg_time_us=avg_time * 1_000_000,
114+
min_time_us=min_time * 1_000_000,
115+
max_time_us=max_time * 1_000_000,
116+
requests_per_sec=iterations / total_time,
117+
)
118+
119+
120+
def benchmark_asgi_parser(request_data: bytes, cfg: Config, iterations: int) -> BenchmarkResult:
121+
"""Benchmark ASGI parser."""
122+
from gunicorn.asgi.parser import HttpParser
123+
124+
times = []
125+
parser_type = cfg.http_parser
126+
127+
for _ in range(iterations):
128+
# Create fresh parser for each iteration
129+
parser = HttpParser(cfg, ('127.0.0.1', 8000), is_ssl=False)
130+
131+
start = time.perf_counter()
132+
result = parser.feed(bytearray(request_data))
133+
end = time.perf_counter()
134+
135+
times.append(end - start)
136+
137+
# Verify parsing worked
138+
assert result is not None
139+
assert result.method is not None
140+
141+
total_time = sum(times)
142+
avg_time = statistics.mean(times)
143+
min_time = min(times)
144+
max_time = max(times)
145+
146+
return BenchmarkResult(
147+
name=f"ASGI {parser_type}",
148+
iterations=iterations,
149+
total_time=total_time,
150+
avg_time_us=avg_time * 1_000_000,
151+
min_time_us=min_time * 1_000_000,
152+
max_time_us=max_time * 1_000_000,
153+
requests_per_sec=iterations / total_time,
154+
)
155+
156+
157+
def print_result(result: BenchmarkResult, baseline: BenchmarkResult = None):
158+
"""Print benchmark result."""
159+
speedup = ""
160+
if baseline and baseline.avg_time_us > 0:
161+
ratio = baseline.avg_time_us / result.avg_time_us
162+
if ratio > 1:
163+
speedup = f" ({ratio:.2f}x faster)"
164+
elif ratio < 1:
165+
speedup = f" ({1/ratio:.2f}x slower)"
166+
167+
print(f" {result.name:20} {result.avg_time_us:8.2f} us/req "
168+
f"({result.requests_per_sec:,.0f} req/s){speedup}")
169+
170+
171+
def run_benchmark_suite(name: str, request_data: bytes, iterations: int):
172+
"""Run a complete benchmark suite for a request type."""
173+
print(f"\n{'='*60}")
174+
print(f"Benchmark: {name}")
175+
print(f"Request size: {len(request_data)} bytes, Iterations: {iterations:,}")
176+
print('='*60)
177+
178+
results = []
179+
180+
# WSGI Python
181+
cfg_python = create_wsgi_config(use_fast=False)
182+
result_wsgi_python = benchmark_wsgi_parser(request_data, cfg_python, iterations)
183+
results.append(result_wsgi_python)
184+
185+
# WSGI Fast (if available)
186+
if FAST_AVAILABLE:
187+
cfg_fast = create_wsgi_config(use_fast=True)
188+
result_wsgi_fast = benchmark_wsgi_parser(request_data, cfg_fast, iterations)
189+
results.append(result_wsgi_fast)
190+
191+
# ASGI Python
192+
cfg_python = create_wsgi_config(use_fast=False)
193+
result_asgi_python = benchmark_asgi_parser(request_data, cfg_python, iterations)
194+
results.append(result_asgi_python)
195+
196+
# ASGI Fast (if available)
197+
if FAST_AVAILABLE:
198+
cfg_fast = create_wsgi_config(use_fast=True)
199+
result_asgi_fast = benchmark_asgi_parser(request_data, cfg_fast, iterations)
200+
results.append(result_asgi_fast)
201+
202+
# Print results
203+
print("\nResults (avg time per request):")
204+
print("-" * 60)
205+
206+
# Print WSGI results
207+
print_result(result_wsgi_python)
208+
if FAST_AVAILABLE:
209+
print_result(result_wsgi_fast, result_wsgi_python)
210+
211+
print()
212+
213+
# Print ASGI results
214+
print_result(result_asgi_python)
215+
if FAST_AVAILABLE:
216+
print_result(result_asgi_fast, result_asgi_python)
217+
218+
return results
219+
220+
221+
def main():
222+
print("HTTP Parser Benchmark")
223+
print("=" * 60)
224+
print(f"Fast parser (gunicorn_h1c): {'Available' if FAST_AVAILABLE else 'Not installed'}")
225+
226+
# Warmup
227+
print("\nWarming up...")
228+
cfg = create_wsgi_config(use_fast=False)
229+
for _ in range(100):
230+
unreader = IterUnreader(iter([SIMPLE_REQUEST]))
231+
Request(cfg, unreader, ('127.0.0.1', 8000), req_number=1)
232+
233+
if FAST_AVAILABLE:
234+
cfg = create_wsgi_config(use_fast=True)
235+
for _ in range(100):
236+
unreader = IterUnreader(iter([SIMPLE_REQUEST]))
237+
Request(cfg, unreader, ('127.0.0.1', 8000), req_number=1)
238+
239+
# Run benchmarks
240+
iterations = 10000
241+
242+
all_results = []
243+
all_results.extend(run_benchmark_suite("Simple GET Request", SIMPLE_REQUEST, iterations))
244+
all_results.extend(run_benchmark_suite("Medium POST Request", MEDIUM_REQUEST, iterations))
245+
all_results.extend(run_benchmark_suite("Complex POST Request", COMPLEX_REQUEST, iterations))
246+
247+
# Summary
248+
print("\n" + "=" * 60)
249+
print("SUMMARY")
250+
print("=" * 60)
251+
252+
if FAST_AVAILABLE:
253+
# Calculate overall speedups
254+
wsgi_python_avg = statistics.mean([r.avg_time_us for r in all_results if r.name == "WSGI python"])
255+
wsgi_fast_avg = statistics.mean([r.avg_time_us for r in all_results if r.name == "WSGI fast"])
256+
asgi_python_avg = statistics.mean([r.avg_time_us for r in all_results if r.name == "ASGI python"])
257+
asgi_fast_avg = statistics.mean([r.avg_time_us for r in all_results if r.name == "ASGI fast"])
258+
259+
print(f"\nWSGI: Fast parser is {wsgi_python_avg/wsgi_fast_avg:.2f}x faster than Python parser")
260+
print(f"ASGI: Fast parser is {asgi_python_avg/asgi_fast_avg:.2f}x faster than Python parser")
261+
else:
262+
print("\nInstall gunicorn_h1c to see fast parser comparison:")
263+
print(" pip install gunicorn_h1c")
264+
265+
print()
266+
267+
268+
if __name__ == "__main__":
269+
main()

benchmarks/simple_app.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,18 @@
44

55
# Simple WSGI app for benchmarking
66

7+
import time
8+
9+
710
def application(environ, start_response):
811
"""Basic hello world response."""
912
path = environ.get('PATH_INFO', '/')
1013

1114
if path == '/large':
1215
body = b'X' * 65536 # 64KB
16+
elif path == '/slow':
17+
time.sleep(0.01) # 10ms simulated I/O
18+
body = b'Slow response'
1319
else:
1420
body = b'Hello, World!'
1521

docs/content/asgi.md

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ gunicorn main:app --worker-class asgi --bind 0.0.0.0:8000
2424
The ASGI worker provides:
2525

2626
- **HTTP/1.1** with keepalive connections
27+
- **HTTP/2** with multiplexing and server push (requires SSL)
2728
- **WebSocket** support for real-time applications
2829
- **Lifespan protocol** for startup/shutdown hooks
29-
- **Optional uvloop** for improved performance
30+
- **Optional fast HTTP parser** via C extension for high throughput
31+
- **Optional uvloop** for improved event loop performance
3032
- **SSL/TLS** support
3133
- **uWSGI protocol** for nginx `uwsgi_pass` integration
3234

@@ -225,6 +227,56 @@ asgi_loop = "auto" # Use uvloop if available
225227
asgi_lifespan = "auto" # Auto-detect lifespan support
226228
```
227229

230+
## Performance
231+
232+
### Fast HTTP Parser
233+
234+
For maximum performance, install the optional `gunicorn_h1c` C extension:
235+
236+
```bash
237+
pip install gunicorn[fast]
238+
```
239+
240+
This provides a high-performance HTTP parser using picohttpparser with SIMD
241+
optimizations, offering significant speedups for HTTP parsing compared to the
242+
pure Python implementation.
243+
244+
The parser is automatically used when available (`--http-parser auto`), or you
245+
can explicitly require it:
246+
247+
```bash
248+
gunicorn myapp:app --worker-class asgi --http-parser fast
249+
```
250+
251+
| Parser | Description |
252+
|--------|-------------|
253+
| `auto` | Use fast parser if available, otherwise Python (default) |
254+
| `fast` | Require fast parser, fail if unavailable |
255+
| `python` | Force pure Python parser |
256+
257+
### Performance Tips
258+
259+
1. **Use uvloop** for improved event loop performance:
260+
```bash
261+
pip install uvloop
262+
gunicorn myapp:app --worker-class asgi --asgi-loop uvloop
263+
```
264+
265+
2. **Install the fast parser** for optimized HTTP parsing:
266+
```bash
267+
pip install gunicorn[fast]
268+
```
269+
270+
3. **Tune worker count** based on CPU cores:
271+
```bash
272+
gunicorn myapp:app --worker-class asgi --workers $(nproc)
273+
```
274+
275+
4. **Increase connections** for I/O-bound applications:
276+
```bash
277+
gunicorn myapp:app --worker-class asgi --worker-connections 2000
278+
```
279+
228280
## Comparison with Other ASGI Servers
229281

230282
| Feature | Gunicorn ASGI | Uvicorn | Hypercorn |

docs/content/news.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33

44
## unreleased
55

6+
### New Features
7+
8+
- **Fast HTTP Parser (gunicorn_h1c 0.4.1)**: Integrate new exception types and limit
9+
parameters from gunicorn_h1c 0.4.1 for both WSGI and ASGI workers
10+
- Requires gunicorn_h1c >= 0.4.1 for `http_parser='fast'`
11+
- Falls back to Python parser in `auto` mode if version not met
12+
- Proper HTTP status codes for limit errors (414, 431)
13+
614
### Performance
715

816
- **ASGI HTTP Parser Optimizations**: Improve ASGI worker HTTP parsing performance

docs/content/reference/settings.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1971,3 +1971,23 @@ need to increase this value.
19711971
This setting only affects the ``asgi`` worker type.
19721972

19731973
!!! info "Added in 25.0.0"
1974+
1975+
### `http_parser`
1976+
1977+
**Command line:** `--http-parser STRING`
1978+
1979+
**Default:** `'auto'`
1980+
1981+
HTTP parser implementation for ASGI workers.
1982+
1983+
- auto: Use H1CProtocol if gunicorn_h1c is available, else PythonProtocol (default)
1984+
- fast: Require H1CProtocol from gunicorn_h1c (fail if unavailable)
1985+
- python: Force pure Python PythonProtocol parser
1986+
1987+
ASGI workers use callback-based parsing in data_received() for efficient
1988+
incremental parsing. The gunicorn_h1c C extension provides significantly
1989+
faster HTTP parsing using picohttpparser with SIMD optimizations.
1990+
1991+
Install it with: pip install gunicorn[fast]
1992+
1993+
!!! info "Added in 25.0.0"

0 commit comments

Comments
 (0)