Results: a private lab server (22 cores, 128 GB). Measured 2026-10-16
The forecast was written first, before any code: the forecast. This page checks each number against it.
Where it was measured. Most of it ran in a 2-core sandbox with 7.8 GB and Python 3.13. RDKit is not installed there, so /api/model was checked with a stand-in for the conformer builder (the routing, not the chemistry). /api/site and the ligand surveys need no RDKit and ran for real. What needs 20 cores is marked lab and is measured on the machine itself with tools/lab/bench.py (see the lab server guide).
Summary
| forecast | measured here | verdict | |
|---|---|---|---|
| P1 · overhead | < 30 ms with the structure held; < 200 ms for a first 20 MB file | held: −18 ms on average over 10 pairs (noise ±60 ms); first 20 MB file: +113 ms and −215 ms | holds |
| P2 · throughput | k at once within 1.25× one | k = 2: 1.02× and 1.07× | holds here; lab for k = 20 |
| P3 · no regression | a request in a worker within 10 % of in-process | 9 of 10 ratios in 0.87–1.02; one 1.18 (+59 ms) | holds within the noise of this box |
| P4 · same answer | byte-identical JSON, five examples | identical apart from timing_ms (5 of 5, twice) | holds, as amended |
| P5 · memory | idle worker < 250 MB; 20 workers < 5 GB; full caches < 70 GB | idle worker 29 MB RSS, 12–14 MB PSS (without RDKit); budgets add up to 50 GB | holds here; lab with RDKit |
| P6 · largest site | close to linear; 12 000 atoms < 20 s, JSON < 40 MB | 1.09–1.45 ms per atom up to 9 517 atoms; 12 000 atoms → 16 s, 24 MB (extrapolated) | holds, mildly superlinear |
| P7 · no re-uploads | later requests < 1 kB | 142–149 bytes (browser) | holds |
| P8 · batch | 156 ligands < 10 s on one core; ≥ 1.6× on 2 workers | 2.95 s; 1.72× (command line); 1.65–1.97× (server, files in the request) | holds; lab for 20 |
| P9 · local dictionary | index < 60 s; lookup < 5 ms | 10.6 ms per MB (≈ 4 s for 400 MB); lookup 0.12 ms median, 1.1 ms p99 | holds |
P1, P3 · what a worker costs
Each of the five requests of P4 ran seven times on two servers with the same profile: one with two worker processes, one computing in its request thread (--workers 0). Each request had its own pH (7.400, 7.401, …), so it was computed and not read from a cache. The structure stayed parsed where it was.
| request | in a worker, median | ratio to in-process (two runs) |
|---|---|---|
| motif 5A7U, zinc finger | 1.13 s | 1.02 · 0.97 |
| motif 7DDO, HExxH of ACE2 | 0.54 s | 0.90 · 0.96 |
| ligand XK2 in 1HVR | 0.53 s | 0.87 · 0.90 |
| ligand STR in 1A28 | 0.40 s | 0.97 · 1.18 |
| ligand HEM in 19HC | 0.34 s | 1.00 · 0.97 |
The differences scatter by ±60 ms either way. On this box the benchmark client, the front process and two workers share two cores, and that scatter is larger than any cost of the pool. Averaged over the ten pairs the worker was 18 ms faster. What a worker adds is small next to that: the job travels as a pickled dict, and the answer comes back as compressed bytes.
The first 20 MB file. The test file was 1HVR copied 144 times into one mmCIF file: 272 160 atoms, 20.0 MB, 28 MB as base64. Its first request took 3.9 s in a worker against 4.1 s in-process, and in a second run 4.07 s against 3.96 s (+113 ms). Reading the file takes 2.6 s of that in both. The second request, by structure_ref, sent 139 bytes and took 0.37 s.
P2 · two requests at once
Each request had its own copy of 1HVR (a REMARK line makes the files differ), so the two land on different workers. Two requests took 1.02× and 1.07× the time of one. On 20 workers this is the number that matters most, and it is measured there.
A change made before measuring. The first design kept every structure on its first two workers (Mitzenmacher's "power of two choices"). On 20 workers, 20 different structures hashed that way would put two or three on some worker while others idle: the balls-into-bins result of Azar, Broder, Karlin and Upfal (1994). The forecast of 20 requests in 1.25× the time of one would then fail for a reason that has nothing to do with Python. So an idle worker now takes any request. It parses the structure again, which costs a fraction of a second next to the seconds a site takes. The first two choices still win whenever they are free.
P4 · the same answer
All five answers were equal in every field except timing_ms, in both runs. The forecast said "byte-identical". That could never hold, because the answer reports its own timing. The forecast should have said "identical apart from the timing".
P5 · memory
| RSS | PSS | |
|---|---|---|
| idle worker, forked from the fork server | 29 MB | 12–14 MB |
| front process, idle | 30 MB | |
| after every benchmark here (caches warm): two workers | 661 MB | |
| the same: front process (the raw files of the uploads) | 212 MB |
PSS divides each shared page among the processes that share it. The fork server imports the app once; each worker is forked from it and shares those pages. RDKit is not in this sandbox, so the idle figure on the lab machine will be higher, by RDKit's share.
The cache budgets of the lab profile on a machine with 22 cores and 125.7 GB (runtime_profile.py):
| per worker (20 workers) | front process | ||
|---|---|---|---|
| parsed structures | 1 608 MB | raw files (for structure_ref) | 3 861 MB |
| computed sites | 772 MB |
That adds up to 50.3 GB when every cache is full. 20 idle workers with RDKit and the front process stay well under 70 GB, as forecast. The budgets count bytes as measured in 16.5.1 (700 B per structure atom, 9 kB per fragment atom), so whether the processes really stop there is checked on the lab machine.
P6 · the largest site
tools/lab/scale_site.py cut segments of growing length from ACE2 (7DDO, chain A, 597 residues without a break) and ran the site pipeline on one core:
| residues | atoms with H | build | orbitals | JSON | total | per atom | JSON size |
|---|---|---|---|---|---|---|---|
| 25 | 409 | 0.01 s | 0.49 s | 0.02 s | 0.52 s | 1.27 ms | 0.9 MB |
| 50 | 781 | 0.02 s | 0.82 s | 0.03 s | 0.87 s | 1.11 ms | 1.7 MB |
| 100 | 1 591 | 0.04 s | 1.63 s | 0.07 s | 1.73 s | 1.09 ms | 3.2 MB |
| 200 | 3 176 | 0.08 s | 3.75 s | 0.13 s | 3.95 s | 1.24 ms | 6.5 MB |
| 400 | 6 300 | 0.15 s | 8.76 s | 0.24 s | 9.14 s | 1.45 ms | 12.8 MB |
| 597 | 9 517 | 0.22 s | 12.17 s | 0.42 s | 12.81 s | 1.35 ms | 19.3 MB |
The time per atom rises by about a quarter between 1 600 and 6 000 atoms, then stays flat. So the pipeline is close to linear but not quite. No protein in the test set has a longer unbroken chain, so 12 000 atoms is extrapolated: 1.34 s per 1 000 atoms gives 16 s and 24 MB of JSON. That is under the 20 s and 40 MB of the forecast, so the lab limit stays at 12 000.
P7 · no re-uploads (browser)
On /sites with 19HC (a 1.1 MB file), the first request sent the file. Every later request named it by its SHA-256: 142 bytes for another ligand and 149 bytes for the survey of every ligand in the file. When the server answered structure_unknown (simulated), the page sent the file once more and then went back to the reference. All 18 checks of the browser test passed, on a desktop and on a 390 px phone.
P8 · batch
The command line (tools/lab/batch_ligands.py). It surveyed the 156 ligands of the 23 biotite test files (31 components, no errors) in 2.95 s on one process and in 1.72 s on two: 1.72×. The output was identical apart from the timing.
The server (POST /api/batch, two workers against --workers 0), in two modes:
| speed-up, 6 runs of the final scheduler | median | |
|---|---|---|
| the files travel in the request (nothing parsed before) | 1.97 · 1.87 · 1.90 · 1.80 · 1.74 · 1.65 | 1.84× |
each file sent first with list_only, then named by structure_ref | 1.37 · 1.85 · 1.12 · 1.20 · 1.36 · 1.75 | 1.37× |
The first row is the forecast's case, and it holds. The second row misses 1.6×, and the reason is worth knowing. The in-process server had already parsed every file at upload, so its batch was pure survey: about 1.0 s. In the pool each file was parsed on whichever worker its hash picked, unevenly by size. Parsing a file costs 1.3 times as much as surveying its ligands (1.35 s against 1.05 s for the 23 files). So a worker that runs out of work can take another's file only by parsing it again.
The first version was slower than one process. It split every file into chunks of ligands and spread them over the workers: 1.26 s against 1.00 s in-process. Each chunk parsed its file again. Now:
- each file is one task, queued at the worker that parsed it;
- each worker takes its largest task first (Graham's rule for jobs of unequal length, 1969);
- an idle worker steals from the longest queue (work stealing, Blumofe & Leiserson 1999), but only while the victim would stay busy longer than the thief would take to parse and survey the stolen file;
- only a file over 2 MB with more than 24 ligands is split.
Molecules, not strings. The summary per component counts distinct molecules. The first identity key counted each atom's hydrogens. With it, the 18 haems of 19HC came out as five "molecules": which two of the four pyrrole nitrogens carry the charge is a choice of the matching, not chemistry. The key now uses the heavy-atom graph and the total hydrogen count, the idea of InChI's mobile-H layer (Heller et al. 2015), refined by Weisfeiler–Lehman hashing (1968). All 18 haems are one molecule, read in six tautomeric forms.
P9 · a local copy of the PDB's dictionary
The real components.cif (about 400 MB) cannot be downloaded in this sandbox. Instead, tools/lab/make_ccd_excerpt.py --repeat 1600 wrote the 50 test components 1 600 times: 80 000 blocks, 103 MB.
| first open: scan and write the index | 1.09 s (10.6 ms per MB) |
| the same from the .gz (unpacked once next to it, then mapped) | 1.28 s |
| later opens (index file read) | 0.08–0.11 s |
| lookup of a component not read before | 0.12 ms median, 1.1 ms p99 |
| lookup of a component read before | 25 µs |
At 10.6 ms per MB, the real file takes about 4 s to index once, well under the 60 s forecast. Real blocks are larger than these (more atoms per component), so a lookup there may take a few milliseconds. That is still under the 5 ms of the forecast, and it is measured on the lab machine. The file is memory-mapped, so 20 workers share one copy in the page cache.
What is still to be measured on the lab machine
python3 server.py --profile lab --port 8000 &
python3 server.py --profile lab --workers 0 --port 8001 &
python3 tools/lab/bench.py --url http://127.0.0.1:8000 --compare http://127.0.0.1:8001 \
--k 1,2,4,8,16,20 --files DIR_OF_STRUCTURES --out bench_lab.json
python3 tools/lab/scale_site.py --json scale_lab.json
python3 tools/lab/batch_ligands.py DIR_OF_STRUCTURES --workers 20 --out survey_lab- P2 at k = 4…20. If 20 requests take more than twice one (the rule in the forecast), the pool goes and the server runs as several plain processes behind nginx.
- P5 with RDKit: the idle worker, and the memory after a long batch.
- P8 on 20 workers: a folder of files, command line and server.
- P9 on the real
components.cif.gz.