Performance¶
This page documents what Compono's benchmark suite measures and what the results actually say, per ADR-0034's redesigned suite — capability-oriented, not a history of milestone-by- milestone optimization work. Per ADR-0030 Amendment 2's benchmark-claims policy, the numbers below are published so a reader can independently evaluate them — this page makes claims only about Compono itself, never a comparative headline like "faster than AutoFixture."
Every current table on this page — Consumer Scenarios, External Comparison, Feature Overhead, Scalability, Source Generation — comes from one full suite run (see Methodology below); none mixes results from different runs. The one exception is explicitly marked: the Feature Overhead section's UseBogus() discussion cites historical, pre-fix figures alongside the current ones, because that history is the record of a real engineering finding, not a live result.
What Compono optimizes for¶
Compono is source-generated by default (Design Principles, ADR-0001) so that composing a type at runtime dispatches to generated code rather than reflecting over it. That buys predictable, generated-path performance and minimal allocations on the construction-dispatch path itself — but the full resolution pipeline (provider dispatch, deterministic random forking, collection generation, diagnostics tracing) is real, non-zero work on top of that dispatch, and this page reports both honestly, per ADR-0034's "engineering questions first" philosophy.
What the benchmarks measure (and don't)¶
BenchmarkDotNet benchmarks answer narrow, comparative engineering questions — "does approach A cost more than approach B, for one operation, isolated from everything else" — under an artificially clean, JIT-warmed, GC-isolated environment. They are not a substitute for full-application performance testing, a scalability/load test, or a guarantee about any specific consumer's real-world numbers. Every benchmark in this suite ties to one of four goals, per ADR-0034's Amendment: the runtime cost of a specific Compono feature, Compono's scalability, Compono's build-time cost, or the migration experience from AutoFixture — a benchmark that can't be tied to one of those doesn't belong in the suite. Five categories, each answering a different question for a different audience — full detail in ADR-0034:
- Consumer Scenarios — what performance should a user expect composing each representative model in a realistic application?
- External Comparison — what should a developer expect migrating from AutoFixture?
- Feature Overhead — how expensive is one specific mechanism (a member rule, a type rule, a custom provider,
UseBogus(),UseNSubstitute(),[Shared]'s underlying row-sharing mechanism), isolated from everything else? - Scalability — does cost grow linearly or super-linearly as batch size, graph depth, or collection size increases?
- Source Generation — clean vs. incremental generator cost, a build-time concern entirely separate from runtime performance.
Methodology¶
Recorded with BenchmarkDotNet v0.15.8, Apple M3 Max, macOS Tahoe 26.6, .NET 10.0.3 arm64 RyuJIT, Release configuration, DefaultJob. Concretely, DefaultJob means: a pilot stage that determines how many iterations a run needs, a warmup phase, then a set of measured iterations — each launched in its own isolated, managed process, not measured in-process alongside the benchmark harness. Per ADR-0034's Reporting Rules, every table below reports the full mandatory column set: Mean, Error, StdDev, Allocated, and Gen0/Gen1 wherever BenchmarkDotNet reports them as nonzero, plus a Ratio column wherever the category has a designated baseline. A page that reported Mean alone, or Mean and Allocated alone, would not meet that bar — this page doesn't. Full detail — every category's complete result set, raw CSV/HTML exports — is in benchmarks/Compono.Benchmarks's BenchmarkDotNet.Artifacts/results/ after a real run (see Reproducing, below).
Consumer-facing results¶
Representative models (Composer.Create<T>(), no comparison baseline — the absolute cost a consumer actually pays):
| Model | Mean | Error | StdDev | Gen0 | Gen1 | Allocated |
|---|---|---|---|---|---|---|
SimplePoco (flat, no dependencies) | 368.3 ns | 3.17 ns | 2.65 ns | 0.2084 | 0.0005 | 1.70 KB |
MediumAggregate (nested dependency + collection) | 981.1 ns | 8.21 ns | 6.86 ns | 0.3529 | - | 2.88 KB |
DeepGraph (8-level chain) | 1,016.2 ns | 10.46 ns | 8.73 ns | 0.4330 | 0.0019 | 3.54 KB |
LargeCollection (100-element collection) | 8,615.9 ns | 50.54 ns | 44.80 ns | 2.5482 | 0.0458 | 20.86 KB |
Migrating from AutoFixture. "Equivalent work" means both frameworks compose the same object graph shape, fill the same number of fields, and (for Compono's side) use its own real default value-generation cost — an 8-character string, a 3-element collection — not a stripped-down or otherwise favorable graph for either side. Every Ratio/Alloc Ratio value below is AutoFixture relative to Compono (Compono is always the baseline, 1.00×):
| Model | Method | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|---|
SimplePoco | Compono (baseline) | 378.2 ns | 6.30 ns | 8.83 ns | 1.00× | 0.2084 | 0.0005 | 1.70 KB | 1.00× |
SimplePoco | AutoFixture | 24,036.9 ns | 466.53 ns | 572.94 ns | 63.6× | 3.6621 | - | 29.96 KB | 17.6× |
MediumAggregate | Compono (baseline) | 962.0 ns | 15.68 ns | 13.90 ns | 1.00× | 0.3529 | 0.0010 | 2.88 KB | 1.00× |
MediumAggregate | AutoFixture | 77,990.7 ns | 1,543.97 ns | 3,389.05 ns | 81.1× | 11.7188 | - | 99.21 KB | 34.4× |
AutoFixture is doing substantially more runtime work here (reflection- based construction plus its own randomized-value-generation pipeline) — this is a recognizable reference point for what migrating changes, not a target Compono is trying to "beat."
Provider-enabled profiles (Composer.Create<T>() with a package's provider active):
| Scenario | Mean | Error | StdDev | Gen0 | Gen1 | Allocated |
|---|---|---|---|---|---|---|
UseNSubstitute() (composing an interface member) | 1.235 μs | 0.0178 μs | 0.0166 μs | 0.8621 | 0.0114 | 7.05 KB |
UseBogus() (composing two convention-matching string members) | 5.870 μs | 0.1104 μs | 0.1033 μs | 0.8545 | 0.0076 | 7.04 KB |
UseBogus()'s cost here reflects a fix applied after an earlier run of this suite found it substantially higher — see Feature Overhead below for the full account. The two numbers aren't measuring the same scope: this row is the full profile (two Bogus-backed members plus the rest of the MediumAggregate graph); Feature Overhead's UseBogus() row below isolates a single member's marginal cost against its cheapest alternative. Same underlying mechanism, different scope — expect the per-member number below to be smaller than this full-profile one.
Feature overhead¶
Isolates one mechanism's marginal cost at a time (full detail: ADR-0034):
| Mechanism | Method | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|---|
| Configuration rules | GeneratedOnly (baseline) | 962.2 ns | 10.61 ns | 9.40 ns | 1.00× | 0.3529 | - | 2.88 KB | 1.00× |
| Configuration rules | + member rule | 1,208.9 ns | 17.65 ns | 15.65 ns | 1.26× | 0.4082 | 0.0019 | 3.34 KB | 1.16× |
| Configuration rules | + type rule | 956.4 ns | 6.82 ns | 6.38 ns | 0.99× (noise) | 0.3586 | - | 2.93 KB | 1.02× |
| Configuration rules | + custom ICompositionValueProvider | 1,312.8 ns | 12.24 ns | 10.85 ns | 1.36× | 0.4368 | 0.0019 | 3.58 KB | 1.24× |
[Shared] row-sharing | Without sharing (baseline) | 805.5 ns | 5.05 ns | 4.48 ns | 1.00× | 0.3042 | 0.0010 | 2.49 KB | 1.00× |
[Shared] row-sharing | With sharing | 742.7 ns | 6.20 ns | 5.80 ns | 0.92× (sharing is cheaper — avoids composing a second independent value) | 0.3109 | 0.0010 | 2.55 KB | 1.02× |
UseNSubstitute() | Registration (baseline) | 332.0 ns | 2.58 ns | 2.42 ns | 1.00× | 0.2027 | 0.0010 | 1.66 KB | 1.00× |
UseNSubstitute() | NSubstitute provider | 1,206.9 ns | 12.61 ns | 11.79 ns | 3.64× | 0.8698 | 0.0114 | 7.12 KB | 4.30× |
UseBogus() | Member rule (baseline) | 333.4 ns | 4.51 ns | 4.22 ns | 1.00× | 0.2027 | 0.0010 | 1.66 KB | 1.00× |
UseBogus() | Bogus convention provider | 2,254.2 ns | 15.50 ns | 13.74 ns | 6.76× | 0.4425 | - | 3.62 KB | 2.18× |
UseBogus(): a finding, a root cause, a fix, a confirmation. An earlier run of this benchmark measured UseBogus() at ~865× a plain member rule (291.5 μs, isolated single-member measurement). That result was published in full, per ADR-0034's publication rule — an unfavorable result is reported exactly like a favorable one. Investigation traced the cost to BogusMemberNameProvider constructing a new Bogus.Faker instance on every resolution (src/Compono.Bogus/BogusMemberNameProvider.cs); Faker construction, which builds out its full set of category generators, is genuinely expensive. The implementation was changed to cache one Faker per thread for built-in conventions, reseeding its Random immediately before every use, while a custom AddConvention delegate still gets its own single-use Faker (it could mutate state a shared instance shouldn't carry between requests) — see ADR-0027's Amendment for the full account and the regression coverage backing it. Rerun after the fix, the benchmark confirms the result: ~865× dropped to ~6.76× (this table), and the full-profile Consumer Scenario cost dropped from 903.4 μs / 2,229.31 KB to 5.870 μs / 7.04 KB (Consumer-facing results, above).
Scalability¶
Batch scaling (CreateMany<T>(count) against its own Create<T>() baseline, per batch size):
| Count | Method | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|---|
| 1 | Create (baseline) | 994.0 ns | 7.88 ns | 7.37 ns | 1.00× | 0.3529 | - | 2.88 KB | 1.00× |
| 1 | CreateMany | 1,032.4 ns | 9.32 ns | 8.72 ns | 1.04× | 0.3681 | 0.0019 | 3.02 KB | 1.05× |
| 10 | Create (baseline) | 994.0 ns | 8.53 ns | 7.56 ns | 1.00× | 0.3529 | - | 2.88 KB | 1.00× |
| 10 | CreateMany | 10,329.1 ns | 126.09 ns | 117.94 ns | 10.39× | 3.5858 | 0.0610 | 29.31 KB | 10.17× |
| 100 | Create (baseline) | 988.6 ns | 6.32 ns | 5.91 ns | 1.00× | 0.3529 | - | 2.88 KB | 1.00× |
| 100 | CreateMany | 102,933.0 ns | 829.95 ns | 776.33 ns | 104.12× | 35.7666 | 4.7607 | 292.29 KB | 101.39× |
| 1,000 | Create (baseline) | 994.4 ns | 10.48 ns | 9.81 ns | 1.00× | 0.3529 | - | 2.88 KB | 1.00× |
| 1,000 | CreateMany | 1,180,425.4 ns | 7,238.77 ns | 6,771.15 ns | 1,187.19× | 359.3750 | 179.6875 | 2,943.90 KB | 1,021.19× |
Scaling is linear through 100 items; at 1,000 items the ratio (1,187.19× against a 1,000× input-size increase) shows a modest, real super-linear component — Gen1 collections start appearing at this scale (0 at count=10, ~180 at count=1,000) where they don't at smaller batches, consistent with GC promotion pressure rather than an algorithmic regression in the composition pipeline itself.
Graph depth (DeepLevel8 at depth 1 vs. DeepGraph's chain at depth 8 — both resolve exactly one string leaf value, so depth is the only variable; an earlier version of this benchmark compared against MediumAggregate instead, which resolves seven strings and a collection on top of its own object graph, conflating depth with total value-generation work):
| Method | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|
Shallow (DeepLevel8, depth 1, baseline) | 240.4 ns | 4.79 ns | 4.00 ns | 1.00× | 0.1631 | 0.0005 | 1.34 KB | 1.00× |
Deep (DeepGraph, depth 8) | 1,074.5 ns | 12.74 ns | 11.29 ns | 4.47× | 0.4330 | 0.0019 | 3.54 KB | 2.65× |
With depth isolated as the only variable, the real cost is clear: 4.47× the mean, 2.65× the allocation, for a chain 8× as deep — consistent with each additional level's own dispatch, path-segment, and diagnostics- trace-buffer bookkeeping (see The Provider Pipeline), including the real Array.Resize DeepGraph's depth is enough to trigger in the trace buffer that DeepLevel8 alone never reaches.
Collection size (WithCollectionSize(n), 3 to 200 elements):
| CollectionSize | Mean | Error | StdDev | Gen0 | Gen1 | Allocated |
|---|---|---|---|---|---|---|
| 3 | 517.6 ns | 4.59 ns | 3.83 ns | 0.2337 | 0.0010 | 1.91 KB |
| 10 | 1,116.5 ns | 9.41 ns | 8.34 ns | 0.4005 | 0.0019 | 3.28 KB |
| 50 | 4,552.5 ns | 24.15 ns | 22.59 ns | 1.3580 | 0.0153 | 11.09 KB |
| 200 | 17,247.1 ns | 261.28 ns | 218.18 ns | 4.9438 | 0.1831 | 40.39 KB |
Sub-linear relative to the 66.7× size increase (a ~33.3× time increase from 3 to 200 elements), since a fixed per-Create dispatch cost is amortized across more elements at larger sizes.
Source generation¶
Clean vs. incremental generator cost, in-process via Roslyn's GeneratorDriver (a maintainer-facing, build-time concern, unrelated to every result above). The incremental compilation is derived from the clean one via SyntaxTree.WithChangedText with an append-only edit — not a second, independently-parsed tree swapped in — so unaffected nodes keep the identity they had in the base tree, which is what actually lets the generator's incremental pipeline skip recomputing work for call sites nothing changed, rather than measuring a wholesale reparse under an "incremental" label:
| TypeCount | Method | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|---|
| 1 | CleanGeneration (baseline) | 55.58 μs | 0.156 μs | 0.122 μs | 1.00× | 12.2070 | 1.4648 | 103.46 KB | 1.00× |
| 1 | IncrementalGeneration | 25.81 μs | 0.448 μs | 0.397 μs | 0.46× | 3.0518 | - | 25.88 KB | 0.25× |
| 10 | CleanGeneration (baseline) | 230.53 μs | 1.614 μs | 1.431 μs | 1.00× | 72.2656 | 15.6250 | 600.10 KB | 1.00× |
| 10 | IncrementalGeneration | 85.56 μs | 1.509 μs | 1.260 μs | 0.37× | 8.0566 | 0.3662 | 66.60 KB | 0.11× |
| 50 | CleanGeneration (baseline) | 1,030.43 μs | 7.461 μs | 7.328 μs | 1.00× | 343.7500 | 109.3750 | 2,809.42 KB | 1.00× |
| 50 | IncrementalGeneration | 364.52 μs | 7.273 μs | 8.084 μs | 0.35× | 29.2969 | 3.9063 | 247.04 KB | 0.09× |
Incremental generation is consistently faster and allocates substantially less (0.09×–0.25× of clean generation's allocation) across every type count measured — confirming the generator's incremental caching genuinely avoids re-processing unrelated syntax on a small, unrelated source edit, rather than silently falling back to a full recompute.
Reproducing¶
-c Release is required — BenchmarkDotNet refuses to run a Debug build. Add -- --filter "*ClassName*" to run one category at a time (the full suite, across every category's parameter matrix, takes on the order of 15 minutes). Full per-category results (every method, every parameter value, raw CSV/HTML) are written to BenchmarkDotNet.Artifacts/results/ relative to the working directory dotnet run was invoked from.