perf: optimize generator-emitted setups by skipping dual registration#741
perf: optimize generator-emitted setups by skipping dual registration#741
Conversation
c980cf9 to
144d48e
Compare
🚀 Benchmark ResultsDetails
Details
Details
Details
Details
Details
|
There was a problem hiding this comment.
Pull request overview
This PR optimizes Mockolate’s setup registration/lookup paths for generator-emitted setups by making the memberId-keyed snapshot tables authoritative for default-scope dispatch, reducing allocations and avoiding redundant storage in string-keyed lists.
Changes:
- Default-scope generator-emitted
SetupMethod/SetupIndexer/SetupEventregistrations now bypass the string-keyed lists and publish only to memberId-keyed snapshot tables. - Dispatch/enumeration paths are updated to consult snapshot tables (methods/indexers/events) and to use empty-storage fast paths (
Array.Empty<T>()) to avoid iterator allocations. - Unused-setup diagnostics now include default-scope snapshot tables for methods and indexers.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| Source/Mockolate/Setup/MockSetups.Methods.cs | Avoids iterator allocation on empty method-setup storage by returning Array.Empty<T>(). |
| Source/Mockolate/MockRegistry.Setup.cs | Removes dual registration for default-scope memberId overloads; snapshot tables become authoritative for generator-emitted setups. |
| Source/Mockolate/MockRegistry.Interactions.cs | Adds snapshot-aware enumeration/matching for methods, indexers, and events (including scenario/default ordering and fast empty paths). |
| Source/Mockolate/MockRegistry.Verify.cs | Includes method/indexer snapshot tables when enumerating unused setups for diagnostics. |
2c94dbb to
4f409de
Compare
…d method setups
The fluent `Setup.MethodName(...)` pipeline routed every call through the
generator-emitted `SetupMethod(int memberId, MethodSetup)` overload, which
ran two parallel registrations: the lock-free `_setupsByMemberId` snapshot
that backs the proxy fast-path (`GetMethodSetupSnapshot`), and the legacy
string-keyed `MockScenarioSetup.Methods` list (`GetMethodSetups<T>(name)`)
inherited from the pre-snapshot dispatch. The dict path is only useful for
hand-written `SetupMethod(MethodSetup)` callers (the `HttpClientExtensions`
pipeline) and for scenario-scoped setups; generator-emitted default-scope
setups were paying the full lazy `MethodSetups` wrapper + `List<MethodSetup>`
allocation, the per-setup lock + `List.Add`, and the per-dispatch iterator
state-machine on unmatched calls.
Drop the dual registration: `SetupMethod(int, MethodSetup)` now writes only
to the snapshot, and `SetupMethod(int, string, MethodSetup)` writes to the
snapshot in the default scope and to the scenario bucket otherwise.
`GetMethodSetups<T>(name)` is rewritten to walk the snapshot table directly
(filtered by `Name` for ref-struct dispatch and scenario fallback callers)
followed by the dict, and `MockSetups.MethodSetups.EnumerateByName<T>`
returns `Array.Empty<T>` instead of an empty-yielding state machine when
its storage is null — letting the unmatched-dispatch hot path skip an
iterator allocation per call when no hand-written entries exist.
`GetUnusedSetups` now also walks the snapshot table so generator-emitted
setups remain visible to the diagnostic enumeration even though they
bypass the dict.
Measured on `CombinedWorkflowBenchmarks` (2 mocks, 3 setups, 4 invokes,
4 verifies — mirrors TUnit's CombinedWorkflow):
Pre 9600 B/iter
Post 9320 B/iter (-280 B/iter)
Time impact is below this benchmark's noise floor (~9.3 KB/iter, GC
variance dominates the per-call savings).
Indexer and event paths share the same dual-registration pattern; those
are intentionally left for follow-up commits to keep this change focused.
…d indexer and event setups
Extends the methods-side optimization to indexers and events. The fluent
`Setup[...]` and `Setup.EventName` pipelines routed every generator-emitted
call through the `SetupIndexer(int, ...)` / `SetupEvent(int, ...)` overloads,
which dual-registered to both the lock-free snapshot table and the legacy
string-keyed `MockScenarioSetup.Indexers` / `.Events` lists. The dict path
is only useful for legacy `SetupIndexer(IndexerSetup)` / `SetupEvent(EventSetup)`
callers and for scenario-scoped setups; the generator-emitted default-scope
case was paying the full lazy wrapper + `List<T>` allocation per mock and
the lock + `List.Add` per setup.
Drop the dual registration:
- `SetupIndexer(int, IndexerSetup)` / `SetupEvent(int, EventSetup)` now write
only to the snapshot.
- `SetupIndexer(int, string, IndexerSetup)` / `SetupEvent(int, string, EventSetup)`
write to the snapshot in the default scope and to the scenario bucket
otherwise.
- `GetIndexerSetup<T>(predicate)` and `GetIndexerSetup<T>(IndexerAccess)`
walk the indexer snapshot table after the scenario bucket and before the
root dict, so scenario-fallback callers (when `IsNullOrEmpty(Scenario)`
is false in the generated dispatch) and ref-struct indexers still find
default-scope memberId-registered setups.
- `GetEventSetupsByName(name)` walks the event snapshot table filtered by
`Name`, which gives the unsubscribe-side dispatch (snapshot keyed only by
the subscribe id) a path to find its setup, and lets scenario-fallback
reach default-scope events.
- `GetUnusedSetups` now also walks the indexer snapshot table for diagnostic
visibility, mirroring the methods change.
Measured with the existing per-feature benchmarks (Mockolate baseline only,
.NET 10, MediumRun) — comparing pre-perf-work (HEAD~1, no opts) to
methods+indexers+events:
Indexer_Mockolate (N=1) 1.385 us / 3.90 KB -> 1.202 us / 3.81 KB (-13.2% time, -90 B)
Indexer_Mockolate (N=10) 3.663 us / 4.95 KB -> 3.393 us / 4.87 KB (-7.4% time, -80 B)
Method_Mockolate (N=1) 617.1 ns / 2.15 KB -> 541.7 ns / 2.04 KB (-12.2% time, -110 B)
Method_Mockolate (N=10) 998.3 ns / 2.36 KB -> 912.4 ns / 2.25 KB (-8.6% time, -110 B)
Workflow_Mockolate 3.377 us / 9.35 KB -> 3.306 us / 9.08 KB (-2.1% time, -270 B)
Event_Mockolate 446.4 ns / 1.83 KB -> 455.4 ns / 1.85 KB (within noise; CI margin ±28 ns)
The indexer and method per-feature workloads measure cleaner than the
combined workflow because they spend a larger fraction of time on the
setup + dispatch paths the optimization touches. The event benchmark
already hit the snapshot fast path on subscribe before the change, so the
saved work on registration shows up only in cold paths the benchmark
doesn't exercise.
No public API surface changes; only private helpers added.
The method had three responsibilities tangled together: scenario-scoped override detection (with a hasScoped flag and yield break), the memberId snapshot walk (two nested loops with a null check), and the root dict walk. Split into three methods: - GetEventSetupsByName decides scoped-vs-default. The scoped path now uses a Count check on the List that EventSetups.GetByName already returns, dropping the lazy "did we yield anything?" flag-and-yield-break pattern. - EnumerateDefaultScopeEventSetupsByName chains the snapshot walk and the root dict walk — no branching of its own. - EnumerateEventSnapshotByName is the bucket-walk loop in isolation. Each method now has a single, easily-named responsibility. No behavioral or allocation-shape change: callers (AddEvent / RemoveEvent dispatch) iterate fully so eager materialization of the scoped list is safe, and GetByName was already allocating a List per call.
4f409de to
1a9a222
Compare
|
|
This is addressed in release v3.1.0. |



This pull request makes significant improvements to how mock setups (methods, indexers, and events) are registered, stored, and enumerated in the Mockolate mocking framework. The changes focus on optimizing storage for generator-emitted setups, ensuring fast-path dispatch, and improving diagnostics and verification, especially for unused setups. The most important changes are grouped below:
Storage and Registration Optimizations
SetupMethod,SetupIndexer, andSetupEventregistration logic.Fast-path Dispatch and Enumeration
GetMethodSetups<T>,GetMatchingIndexerSetupFromSnapshot, andGetEventSetupsByNamenow walk snapshot tables for default-scope setups, ensuring generator-emitted setups are found efficiently and without unnecessary allocations.Array.Empty<T>: This prevents iterator state machine allocations on hot paths when no setups are registered, improving performance.Diagnostics and Verification Improvements