Skip to content

Reuse reflection argument arrays in ConfigurationBinder BindDictionary/BindCollection#130626

Draft
artl93 wants to merge 2 commits into
dotnet:mainfrom
artl93:artl93-binder-reuse-indexer-args
Draft

Reuse reflection argument arrays in ConfigurationBinder BindDictionary/BindCollection#130626
artl93 wants to merge 2 commits into
dotnet:mainfrom
artl93:artl93-binder-reuse-indexer-args

Conversation

@artl93

@artl93 artl93 commented Jul 13, 2026

Copy link
Copy Markdown
Member

Note

Draft / experiment — please do not merge yet. Kept in draft while independent hardware A/B (@EgorBot) is pending. Contact @artl93.

Motivation

Configuration binding runs at app startup and again on every options reload (an IOptionsMonitor change token re-binds the options graph). In the reflection binder, every bound dictionary entry and every bound collection item allocates a throwaway one-element object[] to carry the reflection argument. Apps that bind large or numerous collections/dictionaries (feature-flag maps, endpoint lists, per-tenant settings) pay that per-item allocation on every bind, adding steady-state Gen0 pressure in long-running services.

Change

Hoist a single object?[1] argument array out of the child loop in BindDictionary and BindCollection and overwrite slot 0 each iteration, instead of allocating a fresh array per child. This mirrors the pattern the sibling BindSet method has used for years. The array is allocated lazily on the first successful bind (indexerArguments ??= new object?[1] / addArguments ??= new object?[1]), so routes that are entered but bind zero items (all-invalid values swallowed, or an empty/absent section) allocate nothing — there is no zero-success regression. In BindCollection the lazy allocation is gated on the presence of an Add method, so the no-Add interface path stays allocation-neutral. Source generator is untouched.

Why the reuse is safe: PropertyInfo.SetValue / MethodInfo.Invoke are synchronous and copy the argument array by value into the target frame before returning (MethodBaseInvoker.CheckArguments copies each element into separate invocation storage; PropertyInfo.SetValue allocates its own setter-argument array and copies the index into it). The private, method-local array never escapes, is never read after the call, and is distinct per (recursive) invocation, so overwriting slot 0 on the next iteration is unobservable. Exception type/timing/order is unchanged: the array is only touched on the success path after conversion and recursion have already completed.

Benchmarks (canonical: BenchmarkDotNet exact-parent A/B) + EgorBot

BenchmarkDotNet 0.14.0, MemoryDiagnoser, InProcessEmitToolchain, WarmupCount=4, IterationCount=12. macOS 26.5.2, Apple M5 Pro, .NET SDK 11.0.100-preview.6, .NET 11.0.0 Arm64 RyuJIT. Each arm force-loads the freshly-built binder for that revision; the harness records the SHA-256 of the loaded Microsoft.Extensions.Configuration.Binder.dll at runtime and asserts the two arms differ while Configuration / Abstractions / Primitives are byte-identical:

  • candidate binder 1e173d8b… (this PR, 3888f0b) vs baseline binder b389076e… (exact parent 7aa830a)
  • shared deps identical both arms: configuration 4f4f167e…, abstractions da91bc1f…, primitives d3fcdb36…

Δ = candidate − baseline, Allocated bytes/op:

items N List<int> Δ Dict<string,int> Δ
0 0 0
1 0 0
10 −288 −286
100 −3,168 −3,169
1000 −31,979 −31,954

Model: Δ ≈ −32 × (N − 1) bytes/op for N ≥ 1 (one object[1] per successfully bound child eliminated; the single reused array is still allocated once, lazily, so N=1 nets zero). Mean times are within run-to-run noise — this is purely an allocation reduction with no throughput change.

Fixed-shape @n=100 (Allocated Δ): Dict<string,int> −3,169 · Dict<string,Color> (enum) −3,169 · HashSet<int> −3,168 · nested Dict<string,List<int>> −3,172. Options-reload ×10 @100: List −31,691, Dict −31,688.

