Skip to content

perf: optimize generator-emitted setups by skipping dual registration#741

Merged
vbreuss merged 5 commits intomainfrom
topic/avoid-dual-registration
May 1, 2026
Merged

perf: optimize generator-emitted setups by skipping dual registration#741
vbreuss merged 5 commits intomainfrom
topic/avoid-dual-registration

Conversation

@vbreuss
Copy link
Copy Markdown
Member

@vbreuss vbreuss commented May 1, 2026

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

  • Generator-emitted setups now bypass string-keyed lists for default-scope registrations: Instead, they are stored only in memberId-keyed snapshot tables, making snapshot storage authoritative for fast dispatch and reducing unnecessary allocations. This affects SetupMethod, SetupIndexer, and SetupEvent registration logic.
  • Scenario-scoped setups remain in scenario buckets; default-scope setups use snapshots: Registration methods now clearly separate scenario and default-scope behaviors, ensuring correct storage and lookup.

Fast-path Dispatch and Enumeration

  • New enumeration logic for method, indexer, and event setups: Methods like GetMethodSetups<T>, GetMatchingIndexerSetupFromSnapshot, and GetEventSetupsByName now walk snapshot tables for default-scope setups, ensuring generator-emitted setups are found efficiently and without unnecessary allocations.
  • Empty-storage fast paths return Array.Empty<T>: This prevents iterator state machine allocations on hot paths when no setups are registered, improving performance.

Diagnostics and Verification Improvements

  • Unused setup enumeration now includes snapshot tables: The verification logic for unused setups now checks both string-keyed lists and snapshot tables, ensuring that generator-emitted setups are included in diagnostic reports.
  • Improved documentation and remarks: XML comments have been updated to clarify the new storage and dispatch behaviors, making the codebase easier to understand and maintain.

@vbreuss vbreuss self-assigned this May 1, 2026
@vbreuss vbreuss force-pushed the topic/avoid-dual-registration branch from c980cf9 to 144d48e Compare May 1, 2026 11:52
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 1, 2026

Test Results

    24 files  +  3      24 suites  +3   10m 55s ⏱️ + 1m 51s
 4 062 tests + 46   4 060 ✅ + 46  2 💤 ±0  0 ❌ ±0 
26 312 runs  +182  26 308 ✅ +182  4 💤 ±0  0 ❌ ±0 

Results for commit 2deaeab. ± Comparison against base commit 381840a.

♻️ This comment has been updated with latest results.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 1, 2026

🚀 Benchmark Results

Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 2.65GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.203
[Host] : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

