Skip to content

[Perf]: Eliminate all heap allocations in mutation result creation and materialization #94

Description

@rian-be

Summary

Eliminate per call heap allocations associated with MutationResult<TState> creation and MutationHistoryEntry/MutationAuditEntry materialization by converting output DTOs to readonly record struct, caching singletons for success/empty defaults, and pre caching supporting objects in benchmark setup.

Goal

Reduce result creation time by ~79–98% and allocation to 0 B across all benchmark scenarios. Reduce materialization time by ~96–97% and allocation to 0 B.

Problem

Every MutationResult<TState>.Success() / .Failure() / .PolicyBlocked() call and every history/audit entry materialization incurred several unnecessary heap allocations:

  1. MutationResult<TState> was class every result creation allocated new heap object including its internal default initialized ValidationResult, MutationMetrics (with Dictionary), and empty collections.
  2. ValidationResult.Success() created new instance each call allocated ValidationResult with three empty List<T> instances.
  3. MutationMetrics default initialized a Dictionary every result carried heap allocated empty dictionary even when no metrics were recorded.
  4. ChangeSet.Empty created a new instance used as default property value on every result, even when immediately replaced.
  5. MutationHistoryEntry and MutationAuditEntry were classes materializing audit or history output allocated the entry object plus all supporting objects (MutationIntent, MutationContext, side-effect list copy).
  6. SideEffect.ResolveContract ran reflection every SideEffect.Create() called GetCustomAttributes to resolve contract metadata.
  7. CreateIntent() / CreateContext() called per iteration in benchmarks allocated MutationIntent (with HashSet + Dictionary) and MutationContext (with Dictionary) each benchmark iteration.

Scope

MutationResult<TState> (src/Abstractions/Results/MutationResult.cs):

  • Convert from sealed record (class) to readonly record struct removes the per instance heap allocation associated with result object creation.
  • Add = [] property initializers on PolicyDecisions and SideEffects, plus explicit parameterless.

ValidationResult (src/Abstractions/Results/ValidationResult.cs):

  • Add private static readonly ValidationResult _success = new() singleton Success() returns the cached instance instead of new ValidationResult().

MutationMetrics (src/Abstractions/Metrics/MutationMetrics.cs):

  • Add internal static readonly MutationMetrics Empty = new() used as default in place of new().
  • Add private static readonly IReadOnlyDictionary<string, object> _emptyAdditionalMetrics shared empty dictionary default.

ChangeSet (src/Abstractions/Changes/ChangeSet.cs):

  • Add private static readonly ChangeSet _empty = new() singleton Empty property returns cached instance.

SideEffect (src/Abstractions/Effects/SideEffect.cs):

  • Add ConcurrentDictionary<Type, (string?, int?)> _contractCacheResolveContract looks up the cache before running reflection is performed once per type in steady state. Concurrent first-use races may perform duplicate harmless reflection work via TryAdd.

MutationHistoryEntry (src/Abstractions/History/MutationHistoryEntry.cs):

  • Convert from sealed class to readonly record struct materialized as value type, eliminating the entry object heap allocation.

MutationAuditEntry (src/Abstractions/Audit/MutationAuditEntry.cs):

  • Convert from sealed class to readonly record struct materialized as value type, eliminating the entry object heap allocation.

MutationHistory (src/Abstractions/History/MutationHistory.cs):

  • FirstMutationAt / LastMutationAt replaced FirstOrDefault()?.Timestamp / LastOrDefault()?.Timestamp with Entries.Count > 0 ? Entries[0].Timestamp : null / Entries[^1].Timestamp pattern (struct FirstOrDefault() returns default(T) so ?. is inapplicable).

Benchmarks/Results/MutationResultCreationBenchmarks.cs:

  • Pre cache side-effect lists in [GlobalSetup] benchmark body only measures MutationResult<TState>.Success() with already existing references.

Benchmarks/Results/MutationOutputMaterializationBenchmarks.cs:

  • Pre-cache MutationIntent, MutationContext, SideEffects.ToList() in [GlobalSetup] benchmark body only measures new MutationHistoryEntry { ... } / new MutationAuditEntry { ... } with already-existing references.

