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?)> _contractCache ResolveContract 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:

Singleton Internal State Mutation Methods Risk
ValidationResult._success 3× List<T> (private readonly) AddError, AddWarning, AddInfo Low no call site invokes .Success().Add*(...) in current codebase
ChangeSet._empty List<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:

Benchmark Before After Δ Time Δ Alloc
Success_NoSideEffects 88.25 ns / 528 B 18.61 ns / 0 B −79% −100%
Success_SingleSideEffect 280.39 ns / 792 B 18.72 ns / 0 B −93% −100%
Success_MultipleSideEffects 899.22 ns / 1960 B 18.79 ns / 0 B −98% −100%
HistoryEntry_Materialization 688.7 ns / 896 B 23.40 ns / 0 B −97% −100%
AuditEntry_Materialization 666.0 ns / 848 B 24.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.cs FirstMutationAt/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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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