Skip to content

Commit 3de1ee5

Browse files
Reflexclaude
andcommitted
chore(loadtest): default sync CONCURRENCY cap to 500
The blocking client is GIL-bound, so more worker threads add contention without raising throughput. Measured against the dev cluster at 20k HTTP/1.1 requests, a 500-worker pool beat 5000 on throughput (260 vs 199 req/s) with ~10x lower p50 latency (1.6s vs 16.9s). Document the rationale in the module docstring and README. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f741861 commit 3de1ee5

2 files changed

Lines changed: 15 additions & 7 deletions

File tree

loadtest/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,4 @@ file-descriptor limit (`ulimit -n 65536`) or keep `REQUEST_COUNT` small.
6161
| `NUM_CONNECTIONS` | `10` (`h2_test.py`) / `20` (`raw_fetch_test.py`) | Parallel connections |
6262
| `USE_HTTP2` | `1` | `0` to force HTTP/1.1 in `loadtest.py` |
6363
| `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. |
64+
| `CONCURRENCY` | `500` | Worker-thread cap for the sync path. Effective workers = `min(REQUEST_COUNT, CONCURRENCY)`; above the cap, requests queue through the pool. The sync client is GIL-bound, so raising this past a few hundred adds contention without improving throughput (a 500-worker pool beat 5000 at 20k requests: 260 vs 199 req/s, ~10x lower p50 latency). |

loadtest/loadtest.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,18 @@
1919
hosts the open-file-descriptor limit caps real socket concurrency well below
2020
that anyway), so the worker pool is capped:
2121
22-
workers = min(REQUEST_COUNT, CONCURRENCY) # CONCURRENCY default 5000
22+
workers = min(REQUEST_COUNT, CONCURRENCY) # CONCURRENCY default 500
2323
2424
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.
25+
sync path "concurrency" means the worker count, not REQUEST_COUNT.
26+
27+
The default cap is 500. The blocking client is GIL-bound, so adding threads
28+
past a few hundred does not raise throughput and actively hurts: in testing
29+
against the dev cluster (20 000 HTTP/1.1 requests), a 500-worker pool beat a
30+
5000-worker pool on throughput (260 vs 199 req/s) with ~10x lower p50 latency
31+
(1.6s vs 16.9s), because 5000 threads add GIL contention and context-switching
32+
with no upside. Raise `CONCURRENCY` only if you have evidence it helps on your
33+
host; lower it if thread/FD pressure dominates.
2834
"""
2935

3036
from __future__ import annotations
@@ -46,8 +52,10 @@
4652
USE_HTTP2 = os.environ.get("USE_HTTP2", "1") == "1"
4753
# Async (asyncio) by default; set USE_SYNC=1 to benchmark the blocking client.
4854
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"))
55+
# Worker-thread cap for the sync path (see module docstring). Default 500:
56+
# the blocking client is GIL-bound, so more threads add contention without
57+
# raising throughput (500 beat 5000 in testing — see docstring).
58+
SYNC_WORKER_CAP = int(os.environ.get("CONCURRENCY", "500"))
5159
PROGRESS_INTERVAL = 2.0
5260

5361

0 commit comments

Comments
 (0)