Design Expectations

  • MutationResult<TState> is value type. Its default value contains null references for reference type fields, so factory methods must explicitly initialize all required reference properties. Property initializers (= []) and the explicit parameterless constructor ensure that new MutationResult<TState>() produces valid empty collections and defaults, while default remains zero initialized struct.
  • No method signatures or interface contracts changed. Public result and output types retain their existing members, but their underlying representation changes from reference types to value types.
  • MutationHistoryEntry / MutationAuditEntry as structs means default(T) produces all null fields consumers must use new() { ... } syntax with explicit property values (existing usage already does this).
  • Entries.FirstOrDefault()?.Timestamp no longer compiles for struct T replaced with explicit Count > 0 guard.
  • No boxing occurs when stored in generic containers (IReadOnlyList<T>, List<T>, Dictionary<,List<T>>). Boxing would occur only if passed to non generic IEnumerable, object, or IEqualityComparer paths.
  • No existing is null checks or as casts are used on either type zero consumer breakage.
  • Materialization benchmarks isolate entry value type construction from supporting object creation by pre caching Intent, Context, and side effect lists in [GlobalSetup]. Real pipeline execution also incurs these one-time allocation costs per mutation, but they are not per materialization costs.
  • MutationResult<TState> creation itself introduces no heap allocation because all reference-type fields point to existing cached instances (ValidationResult.Success(), MutationMetrics.Empty, Array.Empty<PolicyDecision>, etc.) rather than newly created objects.

Singleton Safety (Mutability Verification)

The following cached singletons share mutable internal state and must not be mutated:

SingletonInternal StateMutation MethodsRisk
ValidationResult._successList<T> (private readonly)AddError, AddWarning, AddInfoLow no call site invokes .Success().Add*(...) in current codebase
ChangeSet._emptyList<StateChange> (private readonly)Add(StateChange)Low — ChangeSet.Empty is read only in all usages

If defensive protection is required, follow up can freeze the internal lists (eg., ImmutableList<T>, or flag that causes Add to throw). This is not addressed in the current scope.

Acceptance Criteria

  • MutationResult<TState> is readonly record struct zero heap allocation per Success()/Failure()/PolicyBlocked()
  • ValidationResult.Success() returns cached singleton 0 allocation per call
  • MutationMetrics.Empty is cached singleton 0 allocation per result
  • ChangeSet.Empty is a cached singleton 0 allocation per use
  • SideEffect.ResolveContract uses ConcurrentDictionary cache reflection hit once per type
  • MutationHistoryEntry is readonly record struct zero allocation per materialization
  • MutationAuditEntry is readonly record struct zero allocation per materialization
  • All Creation benchmarks show 0 B allocated across all 3 cases
  • All Materialization benchmarks show 0 B allocated across both cases
  • All existing unit tests pass without modification

Benchmark regression validated:

BenchmarkBeforeAfterΔ TimeΔ Alloc
Success_NoSideEffects88.25 ns / 528 B18.61 ns / 0 B−79%−100%
Success_SingleSideEffect280.39 ns / 792 B18.72 ns / 0 B−93%−100%
Success_MultipleSideEffects899.22 ns / 1960 B18.79 ns / 0 B−98%−100%
HistoryEntry_Materialization688.7 ns / 896 B23.40 ns / 0 B−97%−100%
AuditEntry_Materialization666.0 ns / 848 B24.47 ns / 0 B−96%−100%

Non Goals

  • This issue does not change IMutationHistoryStore or IMutationAuditor interfaces
  • This issue does not address StateSizeEstimator.Estimate call guards
  • This issue does not optimize PolicyModificationApplier.Apply heap allocation
  • This issue does not change MutationEngine, MutationExecutionPipeline, or any execution orchestration code
  • This issue does not introduce breaking changes to method signatures or interfaces (underlying type representation changes from reference to value types, but no API surface changes)

Note

  • Struct conversion of MutationResult<TState> required adding = [] property initializers on PolicyDecisions and SideEffects, plus an explicit public MutationResult() { } parameterless constructor.
  • MutationHistory.csFirstMutationAt/LastMutationAt used FirstOrDefault()?.Timestamp with struct MutationHistoryEntry, FirstOrDefault() returns default(T) (cannot be null), so ?. is invalid. Replaced with Entries.Count > 0 ? Entries[0].Timestamp : null.
  • ConcurrentDictionary.TryAdd means concurrent first use may run duplicate reflection results are idempotent and harmless.

Metadata

Metadata

Assignees

Labels

performancePerformance improvements or regressions

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions