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.

GroupInputTimeIntervalThroughput
parsesmall, 177 B3.00 µs2.97 – 3.04 µs56.3 MiB/s
parselarge, 29,104 B381 µs376 – 388 µs72.8 MiB/s
getsmall, PID-5.1110 ns108 – 112 ns
getlarge, OBX[200]-51.94 µs1.91 – 1.97 µs
treesmall13.4 µs13.3 – 13.6 µs
treelarge1.50 ms1.49 – 1.52 ms
validatesmall6.95 µs6.87 – 7.06 µs
validatelarge633 µs628 – 640 µs
rendersmall, 177 B365 ns360 – 372 ns463 MiB/s
renderlarge, 29,104 B29.4 µs28.9 – 30.1 µs944 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

  1. Every published figure names its machine, its toolchain, and its date.
  2. Every benchmark is in the repository and runnable by anyone, in one command.
  3. Inputs are synthetic. No real patient data, ever — see Patient data.
  4. Criterion, defaults, no tuning, so the numbers compare with anyone else's Criterion run.
  5. A performance claim in a pull request carries a before-and-after from one machine in one sitting.
  6. 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:

GroupThe operationPaid
parseText in, Message outOnce per message
getOne field read by pathOnce per field you care about
treeThe whole generic tree, every value namedOnly when something walks the message
validateThe message against its dictionaryOnly when validation is asked for
renderMessage back to ER7 textOnce 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^R01 carrying 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

sh
cargo bench -p hl7-2              # the five groups
cargo bench                       # every crate that has benchmarks
cargo bench -p hl7-2 -- parse     # one group

Benchmarks 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:

sh
git stash
cargo bench -p hl7-2 -- --save-baseline before
git stash pop
cargo bench -p hl7-2 -- --baseline before

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