Skip to content

Commit f741861

Browse files
Reflexclaude
andcommitted
feat(loadtest): add USE_SYNC to benchmark the blocking client
USE_SYNC=1 drives devboxes.create through the blocking Runloop client on a ThreadPoolExecutor instead of async AsyncRunloop + asyncio. The sync worker pool is capped at min(REQUEST_COUNT, CONCURRENCY) with CONCURRENCY defaulting to 5000, since one-thread-per-request is infeasible at high counts and real socket concurrency is FD-bound anyway. Works with USE_HTTP2 for both h1 and h2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4ef3c65 commit f741861

2 files changed

Lines changed: 140 additions & 22 deletions

File tree

loadtest/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ client + server _request handling_ from provisioning.
1515

1616
| Script | Transport under test |
1717
| --- | --- |
18-
| `loadtest.py` | The **SDK** itself (`AsyncRunloop`). `USE_HTTP2=0` → HTTP/1.1; default / `USE_HTTP2=1` → HTTP/2 (shared httpx pool). Always runs against the installed package in this checkout. |
18+
| `loadtest.py` | The **SDK** itself. `USE_HTTP2=0` → HTTP/1.1; default / `USE_HTTP2=1` → HTTP/2 (shared httpx pool). `USE_SYNC=0` (default) → async `AsyncRunloop` via asyncio; `USE_SYNC=1` → blocking `Runloop` via a thread pool. Always runs against the installed package in this checkout. |
1919
| `h2_test.py` | Raw `httpx` HTTP/2, bypassing the SDK. Configurable connection count. |
2020
| `h2_single_conn.py` | Raw `httpx` HTTP/2 on a single warmed connection (50-request burst). |
2121
| `raw_fetch_test.py` | Raw `httpx` HTTP/1.1 keep-alive baseline. |
@@ -33,6 +33,10 @@ cd /path/to/api-client-python && uv sync
3333
source ~/env && REQUEST_COUNT=2000 uv run python loadtest/loadtest.py # HTTP/2
3434
source ~/env && REQUEST_COUNT=2000 USE_HTTP2=0 uv run python loadtest/loadtest.py # HTTP/1.1
3535

36+
# SDK: async (default) vs sync client
37+
source ~/env && REQUEST_COUNT=2000 USE_SYNC=1 uv run python loadtest/loadtest.py # sync, HTTP/2
38+
source ~/env && REQUEST_COUNT=2000 USE_SYNC=1 CONCURRENCY=500 uv run python loadtest/loadtest.py # cap the thread pool
39+
3640
# Raw httpx HTTP/2 vs HTTP/1.1 comparison
3741
source ~/env && uv run python loadtest/h2_test.py
3842
source ~/env && uv run python loadtest/raw_fetch_test.py
@@ -56,3 +60,5 @@ file-descriptor limit (`ulimit -n 65536`) or keep `REQUEST_COUNT` small.
5660
| `REQUEST_COUNT` | `100000` (`loadtest.py`) / `10000` (`h2_test.py`) / `500` (`raw_fetch_test.py`) | Total requests |
5761
| `NUM_CONNECTIONS` | `10` (`h2_test.py`) / `20` (`raw_fetch_test.py`) | Parallel connections |
5862
| `USE_HTTP2` | `1` | `0` to force HTTP/1.1 in `loadtest.py` |
63+
| `USE_SYNC` | `0` | `1` to benchmark the blocking `Runloop` client (thread pool) instead of async `AsyncRunloop` |
64+
| `CONCURRENCY` | `5000` | Worker-thread cap for the sync path. Effective workers = `min(REQUEST_COUNT, CONCURRENCY)`; above the cap, requests queue through the pool. Lower it (e.g. `500`) if thread/FD pressure dominates. |

loadtest/loadtest.py

Lines changed: 133 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,30 @@
1-
"""SDK load test: fires devboxes.create bursts via AsyncRunloop.
1+
"""SDK load test: fires devboxes.create bursts via the Runloop SDK.
22
33
Imports the installed package from this checkout so the benchmark always
44
exercises the exact code here — not a separately published build.
55
66
USE_HTTP2=0 → HTTP/1.1 (httpx with http2=False)
77
default → HTTP/2 (shared httpx pool with http2=True)
8+
9+
USE_SYNC=1 → blocking sync `Runloop` client driven by a thread pool
10+
default → async `AsyncRunloop` client driven by asyncio
11+
812
REQUEST_COUNT defaults to 100 000.
13+
14+
Sync concurrency model
15+
----------------------
16+
The sync `Runloop` client is blocking, so a concurrent burst is driven by a
17+
`ThreadPoolExecutor` rather than `asyncio.gather`. One OS thread per request is
18+
infeasible at high request counts (memory + scheduler overhead, and on most
19+
hosts the open-file-descriptor limit caps real socket concurrency well below
20+
that anyway), so the worker pool is capped:
21+
22+
workers = min(REQUEST_COUNT, CONCURRENCY) # CONCURRENCY default 5000
23+
24+
At request counts above the cap, requests queue through the pool — so for the
25+
sync path "concurrency" means the worker count, not REQUEST_COUNT. 5000 is the
26+
starting default; lower `CONCURRENCY` (e.g. 500) if thread/FD pressure makes it
27+
counterproductive on your host.
928
"""
1029

1130
from __future__ import annotations
@@ -14,19 +33,25 @@
1433
import math
1534
import time
1635
import asyncio
36+
import threading
37+
from concurrent.futures import ThreadPoolExecutor, as_completed
1738

1839
import httpx
1940

20-
from runloop_api_client import AsyncRunloop
41+
from runloop_api_client import Runloop, AsyncRunloop
2142

2243
REQUEST_COUNT = int(os.environ.get("REQUEST_COUNT", "100000"))
2344
RUNLOOP_BASE_URL = os.environ.get("RUNLOOP_BASE_URL")
2445
# HTTP/2 is the SDK default; set USE_HTTP2=0 to benchmark HTTP/1.1.
2546
USE_HTTP2 = os.environ.get("USE_HTTP2", "1") == "1"
47+
# Async (asyncio) by default; set USE_SYNC=1 to benchmark the blocking client.
48+
USE_SYNC = os.environ.get("USE_SYNC", "0") == "1"
49+
# Worker-thread cap for the sync path (see module docstring).
50+
SYNC_WORKER_CAP = int(os.environ.get("CONCURRENCY", "5000"))
2651
PROGRESS_INTERVAL = 2.0
2752

2853

29-
def build_client() -> AsyncRunloop:
54+
def build_async_client() -> AsyncRunloop:
3055
kwargs: dict[str, object] = {"max_retries": 0, "timeout": 120.0}
3156
if RUNLOOP_BASE_URL:
3257
kwargs["base_url"] = RUNLOOP_BASE_URL
@@ -41,17 +66,57 @@ def build_client() -> AsyncRunloop:
4166
return AsyncRunloop(**kwargs) # type: ignore[arg-type]
4267

4368

44-
async def send_request(client: AsyncRunloop, index: int, run_id: str) -> dict[str, object]:
69+
def build_sync_client() -> Runloop:
70+
kwargs: dict[str, object] = {"max_retries": 0, "timeout": 120.0}
71+
if RUNLOOP_BASE_URL:
72+
kwargs["base_url"] = RUNLOOP_BASE_URL
73+
if not USE_HTTP2:
74+
kwargs["http_client"] = httpx.Client(
75+
http2=False,
76+
limits=httpx.Limits(max_connections=None, max_keepalive_connections=100),
77+
timeout=httpx.Timeout(120.0),
78+
)
79+
return Runloop(**kwargs) # type: ignore[arg-type]
80+
81+
82+
_CREATE_KWARGS = {
83+
"blueprint_id": "bp_nonexistent_loadtest_00000",
84+
"environment_variables": {"TEST_VAR_1": "value_one", "TEST_VAR_2": "value_two"},
85+
"launch_parameters": {"resource_size_request": "SMALL", "keep_alive_time_seconds": 300},
86+
}
87+
88+
89+
async def send_request_async(client: AsyncRunloop, index: int, run_id: str) -> dict[str, object]:
4590
start = time.perf_counter()
4691
status: int | None = None
4792
error: str | None = None
4893
try:
4994
await client.devboxes.create(
50-
blueprint_id="bp_nonexistent_loadtest_00000",
5195
name=f"loadtest-{run_id}-{index}",
52-
environment_variables={"TEST_VAR_1": "value_one", "TEST_VAR_2": "value_two"},
5396
metadata={"test_run": run_id, "index": str(index)},
54-
launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 300},
97+
**_CREATE_KWARGS, # type: ignore[arg-type]
98+
)
99+
status = 200
100+
except Exception as exc:
101+
status = getattr(exc, "status_code", None)
102+
error = str(exc) or type(exc).__name__
103+
return {
104+
"index": index,
105+
"latency_ms": (time.perf_counter() - start) * 1000,
106+
"status": status,
107+
"error": error,
108+
}
109+
110+
111+
def send_request_sync(client: Runloop, index: int, run_id: str) -> dict[str, object]:
112+
start = time.perf_counter()
113+
status: int | None = None
114+
error: str | None = None
115+
try:
116+
client.devboxes.create(
117+
name=f"loadtest-{run_id}-{index}",
118+
metadata={"test_run": run_id, "index": str(index)},
119+
**_CREATE_KWARGS, # type: ignore[arg-type]
55120
)
56121
status = 200
57122
except Exception as exc:
@@ -106,31 +171,34 @@ def print_metrics(results: list[dict[str, object]], wall_ms: float) -> None:
106171
print(f" {c}x {msg}")
107172

108173

109-
async def main() -> None:
110-
fd_limit: int | None = None
174+
def _fd_limit() -> int | None:
111175
try:
112176
import resource as _resource
113177

114-
fd_limit = _resource.getrlimit(_resource.RLIMIT_NOFILE)[0]
178+
return _resource.getrlimit(_resource.RLIMIT_NOFILE)[0]
115179
except Exception:
116-
pass
180+
return None
117181

118-
if not USE_HTTP2 and fd_limit is not None and fd_limit < 10000:
119-
print(f"\nWARNING: File descriptor limit is {fd_limit}. For large HTTP/1.1 bursts, run:")
120-
print(" ulimit -n 65536")
121-
print("Or use HTTP/2 multiplexing: USE_HTTP2=1\n")
122-
123-
client = build_client()
124-
run_id = f"run-{int(time.time())}"
125182

126-
print(f"Starting load test: {REQUEST_COUNT} concurrent requests")
183+
def _print_header(run_id: str, fd_limit: int | None, workers: int | None) -> None:
184+
print(f"Starting load test: {REQUEST_COUNT} requests")
127185
print(f"Run ID: {run_id}")
186+
mode = "sync (ThreadPoolExecutor)" if USE_SYNC else "async (asyncio)"
187+
print(f"Concurrency: {mode}")
188+
if USE_SYNC:
189+
print(f"Workers: {workers} threads")
128190
print(f"HTTP mode: {'HTTP/2 (shared httpx pool)' if USE_HTTP2 else 'HTTP/1.1 (httpx http2=False)'}")
129191
print(f"Base URL: {RUNLOOP_BASE_URL or '(SDK default)'}")
130192
if fd_limit is not None:
131193
print(f"FD limit: {fd_limit}")
132194
print()
133195

196+
197+
async def run_async() -> None:
198+
client = build_async_client()
199+
run_id = f"run-{int(time.time())}"
200+
_print_header(run_id, _fd_limit(), workers=None)
201+
134202
completed = 0
135203

136204
async def progress_printer() -> None:
@@ -141,7 +209,7 @@ async def progress_printer() -> None:
141209

142210
async def wrapped(idx: int) -> dict[str, object]:
143211
nonlocal completed
144-
r = await send_request(client, idx, run_id)
212+
r = await send_request_async(client, idx, run_id)
145213
completed += 1
146214
return r
147215

@@ -155,5 +223,49 @@ async def wrapped(idx: int) -> dict[str, object]:
155223
print_metrics(results, wall_ms)
156224

157225

226+
def run_sync() -> None:
227+
workers = max(1, min(REQUEST_COUNT, SYNC_WORKER_CAP))
228+
client = build_sync_client()
229+
run_id = f"run-{int(time.time())}"
230+
_print_header(run_id, _fd_limit(), workers=workers)
231+
232+
completed = 0
233+
stop = threading.Event()
234+
235+
def progress_printer() -> None:
236+
while not stop.wait(PROGRESS_INTERVAL):
237+
pct = completed / REQUEST_COUNT * 100
238+
print(f" progress: {completed}/{REQUEST_COUNT} ({pct:.1f}%)")
239+
240+
progress_thread = threading.Thread(target=progress_printer, daemon=True)
241+
progress_thread.start()
242+
243+
results: list[dict[str, object]] = []
244+
wall_start = time.perf_counter()
245+
with ThreadPoolExecutor(max_workers=workers) as pool:
246+
futures = [pool.submit(send_request_sync, client, i, run_id) for i in range(REQUEST_COUNT)]
247+
for fut in as_completed(futures):
248+
results.append(fut.result())
249+
completed += 1
250+
wall_ms = (time.perf_counter() - wall_start) * 1000
251+
252+
stop.set()
253+
client.close()
254+
print_metrics(results, wall_ms)
255+
256+
257+
def main() -> None:
258+
fd_limit = _fd_limit()
259+
if not USE_HTTP2 and fd_limit is not None and fd_limit < 10000:
260+
print(f"\nWARNING: File descriptor limit is {fd_limit}. For large HTTP/1.1 bursts, run:")
261+
print(" ulimit -n 65536")
262+
print("Or use HTTP/2 multiplexing: USE_HTTP2=1\n")
263+
264+
if USE_SYNC:
265+
run_sync()
266+
else:
267+
asyncio.run(run_async())
268+
269+
158270
if __name__ == "__main__":
159-
asyncio.run(main())
271+
main()

0 commit comments

Comments
 (0)