Benchmarks
A JMH harness in benchmarks/ logs the same mid-size event (eight fields — every scalar type, a list, and
one nested object) through each backend, routed to a discarding sink so only CPU cost is measured. Structured values are
built with the same jsoniter codec everywhere, except the two console rows, which contrast the two derivation paths.
Every row is logging4s itself, wired to a different backend/encoder — this compares our own backend choices against each other, not logging4s against other logging libraries (there is no third-party baseline here).
sbt "benchmarks/Jmh/run -f 1 -wi 5 -i 12 .*BackendsBench.*"Measured on 6.1 as throughput — operations per second, higher is better — and as the bytes allocated per record
(-prof gc), with 6.0 alongside (single fork, one developer machine — rough ballpark, not authoritative; run it on
your own hardware):
| Config | ops/s | bytes per record | on 6.0 |
|---|---|---|---|
log4j2 + JsonTemplateLayout (nested) |
~705k | ~4.2 KB | ~7.6 KB |
console + fromEncoders (jsoniter) |
~663k | ~4.3 KB | ~8.6 KB |
logback + Logging4sEncoder |
~643k | ~5.3 KB | ~10.3 KB |
console + derives |
~606k | ~6.0 KB | ~11.0 KB |
log4j2 (stringified message) |
~510k | ~3.7 KB | ~6.1 KB |
logback + LogstashEncoder |
~494k | ~3.9 KB | ~6.2 KB |
Throughput on this machine is rough: error bars of ±5–7% within a run, and noticeably wider swings between runs. Read it as orders of magnitude, not a precise ranking. The bytes per record are stable to the byte between runs, and are the better measure of what a change did.
Takeaways:
- Throughput spans roughly ~490k–705k ops/s.
log4j2withJsonTemplateLayoutleads; the two configurations that hand the JSON to a stock encoder or layout trail. - 6.1 allocates 38–49% fewer bytes per record than 6.0 on every configuration: the message text and the JSON line are each built in one presized buffer and escaped in place, small value lists are scanned rather than hashed, and logback renders a value's JSON only when an encoder writes it.
- The encoding path matters as much as the backend: on the same
consolebackend,fromEncoderswith a codec allocates ~28% less thanderives, which assembles both JSON and plain fromMirror. Logging4sEncoder(~643k) runs ~1.3×LogstashEncoder(~494k) — it builds the JSON in a single pass, straight from the event, with no Jackson round-trip. It allocates more per record, because it builds the line as aStringand then encodes it, whereLogstashEncoderwrites through Jackson's recycled buffers.
The disabled-level path
A second harness measures what a suppressed record costs — a debug call under a logger set to INFO:
sbt "benchmarks/Jmh/run -f 1 .*LevelGateBench.*"| Call shape | ops/s on 6.1 | on 6.0 |
|---|---|---|
interpolator, level off (debug"event $e") |
~779M | ~791M |
| direct call with a lazy value, level off | ~793M | ~387M |
| direct call with a pre-rendered value, level off (2.x behaviour) | ~757M | ~1.5M |
| interpolator, level on | ~444k | ~409k |
| direct call, level on | ~476k | ~406k |
In 2.x a suppressed debug still rendered every value to JSON at the call site, so it cost roughly a quarter of an
actual log write. 3.0 made values render lazily (~250× cheaper), and the interpolator skipped the call entirely when
the level was off. Since 6.1 every call does — a direct call checks the level before it evaluates its arguments, so
even a value pre-rendered at the call site is never built, and the three suppressed shapes cost the same. With the
level on, the guard costs nothing measurable.
The two disabled rows are JIT-optimistic in absolute terms: with nothing escaping, escape analysis removes the wrapper allocations outright, so ~1.3 ns/op is a floor rather than a field measurement. The orders of magnitude and the ordering are the signal; the enabled rows are ordinary measured work.