Skip to content

Cache a per-type settings plan (P3) - #24

Merged
guy-lud merged 2 commits into
masterfrom
perf/p3-compiled-settings-plan
Jul 13, 2026
Merged

Cache a per-type settings plan (P3)#24
guy-lud merged 2 commits into
masterfrom
perf/p3-compiled-settings-plan

Conversation

@guy-lud

@guy-lud guy-lud commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

P3 — cached "settings plan"

ValuesPopulator now builds a per-type SettingsPlan once (cached on the populator instance, scoped to the builder's SettingsOptions) instead of re-doing reflection on every populate:

  • Section name resolved once and lazily — a builder with no binders (the cold assembly scan) never pays the section-name attribute lookup.
  • Per property, a readonly struct PropertyPlan / PropertyConversion carrying the resolved key, default value, and the converter chosen once up front (folds in converter caching). Converter selection walks the LinkedList manually rather than via LINQ First, so its struct enumerator isn't boxed onto the heap.

Results (BenchmarkDotNet, ShortRun — allocations)

Benchmark Before After Δ
Warm re-populate — 1 prop 368 B 176 B −52%
Warm re-populate — 10 props 3,040 B 1,408 B −54%
Warm re-populate — 50 props 15,681 B 6,848 B −56%
ScanBenchmark.ColdScan (gated) 17.70 MB 18.52 MB +4.66%

The gated ScanBenchmark rise is pure plan-build overhead on the populate-once cold scan; it's well under the 10% allocation gate. A new gated PlanPopulateBenchmark tracks the warm path going forward.

On the "compiled setter"

The fix-plan floated emitting the setter into the generated type (or a compiled Action). I built and measured the emitted __Set variant: it regressed the gated cold ScanBenchmark by +25% (extra per-type IL-emit, not amortized on a populate-once scan) for zero warm gain — net10's PropertyInfo.SetValue no longer allocates an args array, so the reflective set is already allocation-free. Reverted; reflective SetValue retained. A tiered/lazy compiled setter (P3b) is noted as a follow-up only if set time (not allocation) ever shows up in a profile.

Notes

  • 56 tests pass on net8.0 + net10.0.
  • Behavior preserved: identical exception wrapping (SettingsBindingException / SettingsPropertyValueException / not-allow-null throw order) and identical section / key / converter resolution.
  • Handoff + FIX-PLAN.md refresh ride this PR (no separate docs branch).

guy-lud added 2 commits July 12, 2026 23:07
ValuesPopulator now builds a SettingsPlan once per settings interface (cached on the populator instance, scoped to the builder's Options) instead of re-doing reflection on every populate. The plan resolves the section name once and lazily (a no-binder scan never pays for it), and holds per property a readonly-struct PropertyPlan/PropertyConversion carrying the resolved key, default value, and the converter chosen once up front. Converter selection walks the LinkedList manually rather than via LINQ First, so its struct enumerator is not boxed.

Warm re-populate drops 52-56% in allocations (50 props: 15,681 -> 6,848 B). The gated ScanBenchmark rises +4.66% (under the 10% gate) - pure plan-build overhead on the populate-once cold scan. A new gated PlanPopulateBenchmark tracks the warm path.

Reflective PropertyInfo.SetValue is retained: an emitted __Set method / compiled Action<object,object?> setter was implemented and measured but reverted - it regressed the gated cold ScanBenchmark (+25% for the emitted __Set) for zero warm gain, since net10's SetValue no longer allocates an args array. A tiered/lazy compiled setter (P3b) is noted as a follow-up only if set time (not allocation) ever matters.

56 tests pass on net8.0 + net10.0.
From a two-agent (code-review + perf) pass over the P3 diff:

- Read [SettingsProperty] once per property at plan build instead of 3x (via GetPropertyName / GetDefaultValue / CreateConversion). GetCustomAttribute re-materializes the attribute each call, so at ~2000-type cold-scan scale this dominated: it drops the gated ScanBenchmark from +4.66% to ~flat (-0.40% vs master). The now-inlined GetPropertyName/GetDefaultValue helpers are retired (PropertyInfoExtensions deleted).

- Re-wrap converter-setup failures at plan build as SettingsPropertyValueException, restoring the pre-P3 exception contract (a custom ConverterType that throws in its ctor / isn't an ISettingsTypeConverter used to surface wrapped, inside the per-populate convert try).

- Materialize the section binders once in SettingsBuilder so the populator's 'as ISectionBinder[]' fast-path hits (the factory hands a SortedList.Values view); removes a per-populate ToArray (~32 B/call). Warm re-populate is now -55 to -61% vs master (50 props 15,681 -> 6,816 B).

- Document ISettingsTypeConverter as stateless/thread-safe (custom converters are now selected once per type and shared). Minor: fix stale __Set benchmark docstring, drop a redundant string interpolation in GetNormalizeInterfaceName, pass PropertyPlan by 'in'.

56 tests pass on net8.0 + net10.0.
@guy-lud
guy-lud merged commit faa48d9 into master Jul 13, 2026
2 checks passed
@guy-lud
guy-lud deleted the perf/p3-compiled-settings-plan branch July 13, 2026 08:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant