Skip to content

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

forecastmeasured hereverdict
P1 · overhead< 30 ms with the structure held; < 200 ms for a first 20 MB fileheld: −18 ms on average over 10 pairs (noise ±60 ms); first 20 MB file: +113 ms and −215 msholds
P2 · throughputk at once within 1.25× onek = 2: 1.02× and 1.07×holds here; lab for k = 20
P3 · no regressiona request in a worker within 10 % of in-process9 of 10 ratios in 0.87–1.02; one 1.18 (+59 ms)holds within the noise of this box
P4 · same answerbyte-identical JSON, five examplesidentical apart from timing_ms (5 of 5, twice)holds, as amended
P5 · memoryidle worker < 250 MB; 20 workers < 5 GB; full caches < 70 GBidle worker 29 MB RSS, 12–14 MB PSS (without RDKit); budgets add up to 50 GBholds here; lab with RDKit
P6 · largest siteclose to linear; 12 000 atoms < 20 s, JSON < 40 MB1.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-uploadslater requests < 1 kB142–149 bytes (browser)holds
P8 · batch156 ligands < 10 s on one core; ≥ 1.6× on 2 workers2.95 s; 1.72× (command line); 1.65–1.97× (server, files in the request)holds; lab for 20
P9 · local dictionaryindex < 60 s; lookup < 5 ms10.6 ms per MB (≈ 4 s for 400 MB); lookup 0.12 ms median, 1.1 ms p99holds

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.

requestin a worker, medianratio to in-process (two runs)
motif 5A7U, zinc finger1.13 s1.02 · 0.97
motif 7DDO, HExxH of ACE20.54 s0.90 · 0.96
ligand XK2 in 1HVR0.53 s0.87 · 0.90
ligand STR in 1A280.40 s0.97 · 1.18
ligand HEM in 19HC0.34 s1.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

RSSPSS
idle worker, forked from the fork server29 MB12–14 MB
front process, idle30 MB
after every benchmark here (caches warm): two workers661 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 structures1 608 MBraw files (for structure_ref)3 861 MB
computed sites772 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:

residuesatoms with HbuildorbitalsJSONtotalper atomJSON size
254090.01 s0.49 s0.02 s0.52 s1.27 ms0.9 MB
507810.02 s0.82 s0.03 s0.87 s1.11 ms1.7 MB
1001 5910.04 s1.63 s0.07 s1.73 s1.09 ms3.2 MB
2003 1760.08 s3.75 s0.13 s3.95 s1.24 ms6.5 MB
4006 3000.15 s8.76 s0.24 s9.14 s1.45 ms12.8 MB
5979 5170.22 s12.17 s0.42 s12.81 s1.35 ms19.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 schedulermedian
the files travel in the request (nothing parsed before)1.97 · 1.87 · 1.90 · 1.80 · 1.74 · 1.651.84×
each file sent first with list_only, then named by structure_ref1.37 · 1.85 · 1.12 · 1.20 · 1.36 · 1.751.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 index1.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 before0.12 ms median, 1.1 ms p99
lookup of a component read before25 µ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

bash
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.

Source: tools/lab/RESULTS.md in the server package · updated