Skip to main content
Version: 0.3.0

Benchmarks

Benchmarks for the saga coordinator's hot path: step advancement (Coordinator.Advance) and verb dispatch. They exist to (1) establish a baseline and (2) guard the allocation-reduction work that follows. This page records the baseline captured before any tuning.

This is issue #19. The baseline is below; the After โ€” PR2 section records the tuning deltas.

What is measuredโ€‹

All benchmarks run against the in-memory store (store/memory) with a SystemClock, so they isolate the engine's own CPU and allocation cost. In service mode the dominant cost is store and message-queue I/O (Postgres, RabbitMQ); that latency is deliberately out of scope here โ€” these numbers measure engine overhead, not deployed throughput.

AreaBenchmarkPackage
Step advancement (serial)BenchmarkAdvanceengine
Step advancement (concurrent)BenchmarkAdvanceParallelengine
Registry lookupBenchmarkRegistryLookupengine/verbs
Verb dispatch (per verb)BenchmarkVerbExecuteengine/verbs
Verb dispatch (concurrent)BenchmarkVerbExecuteParallelengine/verbs
CEL env / compile / evalBenchmarkNewEnv, BenchmarkCompile, BenchmarkEval, BenchmarkNewEnvCompileEval, BenchmarkNewEnvParallelinternal/cel
Audit event creationBenchmarkNewEventdomain

How to runโ€‹

# All hot-path benchmarks with allocation stats.
go test -run='^$' -bench=. -benchmem ./engine/... ./internal/cel/... ./domain/...

# A single area.
go test -run='^$' -bench=BenchmarkAdvance -benchmem ./engine/...

allocs/op and B/op are deterministic and are the primary signal for this work. ns/op varies with host load (these were captured on a shared machine); treat the timings as indicative, not absolute, and always compare old vs new on the same host in one sitting.

Baselineโ€‹

Captured with -benchtime=200ms, GOMAXPROCS=4, Go 1.26, Intel Xeon Gold 6426Y, Linux/amd64. Timings are indicative; allocation columns are the stable baseline.

engine โ€” step advancementโ€‹

A single Advance call drives every synchronous step of a run, so multi_step_N is the cost of an N-step linear saga end to end.

Benchmarkns/opB/opallocs/op
Advance/trivial3,8041,3147
Advance/single_verb12,0613,31615
Advance/multi_step_1031,21215,55094
Advance/multi_step_100303,304142,0821,003
AdvanceParallel/trivial6,0991,3127
AdvanceParallel/single_verb8,7983,32815
AdvanceParallel/multi_step_1034,99515,60194
AdvanceParallel/multi_step_100301,307142,0901,003

Per step the loop costs roughly ~9 allocs (the delta between consecutive multi_step sizes is ~10 allocs/step), driven by audit-event creation and per-step state/variable writes through the store.

engine/verbs โ€” dispatchโ€‹

Benchmarkns/opB/opallocs/op
RegistryLookup7.200
VerbExecute/noop42481
VerbExecute/set_var_literal2003362
VerbExecute/set_var_cel113,26068,4441,021
VerbExecute/transform111,77868,4421,021
VerbExecute/map_10185,184117,2671,545
VerbExecute/filter_10205,035120,9351,610
VerbExecute/map_100218,094153,1301,728
VerbExecute/filter_100235,462156,7981,793
VerbExecute/decision103,52359,399807
VerbExecute/parallel_28,7342,85454
VerbExecute/parallel_418,4035,735108
VerbExecuteParallel/set_var_cel65,04468,4471,021
VerbExecuteParallel/transform66,86068,4451,021
VerbExecuteParallel/map_100146,907153,1321,728

The registry lookup and literal set_var are effectively free. Every CEL-bearing verb is ~500x more expensive โ€” set_var_cel and transform each cost ~1,021 allocs/op even though they evaluate a trivial expression.

internal/cel โ€” expression primitivesโ€‹

Benchmarkns/opB/opallocs/op
NewEnv/vars_024,03820,837261
NewEnv/vars_525,00221,518272
NewEnv/vars_2024,44723,615305
Compile92,97851,7201,020
Eval24500
NewEnvCompileEval133,65382,1461,331
NewEnvParallel14,65921,518272