Controls — Δ = 0 (byte-identical): int[] and IReadOnlyList<int> (BindArray), ISet<int> and IReadOnlySet<int> (BindSet), scalar POCO (BindProperties). The change is confined to the two touched routes.

Zero-success negative controls — Δ = 0 (no regression): when a changed route is entered but binds zero items (all values fail conversion and are swallowed, or a missing/absent section), lazy allocation means the array is never created: List<int>@100 all-invalid, Dict<string,int>@100 all-invalid, and List/Dict missing-section are all 0. (An earlier eager-allocation variant showed +32 B/op here; lazy-init eliminates it.)

A custom in-process GC.GetAllocatedBytesForCurrentThread allocation probe (warmup=300, iters=4000) corroborates the same deltas within ±0.5 %.

Independent hardware confirmation via @EgorBot -amd -osx_arm64 (targeting head 3888f0b) is requested in a comment below. Blocked-gate caveat: Microsoft.Extensions.Configuration.Binder is an out-of-band NuGet package, not part of the shared framework. EgorBot's runtime-diff harness diffs the shared framework between two commits, so it will load the released binder in both arms and cannot exercise this change — an expected ~0 delta there means "not measured", not "no effect". The authoritative measurement is the local exact-parent binder-swap BDN above, which is proven to load distinct binders per arm.

Validation

  • Reflection Microsoft.Extensions.Configuration.Binder.Tests: 304 passed / 0 failed / 0 skipped, including 3 added NotSourceGenMode tests (custom-collection add order; custom-dictionary set order + overwrite; ErrorOnUnknownConfiguration timing).
  • Source-gen Microsoft.Extensions.Configuration.Binder.SourceGeneration.Tests: 326 passed / 0 failed / 31 skipped (generator untouched; matches baseline).
  • Covered: dictionary overwrite, child ordering, null item values, custom vs interface dispatch, invalid conversion, and no argument-array mutation/aliasing across nested binds.

Risks

  • No measured allocation regression in any scenario (the only prior concern, a +32 B zero-success case in an eager variant, is eliminated by lazy allocation).
  • BDN numbers are Release-built and self-controlled with runtime-verified binder substitution, but are a local machine A/B, not an official dotnet/performance lab run — allocation deltas are exact counts; treat timings as directional.
  • CI is green; no Configuration.Binder test failures.

Lineage

  • Base (exact parent, per-item allocation): 7aa830a03599a8255c2c4abf2947afc5b346cc6f
  • Candidate (lazy-init reuse): 3888f0bea6f492ebfd76baba37349832f9332a8e

Note

This PR description was generated with the assistance of GitHub Copilot.

The reflection-based ConfigurationBinder allocated a fresh single-element
object[] for every successfully bound dictionary child (for
PropertyInfo.SetValue) and every collection item (for MethodInfo.Invoke).
Following the existing BindSet pattern, allocate the argument array once
before the loop and overwrite slot 0 per child. The reused private array
is never observable because reflection invocation is synchronous and
by-value.

Behavior is preserved: configuration-child order, dictionary overwrite
semantics, null item values, custom/interface collection dispatch, and
ErrorOnUnknownConfiguration wrapping/exception timing are unchanged. In
BindCollection the one-slot array is allocated only when an Add method
exists, keeping the null-addMethod path a no-op as before.

Adds targeted reflection-mode tests verifying Add/indexer call order,
per-item values, dictionary overwrite, and exception behavior.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 133a456b-9d7d-4f2c-b1aa-17639ba65277
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-extensions-configuration
See info in area-owners.md if you want to be subscribed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 updates the reflection-based ConfigurationBinder to reuse a single object[] argument array when invoking dictionary indexers and collection Add via reflection, reducing per-element allocations during binding. It also adds targeted tests to validate binding order/value semantics and error behavior for custom collection/dictionary implementations.

