Repository navigation
SNOMED CT: optional memory-mapped cache files, chosen per edition in config.json - #330
Conversation
…: mmap) With snomedLoad: mmap in the library yml, each SNOMED CT cache file is mapped read-only (libc mmap through koffi) instead of read into memory. Its pages are file-backed and shared with the page cache: loading an edition reads nothing up front, only the pages requests touch become resident, and the kernel can drop them under memory pressure and fault them back in. Where mapping is not available (Windows, 32-bit, or mmap fails) the file is read into memory as before. The default (read) is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mapped SNOMED CT pages that have been read count in RSS, but the kernel drops and re-reads them as it needs to. Counting them would make the 503 gate and the 422 abort trip on a server with plenty of headroom. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
How cache files are loaded is a deployment choice, so it moves from the
library yml to the tx module's "snomed" block in config.json:
"snomed": { "load": "read" | "mmap", "prewarm": [...], "read": [...] }
load is the default for every edition (absent: read, as before). prewarm maps
an edition and reads it once through a small buffer at startup, in the
background, so its pages are in the shared page cache before requests need
them (madvise(MADV_WILLNEED) read almost none of it). read loads an edition
into the process even when load is mmap. Entries are cache file names, edition
version URIs or edition URIs (every version); read wins over prewarm; entries
that match nothing, bad values and conflicts are warned about. The memory
guard leaves out file-backed pages once an edition is actually mapped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
snomedLoad: mmap): run the full tx.fhir.org source list in 16 GB, or less# Conflicts: # library/memory-guard.js # tests/library/memory-guard.test.js
|
Thanks - this looks good to merge. Two small things:
|
…acks
SnomedFileReader and SnomedServicesFactory now have a `mapped` flag, true
only when the cache file's buffer is a memory mapping. The library logs it
for each edition that was to be mapped ("load mmap, memory-mapped" or "not
mapped, read into memory"), and only switches the memory guard to leave out
file-backed pages once an edition actually is mapped.
mmap.js's fallback warnings (mapping not available, mmap() failing) go
through a warn function: the library's log via the reader and factory, or
console.warn where there is no logger. They are now given per file.
Tests: with load mmap the reader's buffer is a mapping (and on Linux the
file is in /proc/self/maps), skipped with a message where mapping is not
available; a mapped load and a read load give byte-identical structures and
the same descriptions, relationships and parents for every concept; with
mapping simulated as unavailable or failing, mapped is false and the
fallback is logged, by the reader and through the Library's log.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dvise tx/README.md, the load-config header and config-template.json now say a prewarmed edition is mapped, then read through once in the background at startup so its pages are already in the page cache when requests arrive, and that startup doesn't wait for it. The README also says the server log shows whether each edition that was to be mapped really was. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks, both done (093cfe3, 1c1e11a):
|
With mapped files (measureExcludingFileBacked), the in-operation check (overAbortThreshold, from every deadCheck) read /proc/self/status for RssFile on every call: about 14 us a read, several per request. RSS itself is still read fresh on every check; the file-backed part is the last RssFile read, refreshed at most every 200 ms, or at once when the sample is dropped (configure, measureExcludingFileBacked). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s old overAbortThreshold (from every deadCheck) read process.memoryUsage.rss() fresh on every call: about 11 a request, about 7 us each, near 3% of the main thread at saturation. It now reuses the last measure if it is at most ABORT_SAMPLE_MS (10 ms) old. A dropped sample (configure, measureExcludingFileBacked) is still measured again at once. The 200 ms RssFile window and the 250 ms reject-gate sample are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SNOMED CT: optional memory-mapped cache files, chosen per edition in config.json: run the full tx.fhir.org source list in 16 GB, or less
With #329 merged, tx.fhir.org's full source list needs about 9 GB of process memory, so it already fits a 16 GB machine. This PR adds an opt-in setting that memory-maps the SNOMED CT cache files instead of reading them into the heap. That brings the process to about 3 GB plus reclaimable page cache, which leaves room for more editions and VSAC refreshes on 16 GB, and makes 8 GB workable. The default behaviour is unchanged.
Decisions for you
snomedblock, every edition loads as today (read)config.jsonmmap()prewarmmode alongsidereadandmmapreadandmmapWhat it does
snomedblock in the tx module's config.json says how each edition's cache file is loaded:read(today's behaviour, and the default when the block is absent),mmap, orprewarm(mapped, then read through once in the background so its pages are already in the shared page cache). Details below. The library yml still says only which content to load.SnomedFileReadermaps each.cachefile read-only and wraps the mapping in a Buffer. The structures already read by offset, so nothing else changes. Only the pages that requests touch become resident, as ordinary page cache the kernel can drop and re-read, and shared by every process that maps the same file.RssFilein/proc/self/status). Otherwise mapped pages that have been read would count as used memory and trip the guard on a machine with plenty left.12 files, +509 / −14 (not counting
package-lock.json). One new optional dependency, koffi (MIT, prebuilt binaries for Linux glibc and musl including the Alpine image, macOS, Windows and the BSDs; nothing is compiled). If it fails to install, FHIRsmith is unaffected.Platforms. Maps on Linux (x64/arm64, glibc or musl) and macOS. On Windows, 32-bit platforms, or if koffi is missing or
mmap()fails, it logs one warning and reads the file exactly as today.Measured
Full tx.fhir.org source list (
tx/tx.fhir.org.yml, VSAC pre-loaded), replaying the IG Publisher's real terminology traffic (the calls recorded in build.fhir.orgqa-tx.htmltranscripts), us-central1, pd-balanced disk at production's limits. "99% within" = 99 of 100 calls finished within this time. Cold = first pass after a restart; warm = second pass.Same 4 vCPUs, half the memory: e2-standard-4 (16 GB), about $103/month vs $137 for e2-highmem-4 (list on-demand prices, us-central1, Cloud Billing catalog, 1 Nov 2026, with a 50 GB pd-balanced disk; no discounts; single runs)
On today's machine (e2-highmem-4, 32 GB), mmap against in-memory (3 repeats)
The cost is cold reads. On the production disk, SNOMED CT calls took 28–60% longer in total on the first pass after a restart (14,666 calls, 3 repeats; the typical call 6–7 ms → 8–12 ms), while pages were read from disk. Once those pages are resident, mmap is the same speed or slightly faster. About 1.4 GB of SNOMED CT stayed resident under this traffic.
Smaller still. With mmap the same source list runs on an e2-standard-2 (8 GB, about $54/month): 99% within 682 / 614 ms, but about 23% fewer calls per second on 2 vCPUs. Main is OOM-killed during startup there, with or without #329. The floor is about 4 GB; below that V8's heap limit and the memory guard take over.
Per-edition load mode, set in config.json
How SNOMED CT is loaded is a deployment decision, so it is set in
config.json, in the txmodule's
snomedblock. The library yml still says only what content to load.loadsets the mode for every edition:"read"(the default, today's behaviour) or"mmap".prewarmlists editions to map and also read into the page cache once at startup. Theread runs in the background through a 4 MB buffer, so it costs no process memory. Those
pages are shared by every process that maps the file, so the first requests don't wait for
the disk.
madvise(MADV_WILLNEED)was tried first, but it read almost none of the file.readlists editions to read into the process even whenloadis"mmap".http://snomed.info/sct/731000124108/version/20250901), orreadwins overprewarm.lists, and bad values.
Which editions are busy
The use counts on https://tx.fhir.org/r4/ (2 Nov 2026, after 2 days 2 hours of uptime)
show that two editions take almost all SNOMED CT traffic:
The top two are 97.4% of the total. In the IG Publisher corpus, the calls that pin a version
point at the same two editions (Intl 20250201: 9,497 calls; US 20250901: 2,563). Unversioned
calls go to the yml's default edition, which is International 20260701 in
tx/tx.fhir.org.yml. The production server's page doesn't list that edition yet.Measured: read, mmap, mixed and prewarmed, with 1, 2 and 4 server processes
Setup.
tx/tx.fhir.org.yml, VSAC included.repinning.
second.
once.
left in the page cache by reading files.
What this shows.
Memory grows with the process count only for what each process reads itself.
which are loaded into every process.
6 GB for all nine, 1.4 GB for the two hot ones.
processes.
Four processes on this 32 GB machine:
Prewarming the hot editions brings first-pass latency back to the level of the mix.
329–392 s for the mix and 343–410 s for all-mmap.
15–30%, as the two "all read" runs at N=1 show (517 and 683 s).
Faults on the cold pass: prewarm cut major page faults from about 17,700 (all mmap) to
about 11,300, the same as the mix.
use.
with four.
Warm, every configuration performs the same, within the run-to-run variation.
Startup:
Single-process runs with 3 repeats, against all-read on the same box, give the same picture
for Grahame's mix. These runs used an earlier build that set the mode per line in the yml,
with the same load behaviour.
pin (mlock). This was not implemented.
memlocklimit is 8 MB by default, and Ubuntu's systemd units have the same default,so locking a 0.6–0.9 GB edition would need
LimitMEMLOCK=raised for the service.memory runs short, and then evicting them is the right thing to do.
Correctness
snomedblock, with all editions mmap, and withload: mmapplus prewarm.tests/tx/snomed-load-config.test.jscovers the config parsing and matching;tests/library/memory-guard.test.jscovers the guard change.The full write-up, with per-query footprints and the cost table, is available on request.
🤖 Generated with Claude Code