Benchmarks
JMH numbers from
benchmarks/,
run side-by-side against Monocle
where a direct equivalent exists and against a hand-rolled
decode → modify → re-encode baseline for the EO-only JSON and
PowerSeries optics.
Reading the numbers
All tables show average time per operation (lower is better)
with the 99.9 % confidence interval. eo* / m* are the EO and
Monocle methods, naive* is a hand-written baseline without any
optic machinery.
Sample output, run on a Linux 6.19 x86-64 box, JDK 25, JMH 1.37,
with -f 1 -i 5 -wi 3 -t 1 (one fork, five measurement
iterations, three warmups, single thread). Total wall time:
~10 min. The absolute numbers will vary by hardware; the
ratios reproduce across machines.
JMH caveats. Trustworthy numbers need a quiet machine,
fork count ≥ 3, CPU-frequency scaling locked, and no
background builds. The tables below are indicative, not
publication-grade. See
benchmarks/README.md
for repeatable invocations.
Lens (Tuple2 carrier)
Person.age focused via a Lens. Same fixture on both sides.
| Operation | eo | Monocle | ratio |
|---|---|---|---|
get |
0.45 ns | 0.52 ns | 1.16× |
replace |
1.18 ns | 1.29 ns | 1.09× |
modify |
1.37 ns | 1.52 ns | 1.11× |
The EO GetReplaceLens stores get / enplace as plain
fields and specialises its fused modify on the class, so the
hot path is a straight two-function composition — no
Tuple2 allocation for the (X, A) intermediate that the
generic extension would materialise.
Prism (Either carrier)
Option[Int] prism plus an Either[String, Int] Right-prism:
| Operation | eo | Monocle |
|---|---|---|
getOption (Some) |
0.42 ns | 0.46 ns |
getOption (None) |
0.42 ns | 0.47 ns |
reverseGet |
1.06 ns | 1.10 ns |
Right-getOption (Right) |
1.17 ns | 1.29 ns |
Right-getOption (Left) |
0.46 ns | 0.80 ns |
Right-reverseGet |
1.06 ns | 1.11 ns |
Iso (Forgetful carrier)
(Int, String) ↔ Person(age, name) bijection.
| Operation | eo | Monocle |
|---|---|---|
get |
1.63 ns | 1.67 ns |
reverseGet |
1.22 ns | 1.25 ns |
BijectionIso stores get / reverseGet as plain fields —
same storage shape as Monocle's case class Iso, same
direct-call hot path.
Optional (Affine carrier)
Composed through a Nested0..6 chain. The depth-3 / depth-6
EO variants compose the Lens chain via .morph[Affine] and
.andThen directly onto the leaf Optional — made possible by
dropping the <: Tuple bound on Affine.assoc (see
Concepts → Cross-family composition).
| Operation | eo | Monocle |
|---|---|---|
modify_0 (Some leaf) |
15.11 ns | 12.52 ns |
modify_0_empty (None) |
0.72 ns | 0.65 ns |
replace_0 |
7.96 ns | 1.74 ns |
modify_3 |
79.02 ns | 33.45 ns |
modify_6 |
121.97 ns | 52.49 ns |
Both sides are within ~2× of each other across depths — the EO
path pays Affine's branching overhead relative to Monocle's
Option-specialised internals.
Getter (Forgetful carrier, no write)
| Depth | eo | Monocle |
|---|---|---|
get_0 |
0.54 ns | 0.60 ns |
get_3 |
1.50 ns | 7.88 ns |
get_6 |
2.68 ns | 16.12 ns |
Monocle's composed Getter.andThen chain pays per-hop typeclass
dispatch Monocle's side doesn't optimise away at call-time. EO
resolves the .get extension against each carrier's Accessor
statically, so the composed chain inlines to a direct function
call.
Getter composition isn't expressible through Optic.andThen
in EO today (see
Optics → Getter); the _3 / _6 EO
numbers are from nested .get calls. Monocle's first-class
Getter.andThen is the surface for its side.
Setter (SetterF carrier, write-only)
| Depth | eo | Monocle |
|---|---|---|
modify_0 |
1.45 ns | 1.27 ns |
modify_3 |
25.37 ns | 13.18 ns |
modify_6 |
50.27 ns | 27.32 ns |
Same composition caveat as Getter — EO's deep-modify benches
nest modify calls where Monocle composes natively.
Fold (Forget[F] carrier)
foldMap(identity) over List[Int], sweeping size.
| Size | eo | Monocle |
|---|---|---|
| 8 | 50.8 ns | 11.4 ns |
| 64 | 458.4 ns | 165.1 ns |
| 512 | 3 868.7 ns | 2 179.5 ns |
Monocle wins here because its Fold.foldMap reduces to a
direct Foldable[F].foldMap call; EO's Forget[F] carrier
adds a small per-element dispatch layer through
ForgetfulFold.
Traversal
each on List[Int], plus a modify(_ + 1) sweep:
| Size | eo (each) |
Monocle (fromTraverse) |
speedup |
|---|---|---|---|
| 8 | 17.8 ns | 119.4 ns | 6.71× |
| 64 | 145.7 ns | 1 352.5 ns | 9.28× |
| 512 | 1 939.5 ns | 16 214.0 ns | 8.36× |
A surprisingly large win — EO's Traversal.each keeps the
Forget[T] carrier linear by delegating straight to
Functor[T].map, while Monocle's Traversal wraps each
element in an Applicative[Id] traversal and pays the
per-element wrapping cost.
JsonPrism — cursor-backed JSON edit
No Monocle equivalent at this layer. Compared against the
classical decode → modify → re-encode baseline.
| Depth | eo | naive | speedup |
|---|---|---|---|
| 1 | 69.1 ns | 155.5 ns | 2.25× |
| 2 | 116.1 ns | 158.8 ns | 1.37× |
| 3 | 135.8 ns | 253.3 ns | 1.87× |
| wide | 943.5 ns | 983.6 ns | 1.04× |
The "wide" variant uses 28-total-field records; at that width
the naive decoder has to touch every field. EO's
codecPrism[…].field(_.x).field(_.y) walks only the focused
path.
JsonTraversal — items.each.name edits
Uppercasing every items[*].name inside a Basket record, at
three array sizes:
| Items | eo | naive | speedup |
|---|---|---|---|
| 8 | 796.1 ns | 1 802.2 ns | 2.26× |
| 64 | 5 977.8 ns | 12 404.9 ns | 2.07× |
| 512 | 47 498.6 ns | 95 503.5 ns | 2.01× |
The ratio is roughly constant — the naive path pays a full decode / re-encode for every element, so both scale linearly with array size and EO wins by a constant factor from avoiding the per-element codec round-trip.
PowerSeries — traversal with downstream composition
EO-only — no Monocle equivalent. Toggles isMobile on every
Phone inside a Person.phones: ArraySeq[Phone]; the chain
is Lens → Traversal.powerEach → Lens.
| Size | eo (powerEach chain) |
naive copy / map |
ratio |
|---|---|---|---|
| 4 | 439 ns | 14 ns | 31× |
| 32 | 2 332 ns | 84 ns | 28× |
| 256 | 19 397 ns | 769 ns | 25× |
The carrier is now flat ArraySeq[A] (backed by Array[AnyRef])
with a hand-rolled grow-on-demand builder on the assoc hot
path (swapped from a homegrown Vect[N, A] that paid O(n²)
for persistent concat + slice). The builder avoids both
ArrayBuffer's final toArray copy and Vector's two-level
trie access; the result is linear scaling across all sizes at a
consistent ~25-30× overhead over the naive baseline. That
overhead is the Composer chain's per-element .modify
dispatch, not the storage structure.
For single-pass modify of a collection, Traversal.each[F, A, B]
(linear, no downstream composition) is still the correct choice.
Reach for powerEach when the chain needs to continue past the
traversal.
See the composition notes for the full tradeoff matrix.
Reproducing
From the repo root:
# Trustworthy numbers — three forks, five iterations, three warmups.
sbt "benchmarks/Jmh/run -i 5 -wi 3 -f 3 -t 1"
# Smoke check — one fork, faster but noisier.
sbt "benchmarks/Jmh/run -i 3 -wi 2 -f 1 -t 1"
# Filter by class (JMH regex):
sbt "benchmarks/Jmh/run -i 5 -wi 3 -f 3 -t 1 .*JsonTraversalBench.*"
JMH's GC and stack profilers are useful when a number is surprising:
sbt "benchmarks/Jmh/run -i 5 -wi 3 -f 3 -prof gc .*LensBench.*"
sbt "benchmarks/Jmh/run -i 5 -wi 3 -f 3 -prof stack .*PowerSeries.*"