Skip to content

[Perf]: Eliminate policy evaluation overhead #92

Description

@rian-be

Summary

Eliminate per call overhead in policy evaluation: cache sorted policies, skip async state machines for synchronous policies, remove redundant Task<PolicyDecision> and PolicyDecision allocations, and use NoOp auditor/history stores in benchmark isolation.

Goal

Reduce policy evaluation time by ~75% and allocation by ~45% across all scenarios. Flatten the scaling cost evaluating 4 policies should cost the same as evaluating 0.

Problem

Every EvaluateAsync call incurred several unnecessary costs:

  1. Per call sorting - OrderByDescending / ThenBy ran on every invocation even though policies rarely change at runtime.
  2. Double async state machine - EvaluatePolicyAsync -> InvokePolicyAsync created two async state machines even for synchronous policies with no timeout configured.
  3. Sync policy Task allocation - sync policies' default EvaluateAsync wraps Evaluate via Task.FromResult, allocating new Task<PolicyDecision> per call.
  4. PolicyDecision.Allow() allocation - the parameterless Allow() created new heap object every time.
  5. Audit/history noise in benchmarks - InMemoryAuditor and InMemoryHistoryStore have IsEnabled => true, causing audit entry + history entry allocation per successful execution even when no policies are registered.

Scope

PolicyDecision (src/Abstractions/Policies/PolicyDecision.cs):

  • Add private static readonly PolicyDecision _allow singleton for parameterless Allow() calls returns cached instance instead of new PolicyDecision { IsAllowed = true }.

MutationPolicyEvaluator (src/Runtime/Internal/Evaluation/MutationPolicyEvaluator.cs):

  • Cache sorted IMutationPolicy<TState>[] per state type via ConcurrentDictionary<Type, object> registry query + sort hit once per type, then zero per call sorting overhead.
  • Return ValueTask<PolicyDecision> instead of Task<PolicyDecision> sync completions wrap the result directly without Task allocation.
  • Check task.IsCompletedSuccessfully before await synchronous policies (default EvaluateAsync returns Task.FromResult) complete via task.Result, avoiding async state machine + Task<PolicyDecision> allocation.
  • Eliminate InvokePolicyAsync wrapper merge try/catch inline in EvaluateNoTimeoutAsync.
  • Handle sync throws from EvaluateAsync with inline try catch at each call site.
  • Split into EvaluateNoTimeoutAsync / AwaitRemainingAsync the first policy that does not complete synchronously triggers the async fallback; all-sync paths return a synchronously completed ValueTask.

MutationExecutionPipeline (src/Runtime/Internal/Execution/MutationExecutionPipeline.cs):

  • EvaluatePolicyDecisionAsync returns ValueTask<PolicyDecision> with sync fast path eliminates inner Task<PolicyDecision> + async state machine when the evaluator completes synchronously.
  • Timing recording happens inline in the sync path via evaluateTask.Result; a separate AwaitPolicyAndRecordAsync handles the async-only case.

Benchmarks/Policy/PolicyBenchmarkSupport.cs:

  • SyncAllowBenchmarkPolicy / AsyncAllowBenchmarkPolicy cache PolicyDecision + Task<PolicyDecision> per instance zero allocation per evaluation.
  • Register NoOpAuditor + NoOpHistoryStore in BuildEngine eliminates audit entry + history entry allocation from the benchmark baseline, isolating policy evaluation cost.

Design Expectations

  • Policy evaluation for N synchronous allow policies must allocate the same as the no policy baseline the evaluator itself adds zero per-policy allocation.
  • The ConcurrentDictionary cache is populated once per state type; if policies change at runtime the cache becomes stale (acceptable for current usage policies are registered at engine startup and rarely mutated).
  • No public API or interface changes.
  • try/catch blocks guard synchronous throws from EvaluateAsync (rare; happens only when policy implementation throws rather than returning a faulted task). The IsCompletedSuccessfully fast path avoids await for the common synchronous allow case.

Acceptance Criteria

  • PolicyDecision.Allow() with no arguments returns cached singleton avoids per-call PolicyDecision heap allocation
  • MutationPolicyEvaluator caches sorted policies per type GetPolicies + sort overhead incurred once per type
  • Sync policies complete via IsCompletedSuccessfully check without await
  • EvaluateAsync returns ValueTask<PolicyDecision> zero Task<PolicyDecision> allocation for sync completion
  • No double async state machine InvokePolicyAsync removed, try/catch inlined
  • Policy evaluation benchmarks show at most 1.01× ratio vs baseline
  • All 4 policy benchmarks land at ~1.24 us / 1.66 KB
  • All existing unit tests pass without modification

Benchmark regression validated:

BenchmarkBeforeAfterΔ TimeΔ Alloc
NoPolicy_Baseline5.007 us / 3.01 KB1.226 us / 1.66 KB−75%−45%
SingleSyncPolicy_Allow5.324 us / 3.98 KB1.241 us / 1.66 KB−77%−58%
SingleAsyncPolicy_Allow5.307 us / 3.98 KB1.238 us / 1.66 KB−77%−58%
MultipleMixedPolicies_Allow5.607 us / 5.43 KB1.241 us / 1.66 KB−78%−69%

Non Goals

  • This issue does not change the public IMutationPolicy<TState> interface
  • This issue does not address ValueTask<T> for the interceptor pipeline
  • This issue does not address per-state SemaphoreSlim eviction in MutationExecutionConcurrencyGate
  • This issue does not touch StateSizeEstimator.Estimate call guards
  • This issue does not change any public API or interface

Note

The ConcurrentDictionary.TryGetValue + indexer set pattern has benign race: multiple threads may build the same immutable policy array concurrently during first access. Only one array is ultimately published for subsequent lookups duplicate builds are harmless and do not affect correctness or policy ordering.

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