Performance
FerroEHR applies the same discipline to performance that it applies to conformance: a class is a verdict a server earns by measurement on a stated environment, or does not. The performance chapter of the CNF suite runs an open-loop clinical workload at a published offered-load floor, records the result as a re-checkable histogram, and lets the verdict pipeline recompute (earned or not earned) from that artefact. Nothing on this page is hand-typed; every number comes from the committed measurement records or the generated assets below.
- The volumetric class ladder
- Where the floors come from
- The durability floor — what a single write can never beat
- The hospital simulation
- How a measured run works
- Reproducing it
- Stress testing — the second instrument
- The storage benchmark harness
- The latest measured run
The volumetric class ladder
Performance conformance is graded on a small, closed ladder of deployment
classes: proof-of-concept (POC), small (S), large (L), and regional
(R). Each
class fixes an offered-load floor (the peak API arrival rate the server must
sustain), a latency budget (a p99 service-level objective, uniform across
classes), and an error budget (zero: a failed request under load is a failed
class). A class is earned only when a measured run holds every threshold; a
class is never declared.
Crucially, a class verdict is environment-bound: it is meaningful only alongside the hardware, core count, memory, storage class, and topology it was measured on, which the runner records in the measurement’s environment block and stamps into every asset. The same binary earns different classes on different hardware, and the artefact always says which.
Where the floors come from
The offered-load floors are anchored to population, so a class corresponds to a real catchment a deployment might serve. The derivation is a short chain of published, official activity statistics:
-
Clinical documents per person per year. Summing the major encounter types that each commit a clinical document gives roughly forty-six documents per capita per year: primary- and specialist-care consultations (OECD, Health at a Glance 2023), inpatient discharges (OECD/Eurostat hospital discharge statistics), emergency-department visits (OECD emergency-care indicators), laboratory reports (Royal College of Pathologists activity data), diagnostic-imaging events (NHS England Diagnostic Imaging Dataset, 2023/24), and dispensed prescriptions (NHS Business Services Authority Prescription Cost Analysis, 2024/25). The result sits between the major-document exchange rates Denmark and Estonia report and Finland Kanta’s all-inclusive figure, which is the sanity check that keeps it arguable.
-
Average write rate. Multiplying a class’s served population by that per-capita rate and dividing by the number of seconds in a year gives the average sustained document-write rate for the class:
-
Busy-hour peak. Real clinical traffic is not flat: it concentrates in ward rounds and clinic hours. Following the ITU-T E.500 busy-hour engineering convention, the average is scaled to a busy-hour peak by a peak factor of eight:
-
Read multiplier. A CDR is a read-heavy OLTP system: charts are read far more often than they are written. Following the read-heavy OLTP convention used by standard database benchmarks (YCSB, OLTP-Bench), the offered load applies a read-to-write ratio of 10:1 on top of the write rate; audit-log evidence of how many record interactions one encounter actually generates bounds the read-heavy band from above.
The floors that fall out of this chain are the published defaults the runner enforces; the concrete rates per class are carried in the class ladder above and the summary table below, never re-typed into this prose.
The durability floor — what a single write can never beat
Every committed version is durable: the transaction’s WAL records are flushed
to disk before the server answers, so an acknowledged commit survives a crash.
That flush (one fsync on the WAL device per commit) is a physical lower
bound on single-client write latency, and no storage design takes a lone
sequential client below it. On a laptop-class Docker setup the flush alone
dominates a single write’s budget; on server NVMe it is typically an order of
magnitude smaller. FerroEHR’s optimization target is therefore everything around the
flush: the database’s own per-commit work (one folded commit statement, one
merged placement read) stays far below the flush itself. That is also what
makes concurrent throughput scale: PostgreSQL group-commits, amortizing one
flush across every transaction that reaches it in the same window.
The knob behind this boundary is PostgreSQL’s
synchronous_commit.
Setting it off makes commits return before the WAL flush: single-client
write latency drops to the statement work alone, and a server crash can lose
the most recent acknowledged commits (up to three times wal_writer_delay,
per the PostgreSQL documentation). The database stays consistent; the tail of
acknowledged writes is what is at risk. FerroEHR never defaults this off
and does not recommend it for clinical data. It is an operator decision,
made per deployment on the database side, defensible only where the record of
loss is acceptable (a load-test rig, a reseedable sandbox, an analytics
replica). All published FerroEHR numbers are measured with full durability
on.
The hospital simulation
The measured workload is not a flat operation mix: it simulates a hospital, end to end. Load arrives as clinical journeys (ordered, time-offset operation sequences drawn from a committed journey catalogue):
- ADT flow: an admission creates the EHR, sets its status, commits the admission problem list and summary, and opens the per-episode directory folder tree; a discharge writes the discharge summary and closes the episode out.
- Monitoring: nursing observation rounds commit vital-signs documents at ward cadence.
- The medication loop: an order is followed by scheduled administration commits at the drug-round interval; medicines reconciliation reads the standing medicines list and amends it as a new version.
- Order → result pipelines, asynchronous: a laboratory or imaging request is committed at one instant and its result lands as its own arrival after a realistic turnaround drawn from the catalogue; the ordering clinician’s chart review follows later still. Nothing ever blocks on anything else.
- Clinical review: ward-round chart reads (at version, current, and the revision history), per-patient AQL trends, cross-EHR ward worklists, and a registered stored query executed continuously.
- Governance: versioned amendments, the occasional logical delete, contribution inspection (the audit trail’s read side), and workflow tagging of hot documents.
- The platform surface: template listing and retrieval (the integration-engine poll), specialist synoptic reports, registry submissions, and statutory public-health notifications.
Every stage of every journey instance is its own planned arrival instant on the global open-loop schedule (an order at , its administrations at , the result at ) so many patients’ journeys interleave exactly as wards do, and cross-operation state effects (status transitions during active commits, folder consistency under parallel writes, version chains under interleaved amendments, AQL against a mutating corpus) are exercised under load, which a flat four-operation mix can never reach. A dependent stage whose prerequisite has not landed when its instant fires (a stalled server) records honestly as an error; that is the measurement.
The journey payloads commit against published openEHR CKM templates (vital signs, laboratory results, ePrescription, medicines list, problem list, the International Patient Summary, imaging and cancer synoptic reports, registry and public-health forms), vendored with provenance and committed as byte-identical example skeletons so every measured server receives exactly the same bytes.
The envelope stays population-anchored. The derivation above still fixes the aggregate operation arrival rate (the class floor) and the read:write ratio; the journey catalogue only decomposes those totals into many more operation kinds. Each journey cites the activity statistic that grounds its shape (the same register the floors derive from) and the runner’s artefact validator recomputes the expansion on every load: the catalogue-expanded write share must reconcile to the derivation’s read-heavy band (between the 10:1 floor convention and the audit-log-evidenced ceiling), so the mix stays arguable, never arbitrary.
For the extended eight- and twelve-hour holds, the schedule can follow a diurnal day curve (morning and afternoon peaks, shift-change bumps, a night-time trough) applying the same ITU-T E.500 busy-hour convention the peak factor cites: the class floor is then the busy-hour rate, and the off-peak troughs are the design, not a shortfall.
Finally, the conformance certificate prints the workload coverage: the set of claimed capabilities the simulation actually exercised, joined against the claims matrix. A claimed capability the hospital never touches must carry an adjudicated exclusion printed with its reason: a design-time operation like archetype provisioning is not a sustainable per-patient arrival, and saying so beats pretending the load reached it.
How a measured run works
A performance run is deliberately open-loop: the runner plays a seeded arrival schedule (request is due at a planned instant computed before the run starts) rather than a closed loop of virtual users that would slow its own offered load down whenever the server stalls. This makes the run coordinated-omission-free: each request’s latency is measured from its planned arrival instant, so a server that pauses cannot hide the queue it built up behind a handful of fast replies.
A run has two phases: a discarded warmup window (caches fill, pools warm, the
JIT of a compared server settles) followed by the sustained measurement window
at the class’s offered-load floor. Latencies are accumulated into per-operation
HDR V2 histograms, which are base64-encoded verbatim into the measurement
record in results.json. Because the full histogram is embedded (not just a
handful of pre-computed percentiles) any consumer can re-derive every percentile
and re-check every threshold from the committed artefact. The class verdict itself
is then recomputed by the verdict pipeline from those records: the runner
never writes a verdict it cannot reproduce from the data.
The measured corpus is seeded strictly through the public write path (create EHR, commit composition) and never a database backdoor, so what the run reads is exactly what the server’s own write path produced. The database’s maintenance debt is settled deterministically, and identically for every system under test, outside the measured windows, so neither a stale-statistics plan nor an autovacuum firing mid-window ends up in the record.
Reproducing it
The measured run is a stage of the conformance pipeline. Selecting a class seeds
the matching scale corpus, plays the open-loop schedule against the composed
server, and merges the measurement records into results.json:
# seed the class corpus, run the open-loop schedule, merge the record
CONF_PERF_CLASS=POC bash scripts/conformance.sh
# hold the same offered load for longer — the hours ladder is 1 (the case's
# normative window and the default), 2, 4, 6, 8, or 12
CONF_PERF_CLASS=POC CONF_PERF_HOURS=8 bash scripts/conformance.sh
There is deliberately no shortened run: the measurement record always covers at least the case’s normative window, so nothing sub-normative can ever be mistaken for a measured result. A longer hold is a stricter demonstration of the same class, not a different one. The record carries the actual warmup and window it held, and the verdict machinery re-derives everything from the embedded histograms. Every run seeds a freshly composed server from empty; there is no seed reuse, so no run ever measures another run’s leftovers.
The runner can also be driven directly against a running server, which is how you
point it at a deployment the pipeline does not compose. Its perf subcommand
takes the catalogue root, the ixit topology file (whose environment block is
mandatory: a measurement without the deployment described is not a
measurement), the results.json to merge into, and the class:
veredictum perf --root <catalogue-root> --ixit <ixit.json> \
--results <results.json> --class POC --hours 8
Stress testing — the second instrument
Beside the class runs sits a deliberately different instrument: a step-load stress test. Where a class run holds a real-life, population-anchored rate for at least an hour, the stress test climbs a geometric ladder of short, intense load steps (about two minutes each, doubling the offered rate every step) until the system leaves the stress envelope, then bisects between the last stable rate and the breached one to locate the boundary precisely, the point performance engineering calls the knee of the latency-throughput curve. The headline it finds is the maximum sustainable throughput: the highest offered rate held inside the latency budget (the same idea TPC benchmarks report as maximum qualified throughput).
veredictum stress --root <catalogue-root> --ixit <ixit.json> \
--out <stress.json> --corpus-class POC
The two instruments never blur. A stress report earns no class, never touches
results.json, and carries no class vocabulary at all; the class ladder belongs
to the measured class runs, and the stress chart shows one thing only: where the
system breaks. Their envelopes differ on purpose too: a conformance class demands
a zero error budget, while a stress step allows the small error tolerance standard
load testing works with, because the instrument’s job is to find the knee rather
than to certify anything. Every load step embeds its own re-checkable histograms
and its own resource telemetry (the same per-container CPU/memory/I/O series the
measured runs record, so a breached step shows where it saturated), a breached
step is reported with the exact envelope violation, and a step where the load
generator topped out before the server is flagged as such rather than counted
against the system.
The optimization probe
Between the two verdict-bearing instruments sits a third, purely diagnostic one: the AQL probe. It seeds the same class corpus fresh, fires the measurement machinery’s own AQL set repeatedly, and records each query’s wire-latency percentiles alongside the database-side cost per SQL statement, so an optimization is argued from attributed evidence on a realistically seeded database, never from a hunch on an empty one. Its report is exploration evidence for the optimization loop: it earns nothing and never touches the conformance record.
veredictum aql-probe --root <catalogue-root> --ixit <ixit.json> \
--out <aql-probe.json>
The published assets on this page are rendered from the committed results.json
by the runner’s perf-assets subcommand (wrapped by
scripts/render/perf-assets.sh); the docs CI job re-renders and diffs them, so a
hand-edited or stale asset fails the build.
The storage benchmark harness
A fourth instrument measures the storage layer directly, below the wire:
STORAGE_BENCH_CLASS=poc cargo bench -p ferroehr --bench storage
It seeds a corpus through the ordinary write path, then times the storage layer’s hot paths one at a time: a composition commit, a supersession, the supersession that states the version it replaces, a point read by version uid and one by versioned-object uid, the version at an instant, the revision history, an AQL CONTAINS chain over one EHR and over the whole population, archive and restore of an EHR, and one retention prune.
Beside each operation’s wall-clock it records what the database did for it. The
per-relation tuple counters (inserted, updated, updated in place, dead, live)
and the relation sizes come from pg_stat_user_tables, the buffer hits and
misses from pg_stat_database, and the write-ahead-log bytes from the WAL
position either side of the measurement. One representative commit is probed on
its own, and the population query is explained with
EXPLAIN (ANALYZE, BUFFERS, WAL) inside a transaction that is rolled back, so
the plan shape and its index choices land in the record too.
Everything runs through the service and the public storage API, and the
relations are discovered from the catalogue, so no table name appears anywhere
in the harness and the same file measures a rewritten schema. That is the
point: a storage change is argued from a before-and-after pair taken with one
instrument. It earns no class and touches no conformance artifact; the record’s
shape is documented in docs/benchmarks/storage/README.md. Records are local by
default, and the committed baseline is what this page renders.
Schema generation generation-1, class s
| Operation | Iterations | p50 | p95 | p99 | WAL | Buffer hits |
|---|---|---|---|---|---|---|
create_first_version | 1 200 | 3.6 ms | 5.3 ms | 5.3 ms | 47 MiB | 1 008 028 |
supersede | 870 | 11.2 ms | 14.4 ms | 14.4 ms | 37.5 MiB | 30 795 283 |
supersede_if_match | 850 | 11.4 ms | 14.5 ms | 14.5 ms | 36.5 MiB | 29 699 039 |
by_version_uid | 13 640 | 298.6 µs | 374.6 µs | 374.6 µs | 77.3 KiB | 250 604 |
latest_by_versioned_object_uid | 8 550 | 479.9 µs | 1 ms | 1 ms | 456 B | 121 845 |
version_at_time | 7 260 | 597.3 µs | 640 µs | 640 µs | 136 B | 122 318 |
revision_history | 25 230 | 193.9 µs | 216.6 µs | 216.6 µs | 88 B | 671 513 |
contains_one_ehr | 2 850 | 1.7 ms | 1.8 ms | 1.8 ms | 368 B | 766 648 |
contains_population | 3 440 | 1.1 ms | 1.1 ms | 1.1 ms | 0 B | 1 294 649 |
archive_ehr | 90 | 23.3 ms | 30.3 ms | 30.3 ms | 252.7 MiB | 6 969 118 |
restore_ehr | 160 | 24.9 ms | 69.1 ms | 69.1 ms | 400.5 MiB | 9 276 746 |
retention_prune | 25 650 | 166.7 µs | 185.7 µs | 185.7 µs | 104 B | 35 104 |
One representative commit wrote 2.6 MiB of WAL and touched 88 172 buffers. The population CONTAINS statement plans as Gather and reads 55 817 shared blocks.
| Relation | Live rows | Dead rows | Updates | HOT updates | Size |
|---|---|---|---|---|---|
ehr.audit | 7 188 | 0 | 0 | 0 | 1.9 MiB |
ehr.contribution | 6 988 | 0 | 0 | 0 | 1.3 MiB |
ehr.ehr | 100 | 0 | 0 | 0 | 112 KiB |
ehr.event_outbox | 6 988 | 0 | 0 | 0 | 3.9 MiB |
ehr.node | 296 059 | 0 | 0 | 0 | 271.8 MiB |
ehr.template_ref | 1 | 0 | 0 | 0 | 32 KiB |
ehr.template_store | 1 | 0 | 0 | 0 | 136 KiB |
ehr.vo_archive | 0 | 0 | 0 | 0 | 40 KiB |
ehr.vo_version | 7 088 | 0 | 2 330 | 0 | 30.1 MiB |
Measured over 100 EHRs and 3 000 compositions of template Vital signs at commit 6ebfdb32b304 on 8 cores against PostgreSQL 18.6.
The latest measured run
The per-operation percentiles below are re-derived at build time from the committed HDR V2 histograms for the proof-of-concept class:
What the run cost the machine
Alongside the latencies, every measured run records its resource telemetry: CPU and resident memory for the server and database containers separately, plus block-device and network I/O, sampled at a fixed interval across the whole window with the warmup shaded. These numbers are capacity-planning context; they never influence whether a class is earned.
The database volume’s on-disk size is probed at four anchors: before the scale seed (the empty baseline), after it, after the standing-ward seed, and after the measured window drained. The first two give the storage cost per committed composition; the last two give the sustained load’s write amplification. An anchor that could not be probed is honestly absent rather than guessed:
Both charts render only from a committed measurement record; nothing on this page is ever mocked.
| Class | Corpus | Offered-load floor | p99 budget | Error budget | Measured sustained | Verdict |
|---|---|---|---|---|---|---|
| POC | cnf.scale.10k | 2/s | ≤ 1000 ms | 0 | 2.0/s | EARNED |
| S | cnf.scale.100k | 15/s | ≤ 1000 ms | 0 | — | not measured |
| L | cnf.scale.1m | 150/s | ≤ 1000 ms | 0 | — | not measured |
| R | cnf.scale.10m | 1500/s | ≤ 1000 ms | 0 | — | not measured |
Measured run PERF-hospital_sim-class_POC — class POC, offered load 2.04/s sustained over 3600 s (after 300 s warmup), environment: consumer-laptop (8 cores, 16 GB, nvme, single-node docker compose (8-CPU/8GB Docker VM) on Apple M2, the SMART resource-server posture (docker/sut-smart.yml overlays the base stack) with the external-terminology profile composed beside it (a seeded HAPI FHIR R4 server, docker compose –profile terminology + docker/sut-terminology.yml, fail-open); alongside it a second deployment of the same image in the openPGP version-signing posture (project ferroehr-cnf-pgp, docker/sut-signing-pgp.yml + docker/sut-terminology-failclosed.yml + docker/sut-pgp-parallel.yml, host port 8081) declared as the sut_pgp instance, which carries the fail-closed terminology posture — the measured-performance stage drives the primary deployment alone).
| Operation | Requests | Errors | p50 (ms) | p90 (ms) | p99 (ms) |
|---|---|---|---|---|---|
| adhoc_query | 792 | 0 | 29 | 38 | 64 |
| admin_contribution_report | 60 | 0 | 159 | 176 | 269 |
| analytics_query | 23 | 0 | 62 | 76 | 197 |
| archetype_adl2_list | 60 | 0 | 13 | 18 | 28 |
| composition_commit | 280 | 0 | 50 | 106 | 214 |
| composition_commit_flat | 7 | 0 | 42 | 53 | 53 |
| composition_delete | 4 | 0 | 28 | 35 | 35 |
| composition_read | 1583 | 0 | 24 | 33 | 66 |
| composition_read_current | 896 | 0 | 27 | 36 | 57 |
| composition_read_flat | 7 | 0 | 17 | 25 | 25 |
| composition_revision_history | 889 | 0 | 14 | 21 | 31 |
| composition_update | 68 | 0 | 46 | 67 | 239 |
| composition_version_read | 30 | 0 | 28 | 35 | 49 |
| contribution_commit | 48 | 0 | 54 | 138 | 187 |
| contribution_read | 82 | 0 | 20 | 28 | 60 |
| directory_create | 12 | 0 | 25 | 40 | 86 |
| directory_read | 803 | 0 | 17 | 24 | 40 |
| directory_update | 12 | 0 | 28 | 34 | 42 |
| ehr_create | 12 | 0 | 28 | 32 | 33 |
| ehr_extract_export | 792 | 0 | 149 | 182 | 268 |
| ehr_read | 72 | 0 | 18 | 25 | 37 |
| ehr_status_read | 24 | 0 | 16 | 23 | 25 |
| ehr_status_update | 24 | 0 | 21 | 36 | 55 |
| party_create | 6 | 0 | 22 | 28 | 28 |
| party_read | 6 | 0 | 16 | 24 | 24 |
| party_relationship_create | 6 | 0 | 26 | 30 | 30 |
| party_relationship_read | 6 | 0 | 11 | 17 | 17 |
| party_update | 6 | 0 | 15 | 27 | 27 |
| readonly_write_denied | 7 | 0 | 30 | 46 | 46 |
| smart_configuration_read | 7 | 0 | 15 | 24 | 24 |
| stored_query_execute | 180 | 0 | 41 | 55 | 103 |
| system_options | 7 | 0 | 10 | 18 | 18 |
| tags_put | 30 | 0 | 24 | 33 | 41 |
| tags_read | 30 | 0 | 17 | 26 | 38 |
| tdd_import | 7 | 0 | 35 | 60 | 60 |
| template_adl2_list | 61 | 0 | 13 | 19 | 54 |
| template_example | 60 | 0 | 30 | 102 | 261 |
| template_get | 60 | 0 | 64 | 134 | 240 |
| template_list | 60 | 0 | 53 | 80 | 90 |
| terminology_query | 23 | 0 | 19 | 26 | 73 |
| unauthenticated_probe | 7 | 0 | 18 | 23 | 23 |
| ward_query | 180 | 0 | 28 | 38 | 71 |
Resources (measured context, never a verdict input) — sampled every 10 s; CPU/RSS derived over the measured phase:
| Container | CPU mean | CPU peak | RSS peak |
|---|---|---|---|
sut ferroehr-cnf-ferroehr-1 | 3.6% | 7.7% | 218 MB |
db ferroehr-cnf-ferroehr-postgres-1 | 5.9% | 21.0% | 1.5 GB |
Disk anchors: empty 133 MB → after scale seed 13 GB (≈ 13 KB / composition over 1,000,000 committed) → after ward seed 13 GB → after window 13 GB.