Redesign CallSiteFactory constructor selection with stable arity ordering and parameter-aware ambiguity#129119
Redesign CallSiteFactory constructor selection with stable arity ordering and parameter-aware ambiguity#129119Copilot wants to merge 3 commits into
CallSiteFactory constructor selection with stable arity ordering and parameter-aware ambiguity#129119Conversation
…n order Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com>
CallSiteFactory deterministic and allocation-lighter
|
Tagging subscribers to this area: @dotnet/area-extensions-dependencyinjection |
|
@copilot, the change can be more ambitious, please reimplement using this design: Constructor selection in
|
| Parameter declaration | HasDefaultValue |
Always resolvable? |
|---|---|---|
IFoo foo |
false | No, needs registration |
IFoo? foo (NRT only) |
false | No, needs registration |
IFoo foo = null |
true | Yes, injects null |
IFoo? foo = null |
true | Yes, injects null |
int n |
false | No |
int n = 42 |
true | Yes |
int? n |
false | No |
int? n = null |
true | Yes |
MyStruct s = default |
true | Yes (via GetUninitializedObject) |
MyEnum? e = MyEnum.Foo |
true | Yes (re-boxed) |
C# 8 nullable reference annotations alone have no effect on DI
resolvability; they are not visible to reflection's HasDefaultValue.
2. Problems with the current code on main
Array.Sort(constructors,
(a, b) => b.GetParameters().Length.CompareTo(a.GetParameters().Length));-
Array.Sortis unstable. When two constructors have the same arity the
"winner" depends on the sort algorithm's internal state, the array length,
the runtime, and the .NET version. Same-arity-identical-type-set ties pick
an indeterminate constructor; same-arity-disjoint ties throw
AmbiguousConstructorExceptionwith an indeterminate pair in the message. -
GetParameters()is called inside the comparator.GetParameters()is
a reflection call that allocates a freshParameterInfo[]per invocation.
Sorting calls it~2·N·log Ntimes, then the loop calls it again per
constructor (N), then the hash-set build calls it once more on
bestConstructor. Total: roughly2·N·log N + N + 1allocations of
ParameterInfo[]per selection. -
The ambiguity model is impoverished.
bestConstructorParameterTypesis
aHashSet<Type>. This conflates:- parameters best resolved via container vs via default, and
- parameters with different
[FromKeyedServices(...)]keys but the same
ParameterType.
3. Problems with PR #129119's approach
PR #129119 replaces Array.Sort with a declaration-order single pass, caches
GetParameters(), and introduces "supersede + recheck" plus deferred
ambiguity tracking.
3.1 Algorithmic worst case
Walking in declaration order means a smaller ctor can become best first and
then be superseded by a larger ctor later. Each supersede:
- calls
CreateArgumentCallSiteson the new larger candidate, and - re-walks
resolvedConstructorsto recompute ambiguity against the new
best.types.
In the LargestLast shape (longest constructor declared last), this
degrades to: O(N) CreateArgumentCallSites calls, plus repeated
resolvedConstructors walks. The benchmark below shows this is only ~10%
faster than the unstable-sort baseline.
3.2 Order-dependent behavioural drift ("Example F")
public Foo(IA a, IB b, IC c = null) { } // arity 3, always resolvable
public Foo(ID d = null) { } // arity 1, always resolvable, disjoint| Declaration order | Old code | PR #129119 |
|---|---|---|
| 3-arg first | throws ambiguous | silently picks 3-arg |
| 1-arg first | throws ambiguous | throws ambiguous |
The PR's behaviour depends on source order, which is undesirable.
3.3 Impoverished ambiguity model unchanged
The PR still uses HasSet<Type> for bestConstructorParameterTypes and so
inherits the conflation of container-vs-default and the keyed-services blind
spot from the old code.
4. Proposed design
A four-step change that, taken together, beats both the current code and the
PR on every measured shape while having simpler control flow and a stronger
behaviour contract.
4.1 Step 1: fused insertion sort during a single build pass
Replace the sort-then-walk structure with a single pass that inserts each
constructor into its sorted position as GetParameters() is called for it.
This:
- removes the comparator delegate (no closure allocation),
- calls
GetParameters()exactlyNtimes (down from2·N·log N + N + 1), - produces an arity-DESC, declaration-stable order via the natural stability
of insertion sort withstrictly shortercomparison, - does not require an
int[] orderscratch array.
var sortedCtors = new ConstructorInfo[constructors.Length];
var sortedParams = new ParameterInfo[constructors.Length][];
for (int i = 0; i < constructors.Length; i++)
{
ConstructorInfo c = constructors[i];
ParameterInfo[] p = c.GetParameters();
int j = i;
while (j > 0 && sortedParams[j - 1].Length < p.Length)
{
sortedCtors[j] = sortedCtors[j - 1];
sortedParams[j] = sortedParams[j - 1];
j--;
}
sortedCtors[j] = c;
sortedParams[j] = p;
}For the typical N ≤ 10 this is faster than Array.Sort (which uses
insertion sort internally below the introsort threshold of 16, but with
comparator delegate overhead).
4.2 Step 2: skip-smaller pruning
Because we walk in arity-DESC order, the first resolvable constructor we
encounter is the final best by arity. Every constructor of strictly lower
arity is either absorbed (subset rule) or ambiguous. Smaller resolvable
constructors are never selected, so we don't need to materialise their
ServiceCallSite[].
for (int i = 0; i < n; i++)
{
var p = sortedParams[i];
if (best is not null && p.Length < bestLen) continue; // skip resolve
...
}This combined with Step 1 means we resolve only the top arity tier, plus
any smaller resolvable ctors whose ambiguity we need to verify (see Step 3).
4.3 Step 3: parameter-aware ambiguity model
Replace HashSet<Type> bestConstructorParameterTypes with a richer
representation that captures both service identity (so keyed services
disambiguate correctly) and resolution origin (so default-vs-container
distinguishes correctly):
// Reusing the existing ServiceIdentifier (Key, ServiceType) tuple.
HashSet<ServiceIdentifier> bestFromContainer = ...; // T_container
HashSet<ServiceIdentifier> bestFromDefault = ...; // T_defaultPopulation happens when best is established (or replaced after Step 1 +
Step 2, which actually means "when best is first selected", because Step 1
guarantees no upgrades). For each parameter bp of best:
- if
CreateArgumentCallSitesresolved it from the container → add to
bestFromContainer; - if from
ConstantCallSitesynthesised byTryGetDefaultValue→ add to
bestFromDefault.
For each subsequent smaller ctor X, the existing CreateArgumentCallSites
call is replaced by a per-parameter probe:
bool hasNewResolvable = false;
foreach (var Pi in X.parameters)
{
var id = ServiceIdentifierFor(Pi); // honours keyed attributes
if (bestFromContainer.Contains(id)) continue; // resolvable via container
if (bestFromDefault.Contains(id))
{
if (Pi.HasDefaultValue) continue; // X resolves via its OWN default
return /* abandon X, not resolvable */;
}
// id ∉ T → probe Pi individually (single GetCallSite, no array alloc)
if (probeResolvable(Pi))
hasNewResolvable = true; // candidate ambiguity, keep walking
else
return /* abandon X, not resolvable */;
}
if (hasNewResolvable)
throw Ambiguous(best, X);
// else: X is fully a subset of best, absorbed silentlyThis preserves the old code's behaviour exactly in all the previously
non-anomalous cases, and additionally:
- correctly handles keyed services (no longer collapses different keys onto
the sameType); - avoids allocating a
ServiceCallSite[]for smaller-tier ctors that are not
going to be used.
4.4 Step 4: ambiguity throws immediately
Because Step 1 + Step 2 mean best is established once and never upgraded,
there is no need for the PR's deferred-ambiguity bookkeeping
(ambiguousConstructor, bestConstructorForAmbiguousConstructor,
resolvedConstructors). Ambiguity throws on the spot, matching the old
code.
5. Behaviour invariants
| Property | Old code | PR #129119 | This design |
|---|---|---|---|
| Greedy-longest selection | ✓ | ✓ | ✓ |
| Subset absorption | ✓ | ✓ | ✓ |
| Throws on same-arity disjoint resolvable | ✓ (indeterminate pair) | ✓ (order-dependent pair) | ✓ (deterministic pair) |
| Tie on same-arity identical type set | indeterminate winner | first in declaration order | first in declaration order |
| Cross-arity disjoint resolvable ("Example F") | always throws | order-dependent | always throws |
| Defaults vs container origin distinguished | no | no | yes |
| Keyed-services key distinguished | no | no | yes |
The two bold rows are intentional improvements over both predecessors. The
Example F row restores old-code behaviour (which the PR breaks).
6. Empirical evidence
Microbenchmark over four algorithm variants, simulating
CreateArgumentCallSites with a HashSet<Type> lookup per parameter plus
object[] allocation. Apple M4 Max, .NET 10.0.5, 10 iterations / 5 warmup.
Mean (ns) Ratio vs V1
─── TwoSameArity (PR motivating tie) ─────────────
V1 OldUnstableSort 142.2 1.00
V2 PR DeclarationOrder 98.4 0.69
V3 StableSort + Skip 99.5 0.70
V4 FusedInsertion (this) 94.1 0.66 ← win
─── LargestFirst (PR best case) ──────────────────
V1 OldUnstableSort 227.2 1.00
V2 PR DeclarationOrder 74.5 0.33
V3 StableSort + Skip 80.3 0.35
V4 FusedInsertion (this) 69.7 0.31 ← win
─── LargestLast (PR worst case) ──────────────────
V1 OldUnstableSort 233.2 1.00
V2 PR DeclarationOrder 208.8 0.90
V3 StableSort + Skip 81.6 0.35
V4 FusedInsertion (this) 74.7 0.32 ← win, 2.8× over PR
─── FiveScattered (realistic shape) ──────────────
V1 OldUnstableSort 473.1 1.00
V2 PR DeclarationOrder 255.9 0.54
V3 StableSort + Skip 113.2 0.24
V4 FusedInsertion (this) 113.1 0.24 ← tied; lowest alloc
─── LongestUnresolvable ──────────────────────────
V1 OldUnstableSort 286.3 1.00
V2 PR DeclarationOrder 211.9 0.74
V3 StableSort + Skip 111.9 0.39
V4 FusedInsertion (this) 107.6 0.38 ← win
Caveats:
- Real
CreateArgumentCallSitesperforms recursiveGetCallSitecalls;
costs are higher than the mock used, which widens the gap further
(more expensive resolve = more savings from skipping). - Allocations for V4 are consistently the lowest across all shapes
(e.g. LargestLast: V4 = 312 B vs V2 = 1000 B). OneCtor(constructors.Length == 1) is excluded; it is short-circuited
before any selection logic and so measures only method-shape JIT artefacts.
7. Reference implementation sketch
private ConstructorCallSite CreateConstructorCallSite(
ResultCache lifetime,
ServiceIdentifier serviceIdentifier,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
Type implementationType,
CallSiteChain callSiteChain)
{
try
{
callSiteChain.Add(serviceIdentifier, implementationType);
ConstructorInfo[] constructors = implementationType.GetConstructors();
if (constructors.Length == 0)
throw new InvalidOperationException(SR.Format(SR.NoConstructorMatch, implementationType));
if (constructors.Length == 1)
return BuildSingleCtorCallSite(lifetime, serviceIdentifier,
implementationType, callSiteChain, constructors[0]);
// Step 1: fused insertion-sort during single pass.
int n = constructors.Length;
var sortedCtors = new ConstructorInfo[n];
var sortedParams = new ParameterInfo[n][];
for (int i = 0; i < n; i++)
{
ConstructorInfo c = constructors[i];
ParameterInfo[] p = c.GetParameters();
int j = i;
while (j > 0 && sortedParams[j - 1].Length < p.Length)
{
sortedCtors[j] = sortedCtors[j - 1];
sortedParams[j] = sortedParams[j - 1];
j--;
}
sortedCtors[j] = c;
sortedParams[j] = p;
}
// Step 2 + 3 + 4: walk arity-DESC, skip smaller, parameter-aware ambiguity.
ConstructorInfo? best = null;
ServiceCallSite[]? bestSites = null;
int bestLen = -1;
HashSet<ServiceIdentifier>? bestFromContainer = null;
HashSet<ServiceIdentifier>? bestFromDefault = null;
for (int i = 0; i < n; i++)
{
ParameterInfo[] p = sortedParams[i];
if (best is not null && p.Length < bestLen) continue;
if (best is null)
{
// Establish best. Full CreateArgumentCallSites is needed here
// because we will keep these call sites.
var sites = CreateArgumentCallSites(
serviceIdentifier, implementationType, callSiteChain, p,
throwIfCallSiteNotFound: false);
if (sites is null) continue;
best = sortedCtors[i];
bestSites = sites;
bestLen = p.Length;
ClassifyBestParameters(p, sites, out bestFromContainer, out bestFromDefault);
}
else
{
// Same-arity candidate or smaller (smaller is filtered above).
if (!ProbeSmallerOrSameArity(p, bestFromContainer!, bestFromDefault!,
callSiteChain, serviceIdentifier, out bool hasNewResolvable))
continue; // X not resolvable, ignored
if (hasNewResolvable)
throw Ambiguous(implementationType, best, sortedCtors[i]);
}
}
if (best is null)
throw new InvalidOperationException(SR.Format(SR.UnableToActivateTypeException, implementationType));
return new ConstructorCallSite(lifetime, serviceIdentifier.ServiceType,
best, bestSites!, serviceIdentifier.ServiceKey);
}
finally
{
callSiteChain.Remove(serviceIdentifier);
}
}ClassifyBestParameters walks bestParams paired with the just-built
sites, classifying each as container-origin (call site comes from
GetCallSite) or default-origin (call site is the ConstantCallSite
synthesised by TryGetDefaultValue).
ProbeSmallerOrSameArity implements the per-parameter probe from §4.3,
returning false if the candidate is not resolvable and true otherwise
(with hasNewResolvable indicating whether to throw ambiguous).
Ambiguous builds the existing
SR.AmbiguousConstructorException message with the deterministic
(best, candidate) pair.
8. Risks and open questions
-
Keyed-services behaviour change. Today's
HashSet<Type>treats
[FromKeyedServices("a")] IFooand[FromKeyedServices("b")] IFooas the
same parameter for ambiguity purposes. The proposed design treats them as
distinct. This is a behaviour change. It is probably correct, but it
may break existing tests that lock in the current behaviour. Decision
needed: fix-the-bug or preserve-the-bug. -
Example F restoration. The proposal restores old-code behaviour
("always throw on cross-arity disjoint resolvable"). The PR's
order-dependent behaviour will be lost. This is intentional, but worth
stating explicitly in the PR description. -
ProbeSmallerOrSameArityhelper. The probe needs access to
GetCallSite(ServiceIdentifierFor(Pi), callSiteChain)without allocating
aServiceCallSite[]. Either a new helper method, or abool TryProbe
overload ofCreateArgumentCallSites. The shape of this helper is a
small public-API decision (internal API, but still worth one round of
review). -
OneCtorshort-circuit retention. Keep the existing
constructors.Length == 1early return. It is not a property of any of
the algorithm variants; it is a separate optimisation that all of them
inherit. -
Testing. Existing PR-129119 tests should pass unchanged. Additionally,
add tests for:- Cross-arity disjoint resolvable in both source orders → both throw
ambiguous (Example F). - Keyed-services parameter identity (two ctors with same
Typebut
different[FromKeyedServices]key) → behaves as if types were
distinct. - Default-origin parameter in
bestwhoseTypereappears as a
non-defaulted parameter in a smaller ctor → smaller ctor is
non-resolvable, silently ignored (no ambiguous throw).
- Cross-arity disjoint resolvable in both source orders → both throw
9. Summary
| Aspect | Current | PR #129119 | This proposal |
|---|---|---|---|
| Sort | unstable Array.Sort |
none (declaration walk) | fused insertion sort |
GetParameters() calls |
~2·N·log N + N + 1 |
N |
N |
| Resolves called | up to N |
1 to N (often more than this proposal) |
top arity tier only |
Per-smaller-ctor ServiceCallSite[] alloc |
yes | yes | no (probe-only) |
| Ambiguity model | type-set | type-set | identity + origin |
| Keyed-services correct | no | no | yes |
| Default-vs-container distinguished | no | no | yes |
| Example F behaviour | throws | order-dependent | throws |
| Allocations (LargestLast) | 1000 B | 1000 B | 312 B |
| Time vs old (worst case) | 1.00 | 0.90 | 0.32 |
…er-aware ambiguity Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com>
... Implemented this redesign in commit
I also ran secret scanning (clean) and CodeQL (no alerts produced; analysis was skipped due database size in this environment). |
CallSiteFactory deterministic and allocation-lighterCallSiteFactory constructor selection with stable arity ordering and parameter-aware ambiguity
CallSiteFactory.CreateConstructorCallSiteno longer relies on unstableArray.Sortbehavior for multi-constructor selection. This update reworks selection to be deterministic and allocation-lighter, and it strengthens ambiguity analysis for keyed/default-resolution scenarios.What changed
GetParameters()once per constructor.bestand retains itsServiceCallSite[]for activation.ServiceIdentifierplus resolution origin:ServiceCallSite[]when not needed.constructors.Length == 0andconstructors.Length == 1short-circuit behavior and existing exception message shapes.Behavioral delta (intended and scoped)
ServiceIdentifierkey + type), instead of collapsing toTypeonly.Tests added
InvalidOperationException.Typewith different[FromKeyedServices]keys → verifies keyed identity is treated distinctly.eng/targetingpacks.targets).