Performance

View Source

< Quick Reference | Up: Introduction | Index | State, Reader & Writer >

Each effect invocation involves handler lookup (O(1) map atom key), continuation creation, and function call overhead. Operations use compact representations — bare atoms for 0-arg ops, small tuples for ops with args. No struct allocation on the hot path.

When effects implement ITotalLinearHandler (like State), the comp macro can emit an inline continuation path that skips Comp.bind/2 closure allocation entirely — eliminating the closure-creation cost from the per-operation overhead.

Benchmarks

A loop incrementing a counter via State.get()/State.put(n + 1) at N=10,000 (run with MIX_ENV=prod for consolidated protocols). The "+catches" variants add the same catch frames Skuld uses for error handling — this is the cost real-world Elixir code with try/rescue already pays:

ApproachTimePer-op
Pure tail recursion189 µs0.019 us
Pure tail recursion + catches263 µs0.026 us
Simple state monad164 µs0.016 us
Simple state monad + catches1.71 ms0.171 us
Evidence-passing (reader)318 µs0.032 us
Evidence-passing (reader) + catches863 µs0.086 us
Evidence-passing (reader + CPS)347 µs0.035 us
Evidence-passing (reader + CPS) + catches1.59 ms0.159 us
Skuld (nested binds, CPS path)2.18 ms0.218 us
Skuld/Comp (comp macro, inline)1.42 ms0.142 us
Freyja~10 ms~1 us

Two Skuld variants are shown:

  • Skuld — uses nested Comp.bind/2 calls (the CPS path)
  • Skuld/Comp — uses the comp macro with the total+linear inline optimisation (the optimal path)

Skuld/Comp is 1.5× faster than the nested-bind Skuld path.

The apples-to-apples comparison is Skuld/Comp vs evidence-passing CPS + catches (both have catch frames): Skuld/Comp achieves 0.89× — it is faster than hand-written evidence-passing CPS + catches. The comp macro path eliminates bind overhead entirely, matching the raw CPS baseline.

Where the overhead comes from

Nearly all the gap between Skuld/Comp and bare evidence-passing CPS is catch frames — the same try/catch mechanism every real-world Elixir application already uses for error handling:

Baselineus/opvs bare CPS
Evidence-passing (reader) + CPS0.0351.0×
Evidence-passing (reader) + CPS + catches0.1594.5×
Skuld/Comp (comp macro inline)0.1424.1×
Skuld (nested binds)0.2186.2×

Catch frames account for 4.5× of the total overhead. Skuld/Comp adds 0.89× — indistinguishable from the catch-only baseline.

Iteration strategies

Effectful iteration over collections at N=1,000:

StrategyTimePer-opNotes
FxFasterList115 us0.12 usFastest; no Yield/Suspend support
Yield160 us0.16 usUse when you need interruptible iteration
FxList167 us0.17 usFull Yield/Suspend support

All three maintain constant per-operation cost as N grows.

Protocol consolidation

Elixir consolidates protocols only in :prod mode. In :dev and :test, protocol dispatch uses a slow dynamic lookup (~75 us per call) instead of the compiled dispatch table used in production. Skuld dispatches through the ISentinel protocol in Comp.run/1.

Always benchmark with MIX_ENV=prod to get production-representative numbers. You can also set consolidate_protocols: true in your mix.exs project config temporarily for benchmarking in dev mode.

Running the benchmarks

# Main benchmark (approaches 1-10)
MIX_ENV=prod mix run bench/skuld_benchmark.exs

# Progressive overhead (adds features one at a time)
MIX_ENV=prod mix run bench/overhead_progressive.exs

# Brook vs GenStage comparison
MIX_ENV=prod mix run bench/brook_vs_genstage.exs

< Quick Reference | Up: Introduction | Index | State, Reader & Writer >