CreateMock Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 206.90 ns 4.269 ns 3.784 ns 1.00 1048 B 1.00
Mockolate 193.39 ns 3.067 ns 2.869 ns 1.00 1048 B 1.00
Imposter 277.67 ns 6.398 ns 5.984 ns 1.44 2248 B 2.15
TUnitMocks 40.41 ns 1.152 ns 1.078 ns 0.21 224 B 0.21
Moq 1,442.86 ns 15.483 ns 14.482 ns 7.46 2096 B 2.00
NSubstitute 2,069.51 ns 31.640 ns 29.596 ns 10.70 5048 B 4.82
FakeItEasy 1,779.52 ns 41.849 ns 39.146 ns 9.20 2763 B 2.64
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V74 2.60GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.203
[Host] : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Event Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 324.8 ns 5.84 ns 5.46 ns 1.00 1.83 KB 1.00
Mockolate 333.3 ns 2.66 ns 2.36 ns 1.00 1.91 KB 1.00
Moq 13,917.9 ns 152.95 ns 127.72 ns 41.77 12.51 KB 6.56
NSubstitute 5,164.4 ns 24.74 ns 21.93 ns 15.50 9.05 KB 4.75
FakeItEasy 230,237.8 ns 1,782.63 ns 1,580.25 ns 690.90 15.39 KB 8.07
Imposter 1,501.0 ns 53.62 ns 50.16 ns 4.50 8.8 KB 4.62
TUnitMocks 195.2 ns 2.92 ns 2.73 ns 0.59 1.37 KB 0.72
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Intel Xeon Platinum 8370C CPU 2.80GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.203
[Host] : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v4

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Indexer N Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 1 1,069.6 ns 17.55 ns 16.42 ns 1.00 3.9 KB 1.00
Mockolate 1 1,122.3 ns 29.42 ns 27.52 ns 1.00 3.81 KB 1.00
Moq 1 162,933.3 ns 981.89 ns 918.46 ns 145.26 20.36 KB 5.34
NSubstitute 1 9,987.1 ns 61.65 ns 57.66 ns 8.90 12.84 KB 3.37
FakeItEasy 1 11,543.2 ns 80.08 ns 74.91 ns 10.29 13.87 KB 3.64
Imposter 1 983.6 ns 24.09 ns 22.53 ns 0.88 5.16 KB 1.35
baseline* 10 2,968.4 ns 18.45 ns 16.35 ns 1.00 4.95 KB 1.00
Mockolate 10 2,921.1 ns 10.37 ns 9.70 ns 1.00 4.87 KB 1.00
Moq 10 174,187.2 ns 945.05 ns 884.00 ns 59.63 29.89 KB 6.14
NSubstitute 10 23,069.4 ns 74.91 ns 70.07 ns 7.90 26.13 KB 5.37
FakeItEasy 10 24,550.3 ns 87.57 ns 81.91 ns 8.40 35.48 KB 7.29
Imposter 10 2,283.5 ns 38.83 ns 36.32 ns 0.78 7.97 KB 1.64
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.203
[Host] : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Method N Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 1 419.6 ns 2.16 ns 1.91 ns 1.00 2.15 KB 1.00
Mockolate 1 415.2 ns 5.95 ns 5.57 ns 1.00 2.04 KB 1.00
Moq 1 184,721.5 ns 816.42 ns 681.75 ns 445.01 14.58 KB 7.15
NSubstitute 1 5,912.5 ns 66.56 ns 62.26 ns 14.24 9.12 KB 4.47
FakeItEasy 1 6,386.7 ns 63.48 ns 59.37 ns 15.39 8.11 KB 3.98
Imposter 1 640.7 ns 19.30 ns 18.05 ns 1.54 4.04 KB 1.98
TUnitMocks 1 757.5 ns 9.05 ns 8.46 ns 1.82 2.9 KB 1.42
baseline* 10 684.8 ns 1.01 ns 0.90 ns 1.00 2.36 KB 1.00
Mockolate 10 708.8 ns 28.23 ns 26.41 ns 1.00 2.25 KB 1.00
Moq 10 191,617.6 ns 870.60 ns 771.76 ns 270.71 18.48 KB 8.21
NSubstitute 10 8,938.2 ns 39.42 ns 36.87 ns 12.63 12.07 KB 5.37
FakeItEasy 10 10,313.8 ns 65.34 ns 54.56 ns 14.57 16.05 KB 7.13
Imposter 10 1,404.3 ns 91.99 ns 86.04 ns 1.98 5.52 KB 2.45
TUnitMocks 10 1,889.0 ns 35.27 ns 32.99 ns 2.67 4.49 KB 2.00
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V74 2.60GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.203
[Host] : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Property N Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 1 505.0 ns 3.71 ns 3.47 ns 1.00 2.46 KB 1.00
Mockolate 1 521.7 ns 5.83 ns 5.46 ns 1.00 2.46 KB 1.00
Moq 1 10,066.1 ns 108.88 ns 101.85 ns 19.30 10.39 KB 4.22
NSubstitute 1 6,940.8 ns 62.82 ns 58.76 ns 13.30 11.45 KB 4.65
FakeItEasy 1 7,173.0 ns 53.81 ns 44.93 ns 13.75 11.24 KB 4.57
Imposter 1 447.5 ns 8.58 ns 8.03 ns 0.86 3.13 KB 1.27
TUnitMocks 1 767.3 ns 11.48 ns 10.18 ns 1.47 2.51 KB 1.02
baseline* 10 1,012.7 ns 5.43 ns 4.82 ns 1.00 2.95 KB 1.00
Mockolate 10 1,091.5 ns 11.95 ns 11.18 ns 1.00 2.95 KB 1.00
Moq 10 16,410.5 ns 125.18 ns 110.97 ns 15.04 18.28 KB 6.19
NSubstitute 10 15,804.3 ns 171.27 ns 151.82 ns 14.48 21.08 KB 7.14
FakeItEasy 10 16,739.4 ns 56.16 ns 49.78 ns 15.34 30.81 KB 10.43
Imposter 10 1,084.0 ns 4.50 ns 3.99 ns 0.99 4.67 KB 1.58
TUnitMocks 10 2,302.9 ns 43.83 ns 41.00 ns 2.11 4.66 KB 1.58
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.203
[Host] : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Callback Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 397.4 ns 5.30 ns 4.70 ns 1.00 1.79 KB 1.00
Mockolate 348.6 ns 7.92 ns 7.41 ns 1.00 1.68 KB 1.00
Moq 96,116.8 ns 655.75 ns 547.58 ns 275.85 8.88 KB 5.29
NSubstitute 4,443.3 ns 39.40 ns 36.86 ns 12.75 7.74 KB 4.61
FakeItEasy 4,718.8 ns 52.95 ns 46.94 ns 13.54 6.81 KB 4.05
Imposter 408.2 ns 8.86 ns 7.40 ns 1.17 2.38 KB 1.42
TUnitMocks 631.6 ns 20.51 ns 19.19 ns 1.81 2.63 KB 1.56

