Skip to content

Performance baseline

This page records what Elara costs per operation, measured by the benchmarks that live beside the code. It exists so that a performance change is something that gets noticed rather than discovered later.

Treat the absolute numbers as indicative of one machine. What travels between machines is the shape: the ratios between operations, how a cost scales with size, and which line item dominates.

Running them

cd web && npm run build   # web/embed.go needs web/dist/ to exist
make bench                # full run, 6 samples, writes benchmarks.txt
make bench-quick          # smoke run: do the benchmarks still work

To compare a change against its starting point:

git stash && make bench && mv benchmarks.txt base.txt && git stash pop
make bench && make bench-compare BASE=base.txt

CI does the same automatically on every pull request and prints the comparison to the job summary. It is informational: on a shared runner benchstat reports real regressions and imaginary ones in roughly equal measure, so it does not gate the merge. The job does fail if a benchmark stops compiling or starts erroring, which is what keeps the suite from quietly rotting.

Two durability modes

bbolt calls fsync on every commit. That single syscall costs ~8 ms here — roughly a hundred times more than everything else a write does — so it hides whatever is being measured.

Benchmarks therefore run with NoSync by default, and the ones whose name ends in _Durable keep the real fsync behaviour. The mode is part of the benchmark name deliberately: benchstat matches on names, so it cannot silently diff one mode against the other.

Read it this way: _Durable numbers are the latency a client observes. The rest are the cost of the code, which is the part a change can actually move.

Storage — etcd-compatible KV path

internal/storage/bbolt/config/etcd.go

Operation Time Allocations
PutKey new key 86 µs 186
PutKey new key, durable 8.2 ms 154
PutKey overwrite 66 µs 159
PutKey contended (8 writers, one key) 63 µs 159
RangeQuery single key 2.9 µs 26
RangeQuery prefix, 10 keys 20 µs 85
RangeQuery prefix, 100 keys 189 µs 728
RangeQuery prefix, 1000 keys 1.9 ms 7032
RangeQuery keys-only, 100 keys 163 µs 428
RangeQuery at revision 3.5 µs 28
DeleteRangeKeys single key 72 µs 150
CurrentRevisionValue 0.6 µs 10

Storage — structured config CRUD

internal/storage/bbolt/config/repository.go

Operation Time Allocations
Create 86 µs 184
Get 3.9 µs 25
Update 61 µs 157
Delete 73 µs 142
ListSummariesByPrefix, 1000 configs 1.71 ms 5029
ListSummaryPage (20 of 1000) 1.52 ms 1105
SearchByPath over 1000 configs 140 µs 2051
GetConfigHistory, 20 of 50 revisions 79 µs 492

etcd wire operations

internal/handler/etcdv3/kv_server.go, in-process (no gRPC listener)

Operation Time Allocations
Put 74 µs 183
Put, durable 8.1 ms 161
Txn compare-and-swap 77 µs 210
Txn compare-and-swap, contended 81 µs 215
Txn compare fails (no write) 3.9 µs 42
Txn read-only range 4.2 µs 53
Txn 1 put 76 µs 194
Txn 5 puts 202 µs 460
Txn 20 puts 570 µs 1412
Txn 1 put, durable 8.2 ms 177
Txn 5 puts, durable 8.5 ms 422
Txn 20 puts, durable 9.4 ms 1339

Watch fan-out

internal/transport/watch/publisher.go

Operation Time Allocations
Deliver to 1 matching subscriber 156 ns 0
Deliver to 10 697 ns 0
Deliver to 100 5.6 µs 0
Deliver to 1000 90 µs 0
1000 subscribers, none matching 18 µs 0
100 subscribers, buffers full (events dropped) 8.4 µs 299
Subscribe + unsubscribe 1.6 µs 6

Schema validation on the write path

internal/service/schemavalidator/validator.go

Operation Time Allocations
Validate JSON, schema cached 3.7 µs 47
Validate YAML, schema cached 12.8 µs 123
Validate, schema not cached (compiles) 87 µs 731
Validate, 1 attachment in namespace 3.8 µs 47
Validate, 10 attachments in namespace 4.2 µs 47
Validate, 100 attachments in namespace 8.6 µs 47
Validate, payload rejected 3.4 µs 48
Validator early-return, no schema matched 8 ns 0

The early-return figure excludes the storage lookup that precedes it — the fake store in that benchmark answers from memory. It says only that once the lookup comes back empty, the validator adds nothing.

What these numbers say

One fsync per write, and it is ~99% of the cost. A durable write is 8.2 ms against 77 µs of actual work. Anything that changes how many transactions a request opens changes client-visible latency by milliseconds, not microseconds.

A Txn is one transaction, and that is what makes multi-op cheap. The durable 1/5/20-put curve is 8.2 ms, 8.5 ms, 9.4 ms — almost flat, because all the operations share a single commit and therefore a single fsync. Before Txn became atomic each operation committed separately and the same curve read 8.1 ms, 40.4 ms, 165 ms. Twenty writes in one transaction went from 165 ms to 9.4 ms, a 17-fold improvement, with allocations down 54 %.

That is worth stating plainly because the expectation was the opposite: widening a transaction was assumed to cost throughput, since bbolt permits one writer at a time. It did not. The serialisation concern was real but it applies between concurrent Txn calls, not to the operations inside one — and even there nothing measurable appeared, with contended compare-and-swap coming out 3 % faster than before.

Writes are fully serialised, and contended matches sequential. Under RunParallel a single-writer resource lands at the same ns/op as its sequential benchmark, because throughput is capped at one writer no matter how many wait. Parity is therefore the baseline, not evidence that contention is free. If the contended number ever rises above the sequential one, a transaction is being held open longer per call.

Paging saves allocations, not scanning. Fetching 20 summaries out of 1000 costs 1.52 ms against 1.71 ms for fetching all of them — but 134 KB against 364 KB. The bbolt scan is the same; only materialisation is bounded.

Schema validation no longer scales with how much is configured. Allocations per validated write are flat at 47 whether the namespace holds 1 attachment or 100, and time grows only with the glob matching itself: 3.8 µs → 8.6 µs across that range.

This is the first thing the benchmarks caught. The compiled schema was cached, but the glob in findBestMatch was not, and it ran once per attachment on every write: a namespace with 100 attachments paid 86 µs and 1846 allocations per write, matching or not. Caching the compiled pattern alongside its specificity score cut that by 90% in time and 97% in allocations, and made the allocation count independent of configuration size.

Watch fan-out is allocation-free but linear in subscribers. notify holds a read lock and walks every subscription, so 1000 watchers cost 90 µs per event even though only the matching ones are delivered to — 18 µs of that is the walk and filter alone.