Runtime Performance
Runtime Performance
Section titled “Runtime Performance”The runtime performance suite (just perf-runtime) measures the runtime hot paths of the code the
source generator emits, using the generated aggregates and value objects in src/src/Samples
(OrderAggregate, CustomerAggregate, EmailAddress, Money, OrderStatus, UserDetails, …).
It runs under BenchmarkDotNet with [MemoryDiagnoser], so every case reports both wall time and
allocated bytes per operation.
Running
Section titled “Running”just perf-runtime # quick run (1 warmup, 3 iterations)just perf-runtime --benchmark # benchmark run (3 warmup, 12 iterations)Equivalent: dotnet run --project src/src/Benchmarks/Benchmarks.csproj --configuration Release -- runtime.
Always use Release; in Debug the JIT produces meaningless numbers.
What is measured
Section titled “What is measured”| Category | Cases |
|---|---|
| Aggregate | new CustomerAggregate(), new OrderAggregate() (per-instance registration cost) |
| Command | generated partial methods: CreateOrder, ConfirmOrder, ShipOrder, CompleteOrder, AddLineItem, RegisterCustomer, ChangeEmail |
| Replay | single event application, 100-event stream replay, event GetHashCode |
| ValueObject | scalar (EmailAddress, CurrencyCode, OrderStatus) and complex (Money, UserDetails) Create/Hydrate/equality/hash/compare/ToString/implicit conversion |
| Serialization | event payload round-trip (the reflection-based provider path), snapshot round-trip through the generated OrderAggregateJsonConverter, value-object round-trip through generated converters |
| Collections | EventStoreList add/enumerate, EventStoreSet add/contains/remove |
| Mapping | IAggregateEventNameMapper.GetName per event type |
Command and replay cases construct a fresh aggregate per invocation (measured together with the
command), which reflects the realistic hot path: an aggregate is loaded or created, then mutated.
The delta between Command_CreateOrder and AggregateConstruction_Order isolates the command cost.
Interpreting results
Section titled “Interpreting results”Each run writes artifacts/runtime-performance/{history,latest.json} and compares against the
previous run. The suite fails when:
- allocated bytes per operation regress by more than 10% for any case, or
- mean wall time regresses by more than 40% for any case.
Allocations are the most reliable regression signal in a micro-benchmark: an extra event allocation, delegate, or closure per operation shows up immediately as a byte jump. When reporting a regression, record the machine, framework, mode, and the history file (the previous-run comparison is only meaningful on the same machine).
Known hotspots and findings
Section titled “Known hotspots and findings”The suite exists to surface and track these; the numbers below are from a representative run and should be re-measured locally.
- Generated command methods construct a single event record per invocation: the property
On<Property>Changinghooks run,OnShouldApplyis evaluated beforeOnRaising, the payload is re-synchronized from post-hook values, and the event is recorded viaRecordAndApply. A re-introduction of a second event construction shows up as a byte jump. - Event application/replay is allocation-free:
Replay_100EventStreamallocates the same bytes as constructing the aggregate, because applying an event is a shared-applier lookup plus a delegate call plus property assignments. - Aggregate construction is near-zero allocation:
AggregateBasebuilds a per-type static applier map once (open delegates shared across instances), stores unsaved events in aList<(object, EventMetadata)>, and derivesAggregateTypefrom a cached name. With events assealed recordpayloads carrying a structEventMetadata(no per-event metadata heap object),new OrderAggregate()dropped from ~2.1 KB to ~0.4 KB andCommand_CreateOrderfrom ~2.6 KB to ~0.8 KB. - Event-name mapping is allocation-free on the hot path:
AggregateEventNameMappercaches names by CLR type, so the per-eventType.AssemblyQualifiedNamestring is no longer built on every save (EventNameMapper_GetNamemeasures 0 bytes/op). - Event/snapshot payloads serialize through reflection-based System.Text.Json (no
source-generated
JsonSerializerContextfor events); the generated aggregate/value-object converters are thin wrappers over DTOs and are already reflection-free. EventMetadatais[JsonIgnore]d, so payloads carry no per-event metadata and provider row columns are the source of truth on replay. - SQL Server save (SQL suite): skipping the guaranteed-miss existence
SELECTfor brand-new stream rows, cachingDbContextOptions, gating cache-key allocations onCacheMode, and folding the snapshot write into the events batch/transaction (atomic by default viaRequireSnapshotWrite, with a best-effort opt-out) reducedEventStore_Savefrom ~16 ms to ~12 ms. TheEventStore_Save_NoSnapshotcase isolates the default per-save snapshot (Interval=1) cost; operators can raise the cadence withoperationContext.SetSnapshotStrategy(new IntervalSnapshotStrategy<TAggregate>(N))with no code change. - In-memory store suite:
just perf-inmemorymeasures the allocation-free reference implementation — save ~10 µs, cached get ~0.3 µs, and a 101-event replay ~35 µs — so provider overhead can be compared against a zero-I/O baseline.
The source-generator suite now short-circuits identical reruns via a pre-compilation marker (warm ≈
6–12% of cold); see Source-Generator-Performance.md.