This isolates the headline finding: a compiled program evaluates in 245 ns with zero allocations, but the verbs rebuild the environment and recompile the expression on every single dispatch (NewEnvCompileEval: ~1,331 allocs/op). NewEnv and Compile together account for essentially all of the allocation cost seen in the CEL verbs above.

domain โ€” audit eventsโ€‹

Benchmarkns/opB/opallocs/op
NewEvent490161

NewEvent's cost is a uuid.New() plus a time.Now(); the hot loop emits two to three per step. The UUID and timestamp are audit-critical, so this is a documented floor rather than a tuning target.

After โ€” PR2 (CEL program cache)โ€‹

The tuning adds cel.CompiledProgram, a thread-safe cache that memoises the compiled CEL program for a given (declared variable set, expression) pair, so the verbs no longer rebuild and recompile the environment on every dispatch. The map/filter verbs additionally reuse a single activation map across elements instead of cloning run.Variables per element.

Allocation reductions on the CEL-bearing verbs, measured with benchstat old.txt new.txt over -count=10 -benchtime=100ms runs taken back to back on the same host:

Benchmarkallocs/op (before โ†’ after)B/op (before โ†’ after)
VerbExecute/set_var_cel1,021 โ†’ 6 (โˆ’99.4%)68,444 โ†’ 456 (โˆ’99.3%)
VerbExecute/transform1,021 โ†’ 6 (โˆ’99.4%)68,444 โ†’ 456 (โˆ’99.3%)
VerbExecute/map_101,545 โ†’ 21 (โˆ’98.6%)114.5Ki โ†’ 1.58Ki (โˆ’98.6%)
VerbExecute/map_1001,728 โ†’ 24 (โˆ’98.6%)149.5Ki โ†’ 7.05Ki (โˆ’95.3%)
VerbExecute/filter_101,610 โ†’ 21 (โˆ’98.7%)118.1Ki โ†’ 1.58Ki (โˆ’98.7%)
VerbExecute/filter_1001,793 โ†’ 24 (โˆ’98.7%)153.1Ki โ†’ 7.05Ki (โˆ’95.4%)
VerbExecute/decision807 โ†’ 9 (โˆ’98.9%)57.9Ki โ†’ 1.34Ki (โˆ’97.7%)
VerbExecuteParallel/set_var_cel1,021 โ†’ 6 (โˆ’99.4%)68,445 โ†’ 456 (โˆ’99.3%)
VerbExecuteParallel/transform1,021 โ†’ 6 (โˆ’99.4%)68,442 โ†’ 456 (โˆ’99.3%)
VerbExecuteParallel/map_1001,728 โ†’ 24 (โˆ’98.6%)149.5Ki โ†’ 7.05Ki (โˆ’95.3%)
engine/verbs geomean323 โ†’ 12 (โˆ’96.4%)23.2Ki โ†’ 1.19Ki (โˆ’94.9%)

Wall-clock falls in step with the allocations once the cache is warm (set_var_cel ~136ยตs โ†’ ~0.8ยตs, the engine/verbs sec/op geomean โˆ’94%), but timings vary with host load โ€” the allocation columns are the authoritative result.

What is intentionally unchanged:

  • Advance/* uses literal set_var (no CEL), so its allocs/op are identical before and after; the small sec/op wobble is host noise.
  • VerbExecute/parallel_* passes literal branch lists (not a CEL string), so its path is untouched (54 / 108 allocs/op unchanged).
  • internal/cel NewEnv / Compile / NewEnvCompileEval call the raw primitives directly and remain the reference cost of an uncached build โ€” they show what the cache now avoids.
  • NewEvent is unchanged: its UUID + timestamp are audit-critical, so it stays a documented floor rather than a tuning target.

Comparing runs (benchstat)โ€‹

go test -run='^$' -bench=. -benchmem -count=10 ./engine/... ./internal/cel/... ./domain/... > old.txt
# ...make a change...
go test -run='^$' -bench=. -benchmem -count=10 ./engine/... ./internal/cel/... ./domain/... > new.txt
benchstat old.txt new.txt

Run old.txt and new.txt back to back on the same idle host so the ns/op comparison is meaningful; the allocs/op delta is reliable regardless.