Benchmarks
Timing measurements for sct commands run against a real SNOMED CT release:
- UK Monolith -
SnomedCT_MonolithRF2_PRODUCTION_20260701T120000Z(837,930 active concepts)
Last verified: 2026-07-28, against sct 0.20.1. If you're reading this much later than that date, treat the numbers as a rough shape rather than gospel - re-run How to benchmark yourself below for your own hardware and release.
Benchmark machines
| Machine | CPU | RAM | Storage | Cores |
|---|---|---|---|---|
| Lenovo Yoga 9i Pro | Intel Core Ultra 9 185H | 64 GB | NVMe SSD | 22 |
| Raspberry Pi 5 | Broadcom BCM2712 (ARM Cortex-A76) | 8 GB | microSD | 4 |
| OnePlus 13 (CPH2653, Termux) | Qualcomm Snapdragon 8 Elite (Oryon, 4.32 GHz) | 16 GB LPDDR5X | 512 GB UFS 4.0 | 8 |
Methodology
Each command was timed as a single run (not averaged) with peak RSS captured via /usr/bin/time -v (GNU time getrusage) on the Raspberry Pi and /proc/PID/status VmHWM polling on the Lenovo (which lacks GNU time). Wall time is wall-clock seconds. Both methods report the maximum resident set size of the process in GiB.
On the Lenovo, the source ZIP was on NVMe with a warm page cache. On the Raspberry Pi, the source ZIP was on the microSD card. Both machines processed the same uk_sct2mo_42.3.0_20260701000001Z.zip archive with default settings (active concepts only, simple refsets). The sct ndjson stage extracts the ZIP to a temporary directory before processing.
This is a single documented run per machine, not an average over many iterations - treat the numbers as a real, reproducible order of magnitude rather than a precise statistical claim. Wall-clock time is sensitive to whatever else is running at the time.
FHIR terminology server timings should be treated differently from command timings. Run the FHIR conformance harness first, then benchmark only servers that pass the relevant profile:
benchmarks/conformance.sh --server http://localhost:8080/fhir
benchmarks/bench.sh --db snomed.db --server http://localhost:8080/fhir --runs 20 --warmup 5
See FHIR Conformance And Benchmarks for the full methodology.
Results - UK Monolith Edition (837,930 concepts)
Pipeline timings and peak memory
| Command | Output size | Lenovo 9i - wall | Lenovo 9i - RSS | RPi 5 - wall | RPi 5 - RSS | Notes |
|---|---|---|---|---|---|---|
sct ndjson |
1.3 GB | 42.1 s | 3.73 GiB | 103.1 s | 3.69 GiB | RF2 parsing + join + stream serialise |
sct sqlite |
1.8 GB | 26.3 s | 0.28 GiB | 273.8 s | 0.29 GiB | Stream NDJSON to WAL SQLite + FTS5 rebuild |
sct parquet |
785 MB | 6.0 s | 0.99 GiB | 12.9 s | 0.98 GiB | Batched Arrow writes (50k rows/batch) |
sct tct |
db grows 1.8 -> 2.6 GB | 39.1 s | 0.74 GiB | 156.2 s | 0.80 GiB | 11.6M ancestor/descendant pairs over IS-A |
sct fst build |
135 MB | 18.1 s | 0.77 GiB | 34.5 s | 0.79 GiB | 1.25M distinct keys, 178k word tokens |
Fresh verification on the Lenovo produced a 1,972,310,016-byte SQLite database before TCT and a 2,799,837,184-byte database afterwards. The human-readable values above use the same 1024-based formatter as sct info; ls -lh rounds the same files to approximately 1.9 GiB and 2.7 GiB.
Android phone (OnePlus 13, Termux)
sct runs under Termux on Android, so the whole pipeline was run on a phone. This is kept separate from the table above rather than added as two more columns, because it is not a like-for-like comparison: peak RSS was not captured, and the binary was built from source under Termux (linking against Bionic) rather than being the released linux-aarch64 musl build the other machines effectively represent. Single run, wall-clock only, thermal state not controlled.
| Command | OnePlus 13 | RPi 5 | Lenovo 9i | Phone vs Pi | Phone vs Lenovo |
|---|---|---|---|---|---|
sct ndjson |
103.7 s | 103.1 s | 42.1 s | 1.0x | 2.5x slower |
sct sqlite |
73.8 s | 273.8 s | 26.3 s | 3.7x faster | 2.8x slower |
sct parquet |
12.0 s | 12.9 s | 6.0 s | 1.1x faster | 2.0x slower |
sct tct |
64.6 s | 156.2 s | 39.1 s | 2.4x faster | 1.7x slower |
sct fst build |
18.8 s | 34.5 s | 18.1 s | 1.8x faster | 1.04x slower |
A 2026 flagship phone builds a national SNOMED CT edition end to end in about 4.5 minutes, and matches a 22-core laptop on sct fst build.
The interesting part is that it does not behave like uniformly slower hardware. On sct fst build, sct tct, and sct sqlite it lands much closer to the laptop than to the Pi. On sct ndjson and sct parquet it drops back to Pi-5 pace, despite far faster silicon and UFS 4.0 storage where the Pi has a microSD card.
Those two stages are the allocation-heavy ones - sct ndjson builds millions of short-lived strings and records, and sct parquet accumulates Arrow arrays - which suggests the ceiling is allocator throughput rather than CPU or I/O. Bionic's hardened scudo allocator is a reasonable suspect against glibc on the other two machines. Supporting evidence: the sct parquet progress bar reported ~108 MiB/s reading the NDJSON, far below what UFS 4.0 delivers, so the stage is not storage-bound.
This is a hypothesis, not a measured result. Testing it properly means running the same stage on the same handset under a glibc userland (proot-distro Debian) and comparing, plus capturing peak RSS. If you have a phone and ten minutes, that data would be welcome.
Raspberry Pi 5: from OOM crash to completion
Before the v0.20.1 streaming optimisation, sct ndjson crashed on the Raspberry Pi 5 (8 GB RAM) when building the UK Monolith, almost certainly due to out-of-memory conditions. The old implementation materialised every RF2 file as a whole-file Vec<Row> before aggregation, then built the complete Vec<ConceptRecord> before writing a single byte - so on a national edition the loaded dataset, multi-gigabyte transient row vectors, and the full output record set could all be resident at once.
The v0.20.1 streaming implementation streams rows directly into the dataset maps and writes records to the output file as they are built. The Raspberry Pi 5 now completes the full pipeline (ndjson, sqlite, parquet, tct, fst) successfully within its 8 GB RAM, with 3.69 GiB peak RSS during the most memory-intensive stage.
sct ndjson memory improvement in v0.20.1
The v0.20.1 streaming implementation was compared with its immediate pre-change parent (b2ca527) on 2026-07-27. Both were optimised release builds processing the same uk_sct2mo_42.3.0_20260701000001Z.zip archive with the default active-concept and simple-refset settings, a disk-backed temporary directory, and regular file output. Peak RSS is Linux getrusage maximum resident set size as reported by zsh %M; wall time is one paired run rather than a statistical benchmark.
| Implementation | Peak RSS | Wall time |
|---|---|---|
| Before streaming | 6.42 GiB | 58.71 s |
| v0.20.1 streaming | 3.73 GiB | 43.95 s |
| Improvement | 2.68 GiB / 41.84% lower | 25.14% shorter |
Both runs emitted 837,930 concepts in 1,346,971,886-byte artefacts with byte-identical concept records and the same content fingerprint (sha256:abc9de055e67073b56cc21c01b95762c60cb138f839cbf2bdff5894b4a84500e). The lower peak leaves substantially more headroom on 8 GB machines and avoids the severe swap pressure that can make RF2 conversion appear to stall on slower storage.
Only the UK Monolith is benchmarked currently. The previous version of this page also carried UK Clinical Edition numbers; they've been dropped rather than left stale, since re-running them needs a fresh TRUD-authenticated download this environment didn't have to hand. Re-add if useful - Clinical is ~24x smaller and everything scales down accordingly.
MCP server startup time
The sct mcp server should start fast enough to avoid a perceptible delay when an MCP client opens it. Benchmark the current stateless server/discover lifecycle with a complete 2026-07-28 metadata object:
time printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' \
| sct mcp --db snomed.db --codelist-root ./codelists >/dev/null
The recorded figures below are the pre-rmcp v0.18.2 baseline using the legacy initialization path. They remain useful evidence that database size does not force terminology loading at startup, but the SDK-backed 2026-07-28 path must be remeasured before publishing a current comparison.
| Database | Size | v0.18.2 response time |
|---|---|---|
Synthetic test fixture (tests/fixtures/rf2/) |
136 KB, 22 concepts | ~2.5 ms (3 runs: 2.1 / 2.6 / 2.8 ms) |
| UK Monolith, with TCT | 2.6 GB, 837,930 concepts | ~2.3 ms (3 runs: 2.6 / 2.3 / 2.0 ms) |
The server opens the SQLite file without reading it into memory and reads provenance from a small keyed table. Current discovery responses advertise supported versions, capabilities, cache hints, and server identity; release provenance is carried in result _meta:
{"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","supportedVersions":["2024-11-05","2025-03-26","2025-06-18","2025-11-25","2026-07-28"],"capabilities":{"tools":{}},"ttlMs":60000,"cacheScope":"public","_meta":{"org.sct/provenance":{"release_id":"...","release_date":"..."},"io.modelcontextprotocol/serverInfo":{"name":"sct-mcp","version":"..."}}}}
Note on an earlier regression: a prior release briefly took ~370 ms to start against a full Monolith database, because its startup schema-version check ran SELECT MAX(schema_version) FROM concepts - a full-table scan of an unindexed column. Reading a single row instead (the value is uniform across concepts) restored the few-millisecond startup shown above, on databases of any size. See issue #32.
How to benchmark yourself
sct ndjson
--rf2 accepts either an RF2 directory or a .zip file directly:
# Using a zip file
time sct ndjson --rf2 ~/downloads/SnomedCT_MonolithRF2_PRODUCTION_20260701T120000Z.zip
# Using a pre-extracted directory (warm the page cache first for a fair comparison)
find ~/downloads/SnomedCT_MonolithRF2_PRODUCTION_20260701T120000Z -type f -exec cat {} + > /dev/null 2>&1
time sct ndjson --rf2 ~/downloads/SnomedCT_MonolithRF2_PRODUCTION_20260701T120000Z/
sct sqlite
time sct sqlite --ndjson snomedct-monolithrf2-production-20260701t120000z.ndjson --output snomed.db
ls -lh snomed.db
Verify FTS works:
sqlite3 snomed.db "SELECT id, preferred_term FROM concepts_fts WHERE concepts_fts MATCH 'heart attack' LIMIT 5"
sct parquet
time sct parquet --ndjson snomedct-monolithrf2-production-20260701t120000z.ndjson --output snomed.parquet
ls -lh snomed.parquet
Verify DuckDB can read it:
duckdb -c "SELECT hierarchy, COUNT(*) n FROM 'snomed.parquet' GROUP BY hierarchy ORDER BY n DESC LIMIT 5"
sct markdown
time sct markdown --ndjson snomedct-monolithrf2-production-20260701t120000z.ndjson --output snomed-concepts/
du -sh snomed-concepts/
find snomed-concepts/ -name "*.md" | wc -l
sct tct
Builds the transitive closure table (concept_ancestors) over an existing SQLite database - needed for subsumption-heavy workloads or the SCT-QL compiler, not built by default:
time sct tct --db snomed.db
ls -lh snomed.db
sqlite3 snomed.db "SELECT COUNT(*) FROM concept_ancestors"
sct fst build
time sct fst build --ndjson snomedct-monolithrf2-production-20260701t120000z.ndjson --output snomed.fst
ls -lh snomed.fst
Verify search works:
sct fst search "myocardial infarction" --index snomed.fst