Skip to content

Commit 2845874

Browse files
committed
Benchmark script
1 parent efe1bf8 commit 2845874

1 file changed

Lines changed: 242 additions & 0 deletions

File tree

benchmarks/benchmark.py

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
#!/usr/bin/env python
2+
"""
3+
Throughput / latency benchmark for the scrapy-playwright download handler.
4+
5+
Unlike the test suite (which checks correctness by downloading each URL once),
6+
this script drives *hundreds* of requests through ``handler._download_request``
7+
at a controlled concurrency level and reports steady-state performance:
8+
9+
* throughput (requests / second)
10+
* per-request latency distribution (p50 / p90 / p95 / p99 / max)
11+
* peak concurrent pages and contexts (from the handler's own stats)
12+
* peak RSS of the Playwright process tree (if ``psutil`` is installed)
13+
14+
All traffic is served by the in-repo mock servers (no network variance), so the
15+
numbers reflect the cost of the *handler* — page creation/teardown, request
16+
routing, navigation and response building — rather than the cost of the remote
17+
site.
18+
19+
Examples
20+
--------
21+
# 500 requests, 16 in flight, against the static HTML site
22+
python benchmarks/benchmark.py --requests 500 --concurrency 16
23+
24+
# Compare browsers
25+
python benchmarks/benchmark.py --browser firefox
26+
27+
# Reuse a single page per request vs. create+destroy (the default)
28+
python benchmarks/benchmark.py --reuse-page
29+
30+
# Exercise page methods (scroll an infinite-scroll page) to simulate
31+
# heavier, JS-driven pages
32+
python benchmarks/benchmark.py --page-methods scroll
33+
34+
# Add a server-side delay to model network latency / slow backends
35+
python benchmarks/benchmark.py --server-delay 0.2 --concurrency 32
36+
"""
37+
38+
import argparse
39+
import asyncio
40+
import statistics
41+
import sys
42+
import time
43+
from contextlib import suppress
44+
from pathlib import Path
45+
from typing import List, Optional
46+
47+
# Allow running from the repo root without installing the package as a script.
48+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
49+
50+
# The handler requires the asyncio Twisted reactor; install it before anything
51+
# touches twisted.internet.reactor (mirrors tests/conftest.py).
52+
from twisted.internet.asyncioreactor import install as _install_reactor # noqa: E402
53+
from twisted.internet.error import ReactorAlreadyInstalledError # noqa: E402
54+
55+
with suppress(ReactorAlreadyInstalledError):
56+
_install_reactor()
57+
58+
from scrapy import Request, Spider # noqa: E402
59+
60+
from tests import make_handler # noqa: E402
61+
from tests.mockserver import MockServer, StaticMockServer # noqa: E402
62+
from scrapy_playwright.page import PageMethod # noqa: E402
63+
64+
65+
def _percentile(values: List[float], pct: float) -> float:
66+
"""Nearest-rank percentile (pct in [0, 100])."""
67+
if not values:
68+
return 0.0
69+
ordered = sorted(values)
70+
rank = max(0, min(len(ordered) - 1, round(pct / 100 * len(ordered)) - 1))
71+
return ordered[rank]
72+
73+
74+
class _MemorySampler:
75+
"""Polls the RSS of the Playwright process tree and records the peak."""
76+
77+
def __init__(self, handler, interval: float = 0.25) -> None:
78+
self._handler = handler
79+
self._interval = interval
80+
self._task: Optional[asyncio.Task] = None
81+
self.peak_bytes = 0
82+
try:
83+
import psutil # noqa: F401
84+
85+
self._psutil = psutil
86+
except ImportError:
87+
self._psutil = None
88+
89+
def _proc(self):
90+
with suppress(Exception):
91+
pid = self._handler.playwright_context_manager._connection._transport._proc.pid
92+
return self._psutil.Process(pid)
93+
return None
94+
95+
def _tree_rss(self, proc) -> int:
96+
procs = [proc, *proc.children(recursive=True)]
97+
total = 0
98+
for p in procs:
99+
with suppress(Exception):
100+
total += p.memory_info().rss
101+
return total
102+
103+
async def _run(self) -> None:
104+
while True:
105+
proc = self._proc()
106+
if proc is not None:
107+
self.peak_bytes = max(self.peak_bytes, self._tree_rss(proc))
108+
await asyncio.sleep(self._interval)
109+
110+
def start(self) -> None:
111+
if self._psutil is not None:
112+
self._task = asyncio.ensure_future(self._run())
113+
114+
async def stop(self) -> None:
115+
if self._task is not None:
116+
self._task.cancel()
117+
with suppress(asyncio.CancelledError):
118+
await self._task
119+
120+
121+
def _build_request(url: str, args: argparse.Namespace, page=None) -> Request:
122+
meta: dict = {"playwright": True}
123+
if args.reuse_page:
124+
# Ask the handler to hand the page back instead of closing it...
125+
meta["playwright_include_page"] = True
126+
# ...and feed a previously-used page back in so the handler reuses it
127+
# (it only creates a new page when this key is missing or closed).
128+
if page is not None:
129+
meta["playwright_page"] = page
130+
if args.page_methods == "scroll":
131+
meta["playwright_page_methods"] = [
132+
PageMethod("evaluate", "window.scrollBy(0, document.body.scrollHeight)"),
133+
PageMethod("wait_for_timeout", 50),
134+
]
135+
return Request(url, meta=meta, dont_filter=True)
136+
137+
138+
async def _run(args: argparse.Namespace) -> None:
139+
settings = {
140+
"PLAYWRIGHT_BROWSER_TYPE": args.browser,
141+
# Allow the requested concurrency to actually run in parallel.
142+
"PLAYWRIGHT_MAX_PAGES_PER_CONTEXT": args.concurrency,
143+
"LOG_LEVEL": "WARNING",
144+
}
145+
146+
server_cm = MockServer() if args.server_delay else StaticMockServer()
147+
with server_cm as server:
148+
if args.server_delay:
149+
url = server.urljoin(f"/asdf?delay={args.server_delay}")
150+
else:
151+
url = server.urljoin(f"/{args.path}")
152+
153+
async with make_handler(settings) as handler:
154+
spider = Spider("benchmark")
155+
sem = asyncio.Semaphore(args.concurrency)
156+
latencies: List[float] = []
157+
# Pool of open pages to reuse across requests (reuse mode only). It
158+
# holds at most ``concurrency`` pages: a task grabs one if free,
159+
# otherwise the handler creates a fresh page that then joins the pool.
160+
pool: "asyncio.Queue" = asyncio.Queue()
161+
162+
async def one_request() -> None:
163+
async with sem:
164+
page = None
165+
if args.reuse_page:
166+
with suppress(asyncio.QueueEmpty):
167+
page = pool.get_nowait()
168+
req = _build_request(url, args, page)
169+
start = time.perf_counter()
170+
resp = await handler._download_request(req, spider)
171+
latencies.append(time.perf_counter() - start)
172+
if args.reuse_page:
173+
returned = resp.meta.get("playwright_page")
174+
if returned is not None and not returned.is_closed():
175+
pool.put_nowait(returned)
176+
177+
# Warmup (browser launch + first contexts) is excluded from results.
178+
if args.warmup:
179+
await asyncio.gather(*(one_request() for _ in range(args.warmup)))
180+
latencies.clear()
181+
182+
mem = _MemorySampler(handler)
183+
mem.start()
184+
wall_start = time.perf_counter()
185+
await asyncio.gather(*(one_request() for _ in range(args.requests)))
186+
wall = time.perf_counter() - wall_start
187+
await mem.stop()
188+
189+
while not pool.empty():
190+
with suppress(Exception):
191+
await pool.get_nowait().close()
192+
193+
stats = handler.stats.get_stats()
194+
195+
_report(args, wall, latencies, stats, mem)
196+
197+
198+
def _report(args, wall, latencies, stats, mem) -> None:
199+
n = len(latencies)
200+
print("\n=== scrapy-playwright benchmark ===")
201+
print(f"browser : {args.browser}")
202+
print(f"requests : {n}")
203+
print(f"target concurrency: {args.concurrency}")
204+
print(f"page reuse : {args.reuse_page}")
205+
print(f"page methods : {args.page_methods or 'none'}")
206+
print(f"server delay : {args.server_delay}s")
207+
print("-" * 35)
208+
print(f"wall time : {wall:.2f} s")
209+
print(f"throughput : {n / wall:.1f} req/s")
210+
print(f"latency mean : {statistics.mean(latencies) * 1000:.0f} ms")
211+
print(f"latency p50 : {_percentile(latencies, 50) * 1000:.0f} ms")
212+
print(f"latency p90 : {_percentile(latencies, 90) * 1000:.0f} ms")
213+
print(f"latency p95 : {_percentile(latencies, 95) * 1000:.0f} ms")
214+
print(f"latency p99 : {_percentile(latencies, 99) * 1000:.0f} ms")
215+
print(f"latency max : {max(latencies) * 1000:.0f} ms")
216+
print("-" * 35)
217+
print(f"pages created : {stats.get('playwright/page_count')}")
218+
print(f"peak pages : {stats.get('playwright/page_count/max_concurrent')}")
219+
print(f"peak contexts : {stats.get('playwright/context_count/max_concurrent')}")
220+
print(f"pw requests : {stats.get('playwright/request_count')}")
221+
print(f"pw responses : {stats.get('playwright/response_count')}")
222+
if mem.peak_bytes:
223+
print(f"peak RSS (pw) : {mem.peak_bytes / 1024 ** 2:.0f} MiB")
224+
else:
225+
print("peak RSS (pw) : n/a (install psutil)")
226+
227+
228+
def _parse_args() -> argparse.Namespace:
229+
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
230+
p.add_argument("--requests", type=int, default=300, help="number of timed requests (default: 300)")
231+
p.add_argument("--concurrency", type=int, default=8, help="max in-flight requests (default: 8)")
232+
p.add_argument("--warmup", type=int, default=8, help="untimed warmup requests (default: 8)")
233+
p.add_argument("--browser", default="chromium", choices=["chromium", "firefox", "webkit"])
234+
p.add_argument("--path", default="index.html", help="static path to fetch (default: index.html)")
235+
p.add_argument("--reuse-page", action="store_true", help="keep+close a page per request")
236+
p.add_argument("--page-methods", choices=["scroll"], help="run page methods to simulate heavier pages")
237+
p.add_argument("--server-delay", type=float, default=0.0, help="server-side delay in seconds")
238+
return p.parse_args()
239+
240+
241+
if __name__ == "__main__":
242+
asyncio.run(_run(_parse_args()))

0 commit comments

Comments
 (0)