Skip to content

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

dotnet run -c Release --project benchmarks/Compono.Benchmarks -f net10.0

-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.