baseline* rows show the corresponding Mockolate benchmark from the most recent successful main branch build with results, for regression comparison.

@vbreuss vbreuss marked this pull request as ready for review May 1, 2026 12:06
Copilot AI review requested due to automatic review settings May 1, 2026 12:06
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/SetupEvent registrations 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.

Comment thread Source/Mockolate/MockRegistry.Interactions.cs Outdated
Comment thread Source/Mockolate/MockRegistry.Interactions.cs Outdated
Comment thread Source/Mockolate/MockRegistry.Interactions.cs Outdated
Copilot AI review requested due to automatic review settings May 1, 2026 12:19
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread Source/Mockolate/MockRegistry.Verify.cs
Comment thread Source/Mockolate/MockRegistry.Verify.cs
@vbreuss vbreuss force-pushed the topic/avoid-dual-registration branch from 2c94dbb to 4f409de Compare May 1, 2026 12:55
vbreuss added 4 commits May 1, 2026 15:56
…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.
Copilot AI review requested due to automatic review settings May 1, 2026 13:56
@vbreuss vbreuss force-pushed the topic/avoid-dual-registration branch from 4f409de to 1a9a222 Compare May 1, 2026 13:56
@vbreuss vbreuss changed the title perf: Optimize generator-emitted setups by skipping dual registration perf: optimize generator-emitted setups by skipping dual registration May 1, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

Comment thread Source/Mockolate/MockRegistry.Interactions.cs
Comment thread Source/Mockolate/MockRegistry.Interactions.cs
Comment thread Source/Mockolate/MockRegistry.Interactions.cs
Comment thread Source/Mockolate/MockRegistry.Interactions.cs
Comment thread Source/Mockolate/MockRegistry.Verify.cs
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented May 1, 2026

@vbreuss vbreuss merged commit aac7d35 into main May 1, 2026
17 checks passed
@vbreuss vbreuss deleted the topic/avoid-dual-registration branch May 1, 2026 15:52
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 2, 2026

This is addressed in release v3.1.0.

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

Labels

state: released The issue is released

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants