Documentation
Benchmarks
Measured figures for the five things hl7-2 is asked to do, the method that produced them, and what they are not evidence of. A number without its method is a marketing claim, so the method is here too.
The figures
Machine: Apple M4 Max, 128 GB, macOS 26.6.1, arm64. Toolchain: rustc 1.98.0, release profile. Date: 26 August 2026. Crates: hl7-2 0.2.3 over er7 0.1.2. Method: cargo bench -p hl7-2, Criterion defaults, machine
otherwise idle. The middle column is Criterion's point estimate; the interval is its confidence
interval, reported rather than quietly dropped.
| Group | Input | Time | Interval | Throughput |
|---|---|---|---|---|
parse | small, 177 B | 3.00 µs | 2.97 – 3.04 µs | 56.3 MiB/s |
parse | large, 29,104 B | 381 µs | 376 – 388 µs | 72.8 MiB/s |
get | small, PID-5.1 | 110 ns | 108 – 112 ns | — |
get | large, OBX[200]-5 | 1.94 µs | 1.91 – 1.97 µs | — |
tree | small | 13.4 µs | 13.3 – 13.6 µs | — |
tree | large | 1.50 ms | 1.49 – 1.52 ms | — |
validate | small | 6.95 µs | 6.87 – 7.06 µs | — |
validate | large | 633 µs | 628 – 640 µs | — |
render | small, 177 B | 365 ns | 360 – 372 ns | 463 MiB/s |
render | large, 29,104 B | 29.4 µs | 28.9 – 30.1 µs | 944 MiB/s |
Reading them
Four things in that table are worth saying out loud, including the unflattering one.
Parsing is not your bottleneck
A small message parses in about 3 µs, so one core parses on the order of 300,000 ADTs a second. For essentially every real HL7 interface, the network, the database, and the downstream system decide the throughput. If you are choosing a library on parse speed, you are optimising the wrong number.
Rendering is about eight times cheaper than parsing
365 ns against 3.00 µs on the same message. That is what “stored as sent, decoded on demand” buys: writing back out is mostly copying bytes that were never transformed, which is also why the round trip comes back byte for byte.
Use paths, not the tree — this is the useful one
Reading two fields from the large message costs about 4 µs. Building its whole tree costs 1.50 ms: nearly 400 times more. An integration that wants a handful of fields should reach for paths and never materialise the tree.
The tree on a large message is the slowest thing here
Slower than parsing the same message four times over. It allocates a named node for every value in a 600-segment message, so the cost is real work rather than waste — but it has had no optimisation attention, and it is the first place to look if a profile points this way. Saying so is more useful than omitting the row.
The method
- Every published figure names its machine, its toolchain, and its date.
- Every benchmark is in the repository and runnable by anyone, in one command.
- Inputs are synthetic. No real patient data, ever — see Patient data.
- Criterion, defaults, no tuning, so the numbers compare with anyone else's Criterion run.
- A performance claim in a pull request carries a before-and-after from one machine in one sitting.
- Correctness outranks speed. A faster parser that loses a value, or stops round-tripping byte for byte, is not faster.
The five operations are measured separately rather than as one end-to-end figure, because a given interface pays for only some of them:
| Group | The operation | Paid |
|---|---|---|
parse | Text in, Message out | Once per message |
get | One field read by path | Once per field you care about |
tree | The whole generic tree, every value named | Only when something walks the message |
validate | The message against its dictionary | Only when validation is asked for |
render | Message back to ER7 text | Once per message written out |
One combined “messages per second” would hide which of those you are actually paying for. A feed
that reads two fields and forwards the message pays parse plus two gets; a converter pays parse plus tree; a strict receiver
pays parse plus validate.
The inputs
- small — 177 bytes
- A four-segment
ADT^A08. The shape most interfaces move in bulk, and where per-message overhead dominates. - large — 29,104 bytes
- An
ORU^R01carrying 200 observations as OBR/OBX/NTE triples. The shape that decides whether a parser keeps up with a day's traffic, and where per-segment cost dominates.
Both are built in code at the top of the benchmark file, so the input is readable next to the measurement and cannot drift. Neither is a claim about a representative message mix — there isn't one. Site traffic varies more between two hospitals than between these two sizes. They bracket the range rather than averaging it.
Running them yourself
cargo bench -p hl7-2 # the five groups
cargo bench # every crate that has benchmarks
cargo bench -p hl7-2 -- parse # one groupBenchmarks build under the bench profile, which is release with debug assertions
off. Running them under the dev profile measures the debug build and is meaningless.
Comparing a change
The only comparison that means anything is the same machine, minutes apart:
git stash
cargo bench -p hl7-2 -- --save-baseline before
git stash pop
cargo bench -p hl7-2 -- --baseline beforeCriterion prints the change and whether it considers it significant. Treat anything under about 5% on a laptop as noise — thermal state, other processes, and allocator behaviour move numbers by that much between runs that changed nothing.
What these are not
- Not a guarantee. One run, one machine, one day, two synthetic messages.
- Not a throughput figure for a system. These measure a library call. MLLP framing, TCP, acknowledgement round trips, disk, and the database behind the interface are all outside them, and usually they set the ceiling.
- Not a memory measurement. Nothing here reports allocations or peak resident size. That is a gap, and a contribution adding it would be welcome.
The normative version of this page, with the full rules, is spec/benchmark/index.md.