Skip to content

Commit a8c361f

Browse files
authored
Perf: Eliminate hot path allocations using volatile snapshot + ArrayPool (#87)
2 parents 27f4709 + ef0da20 commit a8c361f

2 files changed

Lines changed: 203 additions & 31 deletions

File tree

Lines changed: 196 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.Buffers;
2+
using System.Runtime.CompilerServices;
13
using ModularityKit.Mutator.Abstractions.Changes;
24
using ModularityKit.Mutator.Abstractions.Context;
35
using ModularityKit.Mutator.Abstractions.Intent;
@@ -16,22 +18,32 @@ namespace ModularityKit.Mutator.Runtime.Interception;
1618
/// to ensure deterministic execution order.
1719
/// </para>
1820
/// <para>
19-
/// The pipeline also filters interceptors via <see cref="MutationInterceptorBase.ShouldRun"/>.
21+
/// The pipeline also filters interceptors via <see cref="MutationInterceptorBase.ShouldRun"/>.
2022
/// Method calls are executed asynchronously to integrate with the ModularityKit mutation pipeline.
2123
/// </para>
24+
/// <para>
25+
/// The execution path reads from a lock-free volatile snapshot updated atomically on register/unregister,
26+
/// avoiding any lock acquisition or array allocation on the hot path when no interceptors are registered.
27+
/// </para>
2228
/// </remarks>
2329
internal sealed class InterceptorPipeline : IInterceptorPipeline
2430
{
2531
private readonly List<IMutationInterceptor> _interceptors = [];
2632
private readonly Lock _lock = new();
2733

34+
// Cached immutable snapshot of the registered interceptors. Invalidated (set to null)
35+
// on every Register/Unregister and lazily rebuilt on next read. `volatile` ensures
36+
// readers on other threads observe fully published array or null, never a partial write.
37+
private volatile IMutationInterceptor[]? _snapshotCache = [];
38+
2839
/// <summary>
29-
/// Registers a new interceptor in the pipeline.
40+
/// Registers new interceptor in the pipeline.
3041
/// </summary>
31-
/// <param name="interceptor">The interceptor to register. Cannot be <c>null</c>.</param>
42+
/// <param name="interceptor">The interceptor to register. Cannot be null.</param>
3243
/// <exception cref="ArgumentNullException">Thrown if <paramref name="interceptor"/> is <c>null</c>.</exception>
3344
/// <remarks>
3445
/// After adding, the interceptors list is sorted by <c>Order</c> to guarantee deterministic execution order.
46+
/// The snapshot cache is invalidated so the next read lazily rebuilds it.
3547
/// </remarks>
3648
public void Register(IMutationInterceptor interceptor)
3749
{
@@ -40,6 +52,7 @@ public void Register(IMutationInterceptor interceptor)
4052
{
4153
_interceptors.Add(interceptor);
4254
_interceptors.Sort((a, b) => a.Order.CompareTo(b.Order));
55+
_snapshotCache = null;
4356
}
4457
}
4558

@@ -49,16 +62,43 @@ public void Register(IMutationInterceptor interceptor)
4962
/// <param name="name">The name of the interceptor to remove.</param>
5063
public void Unregister(string name)
5164
{
52-
lock (_lock) _interceptors.RemoveAll(i => i.Name == name);
65+
lock (_lock)
66+
{
67+
_interceptors.RemoveAll(i => i.Name == name);
68+
_snapshotCache = null;
69+
}
5370
}
5471

5572
/// <summary>
56-
/// Gets a snapshot of the currently registered interceptors.
73+
/// Gets snapshot of the currently registered interceptors.
5774
/// </summary>
58-
/// <returns>An array of interceptors.</returns>
75+
/// <returns>An array of interceptors. Returns <see cref="Array.Empty{T}"/> without taking
76+
/// the lock when the cached snapshot is still valid and empty.</returns>
77+
/// <remarks>
78+
/// <c>volatile</c> on <see cref="_snapshotCache"/> guarantees an acquire fence on every read,
79+
/// so the calling thread observe fully published array or null — never torn reference.
80+
/// Because the array is immutable after publication, contents are implicitly safe.
81+
/// </remarks>
5982
private IMutationInterceptor[] GetSnapshot()
6083
{
61-
lock (_lock) return _interceptors.ToArray();
84+
var cached = _snapshotCache;
85+
if (cached is not null)
86+
return cached;
87+
88+
lock (_lock)
89+
{
90+
// Re check under the lock in case another thread already rebuilt it.
91+
cached = _snapshotCache;
92+
if (cached is not null)
93+
return cached;
94+
95+
cached = _interceptors.Count == 0
96+
? []
97+
: [.. _interceptors];
98+
99+
_snapshotCache = cached;
100+
return cached;
101+
}
62102
}
63103

64104
/// <summary>
@@ -67,23 +107,51 @@ private IMutationInterceptor[] GetSnapshot()
67107
/// <param name="intent">The mutation intent.</param>
68108
/// <param name="context">The mutation context.</param>
69109
/// <returns>An array of interceptors that should be executed.</returns>
70-
private IMutationInterceptor[] GetApplicable(MutationIntent intent, MutationContext context)
110+
/// <remarks>
111+
/// Uses the cached snapshot. When no interceptors are registered, returns the cached empty array
112+
/// without any filtering overhead. When all interceptors are applicable, the snapshot is reused
113+
/// directly without allocating a filtered copy.
114+
/// A pooled buffer is used during filtering to avoid per-call <c>List&lt;T&gt;</c> allocation.
115+
/// </remarks>
116+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
117+
private IMutationInterceptor[] GetApplicable(
118+
MutationIntent intent,
119+
MutationContext context)
71120
{
72121
var snapshot = GetSnapshot();
73-
var result = new List<IMutationInterceptor>(snapshot.Length);
122+
if (snapshot.Length == 0)
123+
return snapshot;
124+
125+
var pool = ArrayPool<IMutationInterceptor>.Shared;
126+
var buffer = pool.Rent(snapshot.Length);
127+
var count = 0;
128+
var excluded = false;
74129

75-
foreach (var t in snapshot)
130+
for (var i = 0; i < snapshot.Length; i++)
76131
{
77-
if (t is MutationInterceptorBase baseInt)
132+
var t = snapshot[i];
133+
var shouldRun = t is not MutationInterceptorBase baseInt || baseInt.ShouldRun(intent, context);
134+
135+
if (shouldRun)
136+
{
137+
buffer[count++] = t;
138+
}
139+
else
78140
{
79-
if (!baseInt.ShouldRun(intent, context))
80-
continue;
141+
excluded = true;
81142
}
143+
}
82144

83-
result.Add(t);
145+
if (!excluded)
146+
{
147+
pool.Return(buffer);
148+
return snapshot;
84149
}
85150

86-
return result.ToArray();
151+
var result = new IMutationInterceptor[count];
152+
Array.Copy(buffer, result, count);
153+
pool.Return(buffer);
154+
return result;
87155
}
88156

89157
/// <summary>
@@ -92,7 +160,10 @@ private IMutationInterceptor[] GetApplicable(MutationIntent intent, MutationCont
92160
/// <param name="action">The action to execute for each interceptor.</param>
93161
/// <param name="intent">The mutation intent.</param>
94162
/// <param name="context">The mutation context.</param>
95-
private async Task ExecuteAsync(Func<IMutationInterceptor, Task> action, MutationIntent intent, MutationContext context)
163+
private async Task ExecuteAsync(
164+
Func<IMutationInterceptor, Task> action,
165+
MutationIntent intent,
166+
MutationContext context)
96167
{
97168
var interceptors = GetApplicable(intent, context);
98169
foreach (var t in interceptors)
@@ -101,19 +172,115 @@ private async Task ExecuteAsync(Func<IMutationInterceptor, Task> action, Mutatio
101172
}
102173
}
103174

104-
/// <inheritdoc/>
105-
public Task OnBeforeMutationAsync(MutationIntent intent, MutationContext context, object state, string executionId, CancellationToken cancellationToken = default)
106-
=> ExecuteAsync(i => i.OnBeforeMutationAsync(intent, context, state, executionId, cancellationToken), intent, context);
175+
/// <summary>
176+
/// Executes registered interceptors before a mutation is evaluated.
177+
/// </summary>
178+
/// <param name="intent">The mutation intent.</param>
179+
/// <param name="context">The mutation execution context.</param>
180+
/// <param name="state">The current mutation state.</param>
181+
/// <param name="executionId">The unique execution identifier.</param>
182+
/// <param name="cancellationToken">Token used to cancel the operation.</param>
183+
/// <returns>A task representing the asynchronous operation.</returns>
184+
public Task OnBeforeMutationAsync(
185+
MutationIntent intent,
186+
MutationContext context,
187+
object state,
188+
string executionId,
189+
CancellationToken cancellationToken = default) =>
190+
ExecuteAsync(
191+
interceptor => interceptor.OnBeforeMutationAsync(
192+
intent,
193+
context,
194+
state,
195+
executionId,
196+
cancellationToken),
197+
intent,
198+
context);
107199

108-
/// <inheritdoc/>
109-
public Task OnAfterMutationAsync(MutationIntent intent, MutationContext context, object? oldState, object? newState, ChangeSet changes, string executionId, CancellationToken cancellationToken = default)
110-
=> ExecuteAsync(i => i.OnAfterMutationAsync(intent, context, oldState, newState, changes, executionId, cancellationToken), intent, context);
200+
/// <summary>
201+
/// Executes registered interceptors after mutation has completed.
202+
/// </summary>
203+
/// <param name="intent">The mutation intent.</param>
204+
/// <param name="context">The mutation execution context.</param>
205+
/// <param name="oldState">The state before the mutation.</param>
206+
/// <param name="newState">The state after the mutation.</param>
207+
/// <param name="changes">The changes produced by the mutation.</param>
208+
/// <param name="executionId">The unique execution identifier.</param>
209+
/// <param name="cancellationToken">Token used to cancel the operation.</param>
210+
/// <returns>A task representing the asynchronous operation.</returns>
211+
public Task OnAfterMutationAsync(
212+
MutationIntent intent,
213+
MutationContext context,
214+
object? oldState,
215+
object? newState,
216+
ChangeSet changes,
217+
string executionId,
218+
CancellationToken cancellationToken = default) =>
219+
ExecuteAsync(
220+
interceptor => interceptor.OnAfterMutationAsync(
221+
intent,
222+
context,
223+
oldState,
224+
newState,
225+
changes,
226+
executionId,
227+
cancellationToken),
228+
intent,
229+
context);
111230

112-
/// <inheritdoc/>
113-
public Task OnMutationFailedAsync(MutationIntent intent, MutationContext context, object state, Exception exception, string executionId, CancellationToken cancellationToken = default)
114-
=> ExecuteAsync(i => i.OnMutationFailedAsync(intent, context, state, exception, executionId, cancellationToken), intent, context);
231+
/// <summary>
232+
/// Executes registered interceptors when mutation fails.
233+
/// </summary>
234+
/// <param name="intent">The mutation intent.</param>
235+
/// <param name="context">The mutation execution context.</param>
236+
/// <param name="state">The current mutation state.</param>
237+
/// <param name="exception">The exception that caused the mutation to fail.</param>
238+
/// <param name="executionId">The unique execution identifier.</param>
239+
/// <param name="cancellationToken">Token used to cancel the operation.</param>
240+
/// <returns>A task representing the asynchronous operation.</returns>
241+
public Task OnMutationFailedAsync(
242+
MutationIntent intent,
243+
MutationContext context,
244+
object state,
245+
Exception exception,
246+
string executionId,
247+
CancellationToken cancellationToken = default) =>
248+
ExecuteAsync(
249+
interceptor => interceptor.OnMutationFailedAsync(
250+
intent,
251+
context,
252+
state,
253+
exception,
254+
executionId,
255+
cancellationToken),
256+
intent,
257+
context);
115258

116-
/// <inheritdoc/>
117-
public Task OnPolicyBlockedAsync(MutationIntent intent, MutationContext context, object state, PolicyDecision decision, string executionId, CancellationToken cancellationToken = default)
118-
=> ExecuteAsync(i => i.OnPolicyBlockedAsync(intent, context, state, decision, executionId, cancellationToken), intent, context);
119-
}
259+
/// <summary>
260+
/// Executes registered interceptors when mutation is blocked by policy.
261+
/// </summary>
262+
/// <param name="intent">The mutation intent.</param>
263+
/// <param name="context">The mutation execution context.</param>
264+
/// <param name="state">The current mutation state.</param>
265+
/// <param name="decision">The policy decision that blocked the mutation.</param>
266+
/// <param name="executionId">The unique execution identifier.</param>
267+
/// <param name="cancellationToken">Token used to cancel the operation.</param>
268+
/// <returns>A task representing the asynchronous operation.</returns>
269+
public Task OnPolicyBlockedAsync(
270+
MutationIntent intent,
271+
MutationContext context,
272+
object state,
273+
PolicyDecision decision,
274+
string executionId,
275+
CancellationToken cancellationToken = default) =>
276+
ExecuteAsync(
277+
interceptor => interceptor.OnPolicyBlockedAsync(
278+
intent,
279+
context,
280+
state,
281+
decision,
282+
executionId,
283+
cancellationToken),
284+
intent,
285+
context);
286+
}

src/Runtime/MutationEngine.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,12 @@
1515
namespace ModularityKit.Mutator.Runtime;
1616

1717
/// <summary>
18-
/// Coordinates mutation execution by handling admission, failure wrapping, and public runtime APIs.
18+
/// Coordinates mutation execution and runtime governance.
1919
/// </summary>
20+
/// <remarks>
21+
/// Handles mutation execution, policy evaluation, interception, auditing,
22+
/// history tracking, metrics, concurrency control, and failure processing.
23+
/// </remarks>
2024
internal sealed class MutationEngine(
2125
IMutationExecutor executor,
2226
IPolicyRegistry policyRegistry,
@@ -33,6 +37,7 @@ internal sealed class MutationEngine(
3337
private readonly IMetricsCollector _metricsCollector = metricsCollector ?? throw new ArgumentNullException(nameof(metricsCollector));
3438
private readonly MutationEngineOptions _options = options ?? throw new ArgumentNullException(nameof(options));
3539
private readonly MutationExecutionConcurrencyGate _concurrencyGate = CreateConcurrencyGate(options);
40+
private static long _executionCounter;
3641
private readonly MutationExecutionFailureHandler _failureHandler = new(interceptorPipeline, auditor);
3742
private readonly MutationExecutionPipeline _executionPipeline =
3843
new(
@@ -58,7 +63,7 @@ public async Task<MutationResult<TState>> ExecuteAsync<TState>(
5863
TState state,
5964
CancellationToken cancellationToken = default)
6065
{
61-
var executionId = Guid.NewGuid().ToString();
66+
var executionId = Interlocked.Increment(ref _executionCounter).ToString("x8");
6267
var stopwatch = Stopwatch.StartNew();
6368
IMetricsScope? metricsScope = null;
6469

0 commit comments

Comments
 (0)