Changes:

  • Reuse a single object?[1] array for PropertyInfo.SetValue(..., indexArgs) in BindDictionary.
  • Reuse a single object?[1] array for MethodInfo.Invoke(..., args) in BindCollection.
  • Add new test helper types and tests that validate per-item call order/values and ErrorOnUnknownConfiguration behavior in non-source-gen mode.
Show a summary per file
File Description
src/libraries/Microsoft.Extensions.Configuration.Binder/src/ConfigurationBinder.cs Hoists/reuses reflection invocation argument arrays in BindDictionary/BindCollection.
src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.TestClasses.Collections.cs Adds custom recording collection/dictionary types used by new tests.
src/libraries/Microsoft.Extensions.Configuration.Binder/tests/Common/ConfigurationBinderTests.Collections.cs Adds non-source-gen tests validating call order/values and strict vs default error handling.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 2

MethodInfo tryGetValue = dictionaryType.GetMethod("TryGetValue", DeclaredOnlyLookup)!;
PropertyInfo indexerProperty = dictionaryType.GetProperty("Item", DeclaredOnlyLookup)!;

object?[] indexerArguments = new object?[1];

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correct that indexerArguments is allocated before the loop. This is measured and disclosed as the change's one negative case: +32 B/op, once, and only when the route is entered but zero children bind successfully (all values fail conversion and are swallowed under the default ErrorOnUnknownConfiguration=false, or a present-but-empty section). An absent section stays +0 because the route is never entered. At 100 all-invalid children it is ~0.02% of the ~166 KB the swallowed-conversion path already allocates.

The eager allocation is deliberate: it mirrors the sibling BindSet (line 928), which has allocated its object?[1] before its loop for years — keeping the three collection-binding methods uniform.

Your lazy suggestion (object?[]? indexerArguments = null;indexerArguments ??= new object?[1]; inside the HasNewValue block) would eliminate even that +32 B while preserving the full win (still exactly one allocation per bind that sets ≥1 item). The tradeoff is eager-consistency-with-BindSet vs zero-regression. Flagging it as an open decision for review rather than silently picking one.

Note

This reply was generated with the assistance of GitHub Copilot.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Adopted the suggested lazy allocation. Both paths now use ??= new object?[1] on the first successful bind instead of allocating the array eagerly, so a route that is entered but binds zero items (all values swallowed as invalid, or an empty/absent section) allocates nothing.

Fresh exact-parent Release A/B (candidate 3888f0b vs parent 7aa830a) confirms the zero-success case is now +0 B/op (List all-invalid @100: 165,986 → 165,986; Dict: 190,198 → 190,198; missing section: +0), while the per-item win is preserved (List/Dict −288/−3.2K/−32K at N=10/100/1000). There is no longer any measured regression in any scenario.

Note

This reply was generated with the assistance of GitHub Copilot.

Comment on lines 823 to 827
MethodInfo? addMethod = collectionType.GetMethod("Add", DeclaredOnlyLookup);
object?[]? addArguments = addMethod is not null ? new object?[1] : null;

foreach (IConfigurationSection section in config.GetChildren())
{

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Same tradeoff as the BindDictionary thread, and already guarded here: addArguments is only allocated when an Add method exists (the no-Add interface path stays allocation-neutral). The residual cost is +32 B/op once only when the route is entered and zero items bind successfully (all-invalid/swallowed or present-but-empty); absent sections are +0.

The eager pattern matches the sibling BindSet (line 928) for consistency. The lazy variant — addArguments ??= new object?[1]; inside the if (itemBindingPoint.HasNewValue && addMethod is not null) block — would remove even the +32 B while keeping one allocation per non-empty bind. Deferred as an open decision (eager consistency vs zero-regression), noted on the dictionary thread too.

Note

This reply was generated with the assistance of GitHub Copilot.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Adopted the suggested lazy allocation. Both paths now use ??= new object?[1] on the first successful bind instead of allocating the array eagerly, so a route that is entered but binds zero items (all values swallowed as invalid, or an empty/absent section) allocates nothing.

Fresh exact-parent Release A/B (candidate 3888f0b vs parent 7aa830a) confirms the zero-success case is now +0 B/op (List all-invalid @100: 165,986 → 165,986; Dict: 190,198 → 190,198; missing section: +0), while the per-item win is preserved (List/Dict −288/−3.2K/−32K at N=10/100/1000). There is no longer any measured regression in any scenario.

Note

This reply was generated with the assistance of GitHub Copilot.

@artl93 artl93 changed the title WIP: DO NOT REVIEW Reuse reflection argument arrays in ConfigurationBinder BindDictionary/BindCollection Reuse reflection argument arrays in ConfigurationBinder BindDictionary/BindCollection Jul 19, 2026
@artl93

artl93 commented Jul 19, 2026

Copy link
Copy Markdown
Member Author

SUPERSEDED — do not action this request. The trigger below used an invalid arg (-linux_amd) and targeted an earlier commit. The authoritative EgorBot request is the newer comment on this PR (-amd -osx_arm64, lazy-init head 3888f0b). Trigger neutralized to avoid a duplicate run.

EgorBot -linux_amd -osx_arm64

Original benchmark (retained for reference)
using System.Collections.Generic;
using BenchmarkDotNet.Attributes;
using Microsoft.Extensions.Configuration;

public class ConfigBinderBench
{
    private IConfigurationSection _list = null!, _dict = null!, _listAllInvalid = null!;

    [GlobalSetup]
    public void Setup()
    {
        var list = new Dictionary<string, string?>();
        for (int i = 0; i < 100; i++) list[$"Root:{i}"] = i.ToString();
        _list = new ConfigurationBuilder().AddInMemoryCollection(list).Build().GetSection("Root");

        var dict = new Dictionary<string, string?>();
        for (int i = 0; i < 100; i++) dict[$"Root:k{i}"] = i.ToString();
        _dict = new ConfigurationBuilder().AddInMemoryCollection(dict).Build().GetSection("Root");

        var bad = new Dictionary<string, string?>();
        for (int i = 0; i < 100; i++) bad[$"Root:{i}"] = "not_an_int";
        _listAllInvalid = new ConfigurationBuilder().AddInMemoryCollection(bad).Build().GetSection("Root");
    }

    [Benchmark] public List<int>? BindList() => _list.Get<List<int>>();
    [Benchmark] public Dictionary<string, int>? BindDict() => _dict.Get<Dictionary<string, int>>();
    [Benchmark] public List<int>? BindList_AllInvalid() => _listAllInvalid.Get<List<int>>();
}

Note

This comment was generated with the assistance of GitHub Copilot.

…ection

Allocate the reused object?[1] argument array on the first successful bind
instead of eagerly before the child loop. Routes that enter but bind zero
items (all-invalid values swallowed, or a present-but-empty section) now
allocate nothing, eliminating the +32 B/op zero-successful-binds regression
while preserving the per-item argument-array reuse win. BindCollection now
gates the lazy allocation on addMethod directly rather than on a pre-built
array, keeping the no-Add interface path allocation-neutral.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 133a456b-9d7d-4f2c-b1aa-17639ba65277
Copilot AI review requested due to automatic review settings July 19, 2026 19:02
@artl93

artl93 commented Jul 19, 2026

Copy link
Copy Markdown
Member Author

Consolidated evidence (canonical: BenchmarkDotNet exact-parent A/B)

Harness: BenchmarkDotNet 0.14.0, MemoryDiagnoser, InProcessEmitToolchain, WarmupCount=4, IterationCount=12.
Environment: macOS 26.5.2 (Darwin 25.5.0), Apple M5 Pro (18 physical cores), .NET SDK 11.0.100-preview.6.26352.110, .NET 11.0.0 Arm64 RyuJIT AdvSIMD.

Runtime-verified binder substitution (loaded-assembly SHA-256)

The harness runs benchmarks in-process (InProcessEmit), so the DLL loaded by the driver is exactly what the benchmarks exercise. Each arm hashes the loaded assemblies at runtime:

assembly candidate arm baseline arm equal?
Binder 1e173d8b96fe… (this PR 3888f0b) b389076e5ee9… (parent 7aa830a) DIFFER ✔
Configuration 4f4f167e3c6a… 4f4f167e3c6a… identical ✔
Abstractions da91bc1f1027… da91bc1f1027… identical ✔
Primitives d3fcdb36c8d7… d3fcdb36c8d7… identical ✔

The two arms load different binders while every shared dependency is byte-identical — so the measured delta is attributable to the binder change alone. (An earlier run silently loaded a released 2018 Microsoft.Extensions.Configuration.dll that a transitive NuGet reference shadowed in, which threw MissingMethodException; the driver now force-copies the full fresh net11.0 dependency set per arm and hard-fails on any all-NA arm.)

Scaling — Allocated bytes/op, Δ = candidate − baseline

items N List<int> cand List<int> base List Δ Dict cand Dict base Dict Δ
0 344 344 0 344 344 0
1 2,776 2,776 0 4,113 4,113 0
10 6,193 6,481 −288 9,891 10,177 −286
100 42,418 45,586 −3,168 70,210 73,379 −3,169
1000 396,343 428,322 −31,979 681,964 713,918 −31,954

Model Δ ≈ −32 × (N − 1) bytes/op (32 B = one object[1] on Arm64). Means indistinguishable within error bars.

Fixed shapes @n=100 & scenarios — Allocated bytes/op

method candidate baseline Δ note
BindDictInt Dict<string,int> 67,843 71,012 −3,169 changed route
BindDictEnum Dict<string,Color> 67,845 71,014 −3,169 changed route
BindHashSet HashSet<int> 47,286 50,454 −3,168 changed route
BindNested Dict<string,List<int>> 251,929 255,101 −3,172 changed route
CtrlIntArray int[] 43,497 43,497 0 control (BindArray)
CtrlIReadOnlyList 43,529 43,529 0 control (BindArray)
CtrlISet ISet<int> 12,824 12,824 0 control (BindSet)
CtrlIReadOnlySet 12,824 12,824 0 control (BindSet)
CtrlScalar (POCO) 1,472 1,472 0 control (BindProperties)
ListAllInvalid @100 173,270 173,270 0 zero-success negative
DictAllInvalid @100 187,071 187,071 0 zero-success negative
ListMissing (absent) 344 344 0 zero-success negative
DictMissing (absent) 344 344 0 zero-success negative
ListReloadx10 @100 420,251 451,942 −31,691 options reload ×10
DictReloadx10 @100 609,101 640,789 −31,688 options reload ×10

Every changed route improves by ≈ −32 × (N − 1); every control and every zero-success negative is byte-identical (no regression, and lazy-init confirmed allocation-neutral when a route binds zero items).

Corroboration (secondary)

A custom in-process GC.GetAllocatedBytesForCurrentThread probe (warmup=300, iters=4000, same exact-parent binder swap) independently reproduces the same deltas within ±0.5 % (e.g. List@1000 −31,975; Dict@100 −3,185). It is corroborating only; the BenchmarkDotNet run above is canonical.

Tests

  • Reflection Microsoft.Extensions.Configuration.Binder.Tests: 304 / 0 / 0 (incl. 3 added semantic tests).
  • Source-gen …Binder.SourceGeneration.Tests: 326 / 0 / 31 skipped (generator untouched).

Lineage

  • Base (exact parent): 7aa830a03599a8255c2c4abf2947afc5b346cc6f
  • Candidate (lazy-init reuse): 3888f0bea6f492ebfd76baba37349832f9332a8e

Note

This comment was generated with the assistance of GitHub Copilot.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new

@artl93

artl93 commented Jul 19, 2026

Copy link
Copy Markdown
Member Author

@EgorBot -amd -osx_arm64

Supersedes the earlier EgorBot request on this PR (which used an invalid -linux_amd arg). This targets the lazy-init final head 3888f0bea6f492ebfd76baba37349832f9332a8e.

using System.Collections.Generic;
using BenchmarkDotNet.Attributes;
using Microsoft.Extensions.Configuration;

public class ConfigBinderBench
{
    private IConfigurationSection _list = null!, _dict = null!, _listAllInvalid = null!;

    [GlobalSetup]
    public void Setup()
    {
        var list = new Dictionary<string, string?>();
        for (int i = 0; i < 100; i++) list[$"Root:{i}"] = i.ToString();
        _list = new ConfigurationBuilder().AddInMemoryCollection(list).Build().GetSection("Root");

        var dict = new Dictionary<string, string?>();
        for (int i = 0; i < 100; i++) dict[$"Root:k{i}"] = i.ToString();
        _dict = new ConfigurationBuilder().AddInMemoryCollection(dict).Build().GetSection("Root");

        var bad = new Dictionary<string, string?>();
        for (int i = 0; i < 100; i++) bad[$"Root:{i}"] = "not_an_int";
        _listAllInvalid = new ConfigurationBuilder().AddInMemoryCollection(bad).Build().GetSection("Root");
    }

    [Benchmark] public List<int>? BindList() => _list.Get<List<int>>();
    [Benchmark] public Dictionary<string, int>? BindDict() => _dict.Get<Dictionary<string, int>>();
    [Benchmark] public List<int>? BindList_AllInvalid() => _listAllInvalid.Get<List<int>>(); // zero-success negative control
}

Interpretation caveat. Microsoft.Extensions.Configuration.Binder is an out-of-band NuGet package, not part of the shared framework. EgorBot's runtime-diff harness swaps the runtime, so if it resolves the binder from the released package it will load the same binder in both arms and report ~0 delta — that would be a harness limitation, not absence of the effect. The authoritative measurement remains the local exact-parent binder-swap A/B in the PR description (BindList/BindDict−3.2 KB/op at 100 items; BindList_AllInvalid+0 B/op — the zero-success negative control allocates nothing after switching to lazy ??= new object?[1] initialization). Posting for independent confirmation on real hardware regardless.

Note

This comment was generated with the assistance of GitHub Copilot.

@artl93

artl93 commented Jul 20, 2026

Copy link
Copy Markdown
Member Author

Status: still WIP / draft — do not review or merge yet

This PR remains an experiment in draft. Current state:

  • ✅ Canonical evidence is now a BenchmarkDotNet exact-parent A/B (see the consolidated evidence comment), with runtime-verified binder substitution — candidate binder 1e173d8b… vs baseline b389076e…, shared deps byte-identical. The prior custom allocation probe is retained as corroboration only.
  • ✅ Result: Δ ≈ −32 × (N − 1) bytes/op on the two changed routes; 0 B on every control and every zero-success negative (no regression).
  • ✅ Tests green (reflection 304/0/0 incl. 3 added semantic tests; source-gen 326/0/31-skip). CI green.
  • Blocked external gate: @EgorBot -amd -osx_arm64 is requested, but Microsoft.Extensions.Configuration.Binder is an out-of-band NuGet package, so EgorBot's shared-framework runtime-diff may not load this PR's binder in either arm. A ~0 delta from EgorBot would mean "not exercised", not "no effect" — the authoritative measurement is the local binder-swap BDN above.

Keeping this in draft until independent confirmation lands. Feedback on the approach is welcome; please hold formal review.

Note

This comment was generated with the assistance of GitHub Copilot.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants