Perf quick wins Q1–Q4 (+ handoff/fix-plan refresh) - #21
Conversation
…–Q4) Five FIX-PLAN quick wins; Q5 was already resolved by B4 (#8), so this is Q1–Q4. - Q1 SettingsCollection.GetEnumerator: was rebuilding a whole Dictionary via ToDictionary on every enumeration; now yields over the backing dictionary. - Q2 SettingsTypesExtractor: replace Name.ToLower().EndsWith(suffix.Trim().ToLower()) with EndsWith(suffix, OrdinalIgnoreCase) and hoist the trimmed suffix out of the per-type predicate. Also fixes a culture-sensitivity smell (ToLower was CurrentCulture). Regression test locks case-insensitive matching. - Q3 EnvironmentVariableBinder: fast-path context.Key when there's no prefix and no formatter (skips the per-property StringBuilder), and collapse the Contains+indexer double lookup to a single IDictionary indexer read. Regression test covers the prefix branch. - Q4 SettingsClassGenerator: cache the generated impl by interface Type in a ConcurrentDictionary instead of re-querying the module by mangled type name on every call. Regression test asserts the same interface returns the cached Type. Build clean on net8.0 + net10.0; 55/55 tests green on net10.0 (was 52).
Bring the running status current: P2 (#18) and the docs tutorials (#20) merged, the #8–#20 workstream branches pruned, and Q1–Q4 quick wins in flight (Q5 was already resolved by B4). Flip the merged checklist items and re-rank next priorities to P3 → P4 → P5, then engine tests (T7 concurrency race still open) and architecture.
Q4's Type-keyed cache exposed a latent collision: GenerateType derived the impl type name from the *simple* interface name (GetNormalizeInterfaceName = Type.Name minus leading I), so two settings interfaces sharing a simple name across namespaces (Foo.ISettings + Bar.ISettings) both mapped to the same module type name. Under the old name-keyed lookup the second silently reused the first's (wrong) type; under the Type-keyed cache the second DefineType now throws and aborts the whole scan. Fix derives the impl name from the namespace-qualified FullName (sanitized), kept deliberately separate from GetNormalizeInterfaceName — that helper also backs SettingsOptions.SectionNameFormatter (the config section name), which must stay simple-name-based. + a regression test generating two same-simple-name interfaces. 56/56 green on net10.0.
The macro ScanBenchmark can't resolve the quick wins (they're <1% of the IL-emit/populate cost). These isolate each changed hot path so the wins are measurable and trackable: - EnumerateBenchmark (Q1): enumerate a ~2000-entry ISettingsCollection. - EnvBinderBenchmark (Q3): EnvironmentVariableBinder.BindPropertySettings fast path. - GenerateTypeBenchmark (Q4): warm SettingsClassGenerator.GenerateType (cache hit). Grants the benchmark assembly InternalsVisibleTo (Q4 uses the internal generator) and references the Binders project (Q3 uses EnvironmentVariableBinder).
Update: M1 fix + micro-benchmarks (proof)M1 (from review) fixed ( Micro-benchmarks added (
Honest framing: these are per-operation wins on repeated paths (warm resolves, enumeration, per-property env binding). Cold startup is dominated by (Q2 is a culture-correctness fix — |
…22) Runs BenchmarkDotNet on every push to master and on PRs, then feeds per-benchmark allocated-bytes into benchmark-action/github-action-benchmark: - push to master records the new baseline on the gh-pages data branch (dev/bench); - PRs compare against that baseline, comment on a >10% allocation jump, and fail the check. Gates on allocated bytes rather than time: allocation counts are deterministic and stable on shared runners, so they can safely fail a build; time stays informational (in the run logs). JSON export -> jq reshape -> customSmallerIsBetter was validated locally against ScanBenchmark. Mirrors ci.yml's SDK/cache setup. The micro-benchmark filters activate automatically once PR #21 lands them on master.
…23) * Session wrap: refresh handoff + fix-plan; gitignore BenchmarkDotNet output - Handoff/fix-plan now reflect #21 merged (Q1–Q4 + M1 collision fix + micro-benchmarks, proven 2.7x–32x on repeated paths) and #22 open (per-push benchmark tracking, gate on allocation regressions). - Records the durable facts: gate on allocations not time, gh-pages holds the baseline, and the M1 rule (generated impl name is separate from the section name). - Next priority remains P3. - gitignore BenchmarkDotNet.Artifacts/ so local benchmark runs don't leave untracked output. * gitignore .claude/settings.local.json (personal SessionStart hook)
Implements the FIX-PLAN perf quick wins. Five items were planned; Q5 was already resolved by B4 (#8) — the dead
string.Equals(x, null, …)checks inBindingContextare gone — so this is Q1–Q4. Each is a small, behavior-preserving optimization with a regression test where behavior could subtly shift.Changes
SettingsCollection.GetEnumeratorrebuilt a wholeDictionaryviaToDictionaryon every enumeration. Nowyield returns over the backing dictionary — same pairs, same order, no per-enumeration allocation.SettingsTypesExtractormatched the suffix withName.ToLower().EndsWith(suffix.Trim().ToLower())(twoToLowerallocations per scanned type, underCurrentCulture). NowEndsWith(suffix, StringComparison.OrdinalIgnoreCase)with the trimmed suffix hoisted once out of the per-type predicate. Also removes the culture-sensitivity smell. (Regression test: a type ending…SETTINGSstill matches the defaultSettingssuffix — a case-sensitiveEndsWithwould miss it.)EnvironmentVariableBinderallocated aStringBuilderper property and did a double lookup (Contains+ indexer). Now fast-pathscontext.Keywhen there's no prefix and no formatter (the common case, zero allocation), and does a singleIDictionaryindexer read (returns null when absent; env values are never null when present). (Regression test added for the prefix branch.)SettingsClassGeneratorre-queried the module by mangled type name (Assembly.GetType(name.Replace("+","\\+"))) on everyGenerateType. Now caches the generated impl by interfaceTypein aConcurrentDictionary<Type, Type>. (Regression test: the same interface returns the cachedType.)DefineTyperace — that stays a T7 concurrency item.Verification
Also
SESSION-HANDOFF.md+FIX-PLAN.md: marks P2 (Memoize property extraction, drop the O(n^2) dedup (P2) #18) / docs (Refresh the docs/ tutorials for the SimpleSettings rename and current API #20) / Q1–Q4 done, records the Fix five correctness bugs in the binding engine + add regression tests #8–Refresh the docs/ tutorials for the SimpleSettings rename and current API #20 branch pruning, and re-ranks next priorities to P3 → P4 → P5, then engine tests and architecture.