Skip to content

Commit b0774fd

Browse files
authored
feat(benchmarks): generate the README comparison table in CI (#535)
Closes #469 — replaces the hand-measured table from #523 with a benchmark harness that measures the latest published clients against a local server in CI, monthly or on demand, and opens a PR when the numbers move.
1 parent 35b0c5d commit b0774fd

11 files changed

Lines changed: 1087 additions & 16 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
name: Refresh comparison benchmark
2+
3+
on:
4+
schedule:
5+
# First of the month; the table tracks other projects' releases, not ours.
6+
- cron: '0 4 1 * *'
7+
workflow_dispatch:
8+
inputs:
9+
requests:
10+
description: Requests per run
11+
default: '2000'
12+
runs:
13+
description: Runs per client, the median is reported
14+
default: '11'
15+
# Exercise the harness whenever it changes, without proposing anything.
16+
pull_request:
17+
paths:
18+
- benchmarks/**
19+
- .github/workflows/comparison-benchmark.yaml
20+
21+
permissions:
22+
contents: read
23+
24+
concurrency:
25+
group: comparison-benchmark
26+
cancel-in-progress: false
27+
28+
env:
29+
BRANCH: benchmarks/comparison-refresh
30+
REQUESTS: ${{ inputs.requests || '2000' }}
31+
RUNS: ${{ inputs.runs || '11' }}
32+
33+
jobs:
34+
benchmark:
35+
name: Measure
36+
runs-on: ubuntu-latest
37+
timeout-minutes: 60
38+
steps:
39+
# No credentials in this job: it installs and executes unpinned releases of
40+
# every client, so it must have nothing worth stealing. Reading a public
41+
# repository needs no token.
42+
- name: Checkout
43+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
44+
with:
45+
ref: ${{ github.event_name == 'pull_request' && github.sha || 'master' }}
46+
persist-credentials: false
47+
48+
- name: Setup Node.js
49+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
50+
with:
51+
node-version: 24
52+
53+
- name: Setup uv
54+
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
55+
56+
# No lockfiles anywhere here on purpose: the point is to compare whatever
57+
# each project publishes today.
58+
- name: Install the Node.js clients
59+
run: npm install --prefix benchmarks/node --no-audit --no-fund --no-package-lock
60+
61+
- name: Install the Python clients
62+
run: |
63+
uv venv --seed --python 3.12 benchmarks/python/.venv
64+
uv pip install --python benchmarks/python/.venv/bin/python -r benchmarks/python/requirements.txt
65+
66+
- name: Benchmark the Node.js clients
67+
run: node benchmarks/node/bench.mjs --requests "$REQUESTS" --runs "$RUNS"
68+
69+
- name: Benchmark the Python clients
70+
run: benchmarks/python/.venv/bin/python benchmarks/python/bench.py --requests "$REQUESTS" --runs "$RUNS"
71+
72+
- name: Rewrite the README table
73+
run: node benchmarks/update-readme.mjs
74+
75+
- name: Upload the rewritten README and raw measurements
76+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
77+
if: always()
78+
with:
79+
name: comparison-results
80+
path: |
81+
README.md
82+
benchmarks/results-*.json
83+
84+
# Separate runner, so the token never shares an environment with third-party
85+
# code. This job installs nothing.
86+
propose:
87+
name: Open a pull request
88+
needs: benchmark
89+
if: github.event_name != 'pull_request'
90+
runs-on: ubuntu-latest
91+
steps:
92+
- name: Checkout
93+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
94+
with:
95+
ref: master
96+
token: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }}
97+
98+
- name: Download the rewritten README
99+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
100+
with:
101+
name: comparison-results
102+
103+
- name: Open a pull request
104+
env:
105+
GH_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }}
106+
run: |
107+
# Without this, an artifact that unpacked somewhere unexpected would
108+
# look exactly like "the numbers did not move".
109+
test -f benchmarks/results-node.json || { echo 'the artifact did not unpack where expected'; exit 1; }
110+
111+
if git diff --quiet -- README.md; then
112+
echo "The measurements did not move the table; nothing to propose."
113+
exit 0
114+
fi
115+
116+
git config user.name 'apify-service-account'
117+
git config user.email 'apify-service-account@users.noreply.github.com'
118+
git checkout -B "$BRANCH"
119+
git commit -m 'docs: refresh the client comparison benchmark' -- README.md
120+
git push --force origin "$BRANCH"
121+
122+
if [ -z "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[].number')" ]; then
123+
gh pr create --base master --head "$BRANCH" \
124+
--title 'docs: refresh the client comparison benchmark' \
125+
--body "Measured by \`.github/workflows/comparison-benchmark.yaml\`, $RUNS runs of $REQUESTS requests per client. Raw numbers are attached to [the run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})."
126+
else
127+
echo "The existing pull request now points at the new measurements."
128+
fi

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,9 @@ venv
1313
*.so
1414
*.py[cod]
1515
_build/
16+
17+
# Benchmarks
18+
/benchmarks/.cert
19+
/benchmarks/results-*.json
20+
/benchmarks/node/node_modules
21+
/benchmarks/node/package-lock.json

README.md

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,34 +31,33 @@ async fn main() {
3131
}
3232
```
3333

34+
<!-- comparison:start -->
3435
### Comparison
3536

36-
Sequential requests from a single client against a local Node.js HTTP/2 server (1 KiB JSON body, keep-alive), best of 11 runs of 2000 requests, pinned to one core. Profile counts are the distinct versioned impersonation targets exposed by the public API. Numbers are indicative — rerun them on your own hardware before drawing conclusions.
37+
Median of 11 runs of 2000 sequential requests to a local HTTP/2 server, 1 KiB JSON responses over one warm connection. `Profiles` counts the impersonation targets each API exposes.
3738

3839
**Python**
3940

4041
| Package | req/s | Wheel | Profiles | Backend |
4142
| --- | --- | --- | --- | --- |
42-
| [`rnet`](https://github.com/0x676e67/rnet) | 3808 | 3.7 MB | 75 | Rust |
43-
| [`primp`](https://github.com/deedy5/primp) | 3547 | 5.3 MB | n/a[^1] | Rust |
44-
| **`impit`** | 2885 | 4.3 MB | 20 | Rust |
45-
| [`tls-client`](https://github.com/FlorianREGAZ/Python-Tls-Client) | 1831 | 41.3 MB | 51 | Go |
46-
| [`curl_cffi`](https://github.com/lexiforest/curl_cffi) | 1548 | 13.5 MB | 38 | C (libcurl) |
47-
| `httpx` (no impersonation) | 759 | 0.1 MB || Python |
43+
| [`primp`](https://github.com/deedy5/primp) | 6312 | 5.9 MB | n/a | Rust |
44+
| [`rnet`](https://github.com/0x676e67/rnet) | 5093 | 3.7 MB | 75 | Rust |
45+
| **`impit`** | 3808 | 4.2 MB | 20 | Rust |
46+
| [`curl_cffi`](https://github.com/lexiforest/curl_cffi) | 3428 | 13.5 MB | 38 | C (libcurl) |
47+
| [`tls-client`](https://github.com/FlorianREGAZ/Python-Tls-Client) | 3223 | 41.3 MB | 51 | Go |
48+
| `httpx` (no impersonation) | 2311 | 0.1 MB || Python |
4849

4950
**Node.js**
5051

5152
| Package | req/s | Install | Profiles | Backend |
5253
| --- | --- | --- | --- | --- |
53-
| **`impit`** | 1353 | 8.7 MB | 20 | Rust |
54-
| [`got-scraping`](https://github.com/apify/got-scraping) | 1149[^2] | 5.2 MB | 3[^3] | Node.js TLS |
55-
| [`node-tls-client`](https://github.com/Sahil1337/node-tls-client) | 901 | 31.1 MB | 63 | Go |
56-
| [`cycletls`](https://github.com/Danny-Dasilva/CycleTLS) | 287 | 133.3 MB | raw JA3 | Go subprocess |
57-
| `undici` (no impersonation) | 2030 | 2.0 MB || Node.js |
58-
59-
[^1]: `primp` accepts arbitrary version strings and snaps to the nearest shipped profile, so the set is not enumerable through the public API.
60-
[^2]: Over HTTP/1.1 — with HTTP/2 enabled the server closes the session with `GOAWAY` after roughly a thousand requests.
61-
[^3]: Cipher suite and signature algorithm order only; no control over extension order, GREASE, or HTTP/2 `SETTINGS`.
54+
| [`got-scraping`](https://github.com/apify/got-scraping) | 2332 | 4.7 MB | 3 | Node.js TLS |
55+
| **`impit`** | 2260 | 8.7 MB | 20 | Rust |
56+
| [`cycletls`](https://github.com/Danny-Dasilva/CycleTLS) | 610 | 133.0 MB | raw JA3 | Go subprocess |
57+
| `undici` (no impersonation) | 4661 | 1.9 MB || Node.js |
58+
59+
Measured by [`benchmarks/`](benchmarks) on linux-x64, 2026-09-03. Rerun it on your own hardware.
60+
<!-- comparison:end -->
6261

6362
### Other projects
6463

benchmarks/README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Comparison benchmark
2+
3+
Generates the comparison tables in the root [`README.md`](../README.md). Nothing is pinned and no
4+
lockfile is committed, so every run measures the latest published release of each client, `impit`
5+
included.
6+
7+
## Running it
8+
9+
```bash
10+
npm install --prefix node --no-audit --no-fund
11+
uv venv --seed --python 3.12 python/.venv
12+
uv pip install --python python/.venv/bin/python -r python/requirements.txt
13+
14+
node node/bench.mjs # writes results-node.json
15+
python/.venv/bin/python python/bench.py # writes results-python.json
16+
node update-readme.mjs # rewrites the table in ../README.md
17+
```
18+
19+
`--requests`, `--runs` and `--warmup` shrink a run while iterating, `--body-bytes` changes the
20+
response size, and `--only impit,undici` limits it to a few clients. Pass the same values to both
21+
scripts — `update-readme.mjs` rejects reports taken with different parameters. `uv venv --seed`
22+
matters, because `bench.py` shells out to `pip download` to size each wheel.
23+
24+
Run the two scripts one after the other, never in parallel: they compete for the same cores and both
25+
sets of numbers come out low.
26+
27+
## What is measured
28+
29+
[`server.mjs`](server.mjs) is the origin — a Node.js `http2` server on a self-signed certificate
30+
serving a 1 KiB JSON body. Each client then issues sequential requests over one connection, N
31+
times, and the median run is reported. Sequential single-client traffic isolates per-request client
32+
overhead, which is what differs between these libraries.
33+
34+
The result files also carry the best and worst run, the negotiated protocol, and how many
35+
connections the client opened while being measured. That last one is worth checking when a number
36+
looks off: `cycletls` opens a fresh connection per request, so its figure includes a handshake every
37+
time.
38+
39+
`Profiles` counts the impersonation targets each public API accepts, minus aliases that resolve to
40+
another target. There is no uniform way to ask for that, so each client has its own accessor in the
41+
`CLIENTS` table.
42+
43+
Sizes are what each ecosystem distributes: the platform wheel for Python, and for Node.js whatever a
44+
fresh `npm install` leaves on disk with its transitive dependencies.
45+
46+
## Notes on the origin
47+
48+
Node's HTTP/2 Rapid-Reset mitigation is off in `server.mjs`. Clients that `RST_STREAM` each response
49+
once they have read it — got's `http2-wrapper` does — otherwise burn the default budget of 1000
50+
resets and take a `GOAWAY` mid-run. Right for a public origin, wrong for a benchmark.
51+
52+
## Adding a client
53+
54+
Add an entry to `CLIENTS`: how to build it, how to issue one request, and how to count its profiles.
55+
`update-readme.mjs` takes care of ordering and the caption.

benchmarks/harness.mjs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { spawn } from 'node:child_process';
2+
import { mkdtemp, readdir, rm, stat } from 'node:fs/promises';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
6+
export function parseArgs(argv, defaults) {
7+
const out = { ...defaults };
8+
for (let i = 0; i < argv.length; i += 1) {
9+
const arg = argv[i];
10+
if (!arg.startsWith('--')) throw new Error(`unexpected argument ${arg}`);
11+
const key = arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
12+
if (!(key in out)) throw new Error(`unknown option ${arg}`);
13+
const value = argv[i + 1];
14+
if (value === undefined || value.startsWith('--')) throw new Error(`${arg} needs a value`);
15+
out[key] = typeof out[key] === 'number' ? Number(value) : value;
16+
i += 1;
17+
}
18+
return out;
19+
}
20+
21+
/**
22+
* `runs` batches of `requests` sequential requests over one warm connection. The
23+
* median is what the table quotes; best and worst come along because some clients
24+
* swing 3x between runs, which a best-of-N figure would flatter.
25+
*/
26+
export async function measure(request, { requests, runs, warmup }) {
27+
for (let i = 0; i < warmup; i += 1) await request();
28+
29+
const rates = [];
30+
for (let run = 0; run < runs; run += 1) {
31+
const start = process.hrtime.bigint();
32+
for (let i = 0; i < requests; i += 1) await request();
33+
const elapsedNs = Number(process.hrtime.bigint() - start);
34+
rates.push((requests * 1e9) / elapsedNs);
35+
}
36+
rates.sort((a, b) => a - b);
37+
return {
38+
rps: rates.at(-1),
39+
rpsMedian: rates[Math.floor(rates.length / 2)],
40+
rpsWorst: rates[0],
41+
};
42+
}
43+
44+
async function treeSize(dir) {
45+
let total = 0;
46+
for (const entry of await readdir(dir, { withFileTypes: true })) {
47+
const path = join(dir, entry.name);
48+
if (entry.isDirectory()) total += await treeSize(path);
49+
else if (entry.isFile()) total += (await stat(path)).size;
50+
}
51+
return total;
52+
}
53+
54+
function run(command, args, options = {}) {
55+
return new Promise((resolve, reject) => {
56+
const child = spawn(command, args, { stdio: ['ignore', 'ignore', 'pipe'], ...options });
57+
let stderr = '';
58+
child.stderr.on('data', (chunk) => { stderr += chunk; });
59+
child.on('error', reject);
60+
child.on('close', (code) => {
61+
if (code === 0) resolve();
62+
else reject(new Error(`${command} exited with ${code}: ${stderr.trim().slice(0, 500)}`));
63+
});
64+
});
65+
}
66+
67+
/** Bytes a fresh `npm install <pkg>` drops on disk, transitive dependencies included. */
68+
export async function installSize(pkg, version) {
69+
const dir = await mkdtemp(join(tmpdir(), 'impit-bench-size-'));
70+
try {
71+
await run('npm', [
72+
'install', `${pkg}@${version}`,
73+
'--prefix', dir,
74+
'--no-save', '--no-audit', '--no-fund', '--loglevel', 'error',
75+
]);
76+
return await treeSize(join(dir, 'node_modules'));
77+
} finally {
78+
await rm(dir, { recursive: true, force: true });
79+
}
80+
}
81+
82+
export function formatMB(bytes) {
83+
return `${(bytes / 1e6).toFixed(1)} MB`;
84+
}

0 commit comments

Comments
 (0)