Skip to content

Commit 3fdf1af

Browse files
authored
Notes: provider specialization as a performance direction (#204)
* Notes: provider specialization as a performance direction Emit against the concrete provider the consumer already references, rather than the provider-agnostic ADO.NET surface. The note is the measured case, the specific techniques, how detection would work, and a sequencing with exit criteria. The opportunity, measured as allocation per operation against PostgreSQL: from where Dapper.AOT is today to hand-tuned ADO.NET is a 46% cut on a single-row lookup and 20% over a hundred rows, all of it reachable from generated code within the existing contract. Going further and bypassing ADO.NET entirely is worth a further 9%, so the ceiling is close and most of what is available is in front of us rather than behind an API redesign. Seven techniques are spelled out, since they are the actual specification: one command created once and reused, prepared once, a generic provider-specific parameter, no DbType inference per assignment, typed getters on the concrete reader, SequentialAccess plus arity from the call site, and IsDBNull before a typed get. Five are provider-agnostic and can land first. The generic-parameter question is verified rather than assumed, by scanning the shipped assemblies across every version in the local caches, and the answer narrows the plan usefully: only Npgsql has one. Microsoft.Data.SqlClient, MySqlConnector and Microsoft.Data.Sqlite all route value types through DbParameter.Value, which is object, so the box is unavoidable from outside those drivers. That makes the largest single item PostgreSQL-only today, makes it a better-bounded piece of work than it looked, and turns "add a generic parameter type" into a feature request those driver teams can be shown a number for. Two things are flagged rather than concluded. Dapper.AOT currently shows no single-row allocation win over vanilla, which wants an owner's eye before any of this is sized, since it may be a bug rather than a missing feature. And the timing column says nothing on this setup: a round trip is ~475us and a hundred extra rows cost 30us, so client cost sits inside a shadow a hundred times its size. Allocation is the honest axis; saturation is the other one and is unmeasured. Non-goals are stated: the connection model is out of reach from generated code sitting on ADO.NET, specialization must never change observable behaviour, and the agnostic path must not regress. * Notes: correct the detection rule, and add an emission-strategy section The detection rule was too conservative. "Specialize only when the static type proves it" declines the common case, since a great deal of real code takes DbConnection or IDbConnection from DI. Where the static type does not prove it but the provider is referenced, emit a runtime type test with the agnostic path as the else -- the test is one type check against a database round trip, and monomorphic per call site, so it predicts perfectly. Three details recorded because they decide whether that is sound: test the command rather than the connection, since NpgsqlParameter<T> needs an NpgsqlCommand and CreateCommand is typed DbCommand whatever the connection is; wrapped connections such as MiniProfiler's fail the test and take the agnostic arm, which is a feature and worth saying out loud before someone asks; and the number of specialized arms wants a cap rather than an open-ended cross product. Adds a section on how the emission should be structured, which the note was missing -- it said what to emit but not how. The case for full explicit emission is not mainly that the code is more obvious: it removes a type-erasure tax the current design imposes, since an anonymous type cannot be a type argument on a runtime type and the factory degrades to CommandFactory<object?> with a cast back; and it turns provider specialization from a plumbing problem into a line of code. The payoff is already estimated rather than aspirational: the hand-tuned ADO.NET row is essentially what full explicit emission looks like, so 1,927B to 1,035B is the estimate for this proposal specifically. Costs are stated too. Code size is the serious one, mitigated by emitting one concrete method per shape and provider rather than inlining at every call site. Behaviour migrating from library to generator is the actual work, and is why this sits downstream of parity rather than parallel to it. And generated code freezes at generation time where a library fix ships by package bump. One claim is marked as needing testing rather than asserted: whether a generic TArgs interceptor with an inline body lets the anonymous argument object stop escaping and become a stack-allocation candidate. What is certain is that the current shape forecloses it. * Notes: test the generic-args idea, and close it Sketched and compiled rather than reasoned about, because the tempting next step after removing the CommandFactory<object?> erasure is to stop erasing the argument object too -- give it a TArgs of its own and hope it stops escaping. Three measurements say no. The dominant read shape cannot reach such an overload at all. Query<Customer> supplies one type argument, so a two-parameter Query<TResult, TArgs> is not even a candidate: explicit type arguments must supply every type parameter, and C# has no partial inference. Nor can the call be written explicitly, since an anonymous type has no name to give. Unreachable by construction rather than by oversight. Two side findings recorded in case a generic-args overload is ever wanted for other reasons: it binds without OverloadResolutionPriority, because an identity conversion already beats conversion-to-object; and DynamicParameters-shaped arguments would start binding to it, which Dapper handles specially today and would need a carve-out. The object is small: 24B for a one-field anonymous type, 32B for two fields. Against the ~890B separating Dapper.AOT today from hand-tuned ADO.NET that is under 3% of the gap. And the stack allocation does not happen anyway. Probed with an anonymous object created, read once, and never crossing a call boundary, against the same object passed as object: 24.0B per iteration either way. The hoped-for saving is not there to collect. So the ~890B is elsewhere -- command, parameter collection and parameter objects per execution, the reader, the state machines, and the boxing of parameter values. Items 1, 2 and 3 of the technique list, not the argument object. The step-0 anomaly points the same way, since no single-row win over vanilla is what one would expect if the command is not being reused. * Notes: pin down where the overload finding applies The claim that a generic-args overload binds without OverloadResolutionPriority was too general: it was tested on Execute only. The line is whether the call site states a type argument, and the two sides fail differently. With an explicit type argument, Query<Customer> supplies one and a two-parameter overload is not a candidate at all, so priority is moot rather than unnecessary -- there is nothing to prioritise. Without one, the generic overload binds on ordinary rules because an identity conversion beats conversion-to-object, now verified for Execute, dynamic-returning Query and ExecuteScalar rather than inferred from one case. Which bounds the idea precisely: a generic-args overload is available for the non-generic-result methods, most of the write path plus dynamic reads, and for nothing else. null and object-typed locals keep today's overload either way. * Notes: promote the end-to-end emission major, with a sketch It was described in one sentence as "an alternative for a major", and the single-method-body shape appeared only as a code-size mitigation under costs -- backwards, since that shape is the design rather than a workaround. Now its own section, with the emitted shape sketched. Working the sketch through turned up a constraint worth recording: a helper method cannot take the anonymous argument type, because that type cannot be written as a parameter type. The resolution is that the shared body takes the extracted values rather than the argument object, which is forced rather than chosen -- and it is the good outcome, because sql then becomes a parameter and fifty call sites of the same shape collapse to one emitted method instead of fifty copies of a reader loop. The obvious objection to end-to-end emission largely evaporates, which makes the major more attractive than the note previously made it sound. Also notes what splits a shape -- operation, row type, parameter names and types -- with the name question flagged as wanting a decision against real codebases. Sequencing gains a step 3b: the major is not required by steps 1-3, which can land on the current factory shape; it is what makes them cheap to write and turns the detection switch into an if. So the honest question is whether to do steps 1-3 twice or once. The args-object dead end moves under a "closed ideas" heading, since it was longer than the proposal it was a digression from. * Notes: stop asserting the factored form wins The note claimed fifty call sites of a shape would collapse to one emitted method, which was illustration presented as fact, and used it to conclude the code-size objection to end-to-end emission "largely evaporates". Neither is established. Putting the body inline under the Cast, and using typed.id from there, runs the same code and specializes identically. The differences are narrower than the note implied: emitted source size, compile time and golden-fixture bulk; one async state machine type per call site rather than per shape, which is the most concrete of them and is an AOT metadata argument rather than a throughput one; and debuggability. Per-operation cost is unchanged either way. The inline form is also simpler -- no shape key, no decision about whether parameter names split a shape, no helper signature to design -- so it wins by default unless shape repetition is high enough to pay for the machinery. That number is unmeasured, and the note now says to measure it across real repositories before choosing rather than assuming. What survives as forced rather than chosen: if the body is factored out at all, it must take extracted values, since an anonymous type cannot be written as a parameter type. * Notes: record the binder-passing variant, measured Passing the arguments as object plus a binder that destructures them, rather than the shared body taking extracted values, erases the argument shape out of the signature -- so the shape key collapses from (operation, row type, parameter names and types) to just (operation, row type). That fixes the sharing problem both earlier forms had, and it is the strongest argument for the factored shape. Function pointers are the obvious primitive and the measurement does not support them. Neither indirection allocates, since a static readonly delegate is built once at type init. The function pointer measured slower than the delegate, 7.09 against 4.04 ns, most likely because the JIT can speculatively inline through a delegate with a stable target and cannot do so for a pointer arriving as a parameter. And all of it is noise at the scale that matters: 2-5 ns against a ~475us round trip is about 0.001%. So the delegate form is the better default rather than the fallback -- it needs no AllowUnsafeBlocks, no detection of it, and no second emission path. Recorded that function pointers would impose exactly that: a consumer without the switch gets CS0214 from generated code, and it is compilation-wide rather than something a generated file can opt into. One simplification found while checking: the binder needs no per-provider signature. Function pointer parameters are contravariant so narrowing is rejected outright, and a provider-specific pointer type would force a separate shared body per provider -- unnecessary, since the binder can take DbCommand and cast internally, the shared body having already proven the type in its type-test branch. The cost of the variant is that the binder is an opaque call mid-body, so the JIT cannot optimise across it as it can for a fully inline body. Irrelevant against a round trip, recorded because it is the one real difference. * Notes: measure the emitted shape, and answer step 0 The shape was written out by hand -- interceptor-constrained entry, binder in a static readonly delegate, shared body with the provider type test -- and measured against the same PostgreSQL workload. It reaches the target: 1,224B against Dapper.AOT's 1,971B, a 37% cut, landing within about 10% of hand-tuned. So the three-layer shape costs nothing meaningful over hand-written code. The important finding is that command reuse is roughly 70% of the entire gap. Identical code with only the command policy differing gives 1,848B fresh against 1,224B reused, about 625B of the ~890B. Typed parameters, typed getters, SequentialAccess and the provider switch share the remaining ~265B. And fresh-command lands on Dapper.AOT's number, which is evidence that Dapper.AOT is not reusing commands in this shape. That answers step 0 by measurement rather than profiling, and reframes the work: item 1 is not one technique among seven, it is most of the prize. Positional parameters were tested and do not pay in this case: 1,283B positional against 1,224B named, no benefit and marginally worse. Npgsql appears to resolve the name mapping once at Prepare and cache it, leaving nothing to save per execution. The test was the prepared-and-reused case, which is the best case for that caching, so the idea could still pay on non-prepared or fresh-command paths -- which is where much real code apparently sits. Also records a constraint found the hard way: parameters must be declared before Prepare, since the server records the parameter list at parse time. A binder that adds lazily on first execution cannot also prepare. That is exactly why AddParameters and UpdateParameters are separate concerns, and collapsing them breaks preparation. * Notes: measure CacheCommand and StrictTypes, and split reuse from preparation The earlier claim that command reuse is ~70% of the gap was too coarse, and it compared against Dapper.AOT's default rather than its best available. Both are now corrected by measurement. Command handling splits into two comparable, independent wins: reusing the command object is worth ~382B, and preparing it is worth a further ~242B. One item in the technique list was hiding two. CacheCommand and StrictTypes exist already, so the plain DapperAot row is the default rather than the ceiling. CacheCommand works and captures about half of what object reuse is worth -- 190B of the ~380B -- and why it does not reach the rest is worth knowing before building anything. StrictTypes showed no reliable gain on top, inside jitter and worse in that run. Nothing reaches preparation: CanPrepare is emitted as true, but the ~242B it is worth does not appear in any configuration measured, which makes it the largest unclaimed item and one that needs no new API. Also states plainly that allocation is not perf and that perf is not yet measured. Wall-clock says nothing when the round trip dominates by a hundredfold; client CPU per operation was attempted and is only good to 5-10% because process CPU time is quantised to the scheduler tick. The instrument that would answer it is throughput under saturation, with client and server pinned to different cores. And records two decisions on the positional rewrite: it should land after or as part of the emission work rather than being retrofitted onto the current library, since the rewrite belongs where the SQL is emitted; and it applies only where the SQL is a compile-time constant literal, because a generator cannot rewrite a string it cannot see. * Notes: retract "the timings say nothing", and put preparation first The claim that wall-clock says nothing because a ~475us round trip dominates was wrong, and it came from a badly configured instrument rather than from the physics: every timing run behind it used BenchmarkDotNet's short job, three warmup and three iterations. That is fine for allocation, which is counted rather than timed, and useless for timing. With a normal job the error bars fall to +/-5-8us and the differences resolve cleanly. The constant round trip is what helps -- it is a fixed offset, so the difference between two stacks is the work they do. Measured properly, Prepare() is nearly the entire timing story: same code, only preparation differing, 512.2us against 479.6us. That 32.6us is about 90% of the 36us spread across every stack measured. The corollary reorders the technique list. Command object reuse buys no time at all, only bytes -- 506.9us fresh against 512.2us reused is nothing. CacheCommand likewise. Positional parameters buy no time either, agreeing with the allocation result. So the two axes rank the work differently and preparation is first on both, while being the one thing nothing currently reaches: CanPrepare is emitted as true and nothing acts on the flag. Also records why preparation is worth more than it looks: it removes per-execution parse and plan work on the server, so it is not client CPU, buys server capacity as well as latency, and does not shrink as the network gets slower. * Notes: bring the plan into line with the measurements The measured sections were added but the conclusions around them were not updated, which is exactly the drift worth catching. Step 0 still read as open when it had been answered: the anomaly is that the default configuration neither re-uses nor prepares commands. CacheCommand recovers about half of what re-use is worth and nothing reaches preparation, so the baseline for comparison is [DapperAot, CacheCommand] rather than the bare default. The opening table and its caveat now say so too. Step 1 lumped command re-use and preparation together as one agnostic item. They separate, and they rank differently: preparation is ~90% of the timing spread and ~242B and is unclaimed, while re-use is allocation-only. Preparation is now its own step, first, with the declare-before-Prepare constraint attached, and the rest of the agnostic techniques follow as 1b with the explicit expectation that they move allocation and not timing. How to measure now says to measure both axes always -- since they rank the work differently and inferring one from the other is how this plan got mis-ordered once already -- and to never use BenchmarkDotNet's short job for timing, which is what produced the retracted claim. * Add the probes behind the note, and say what is not reproducible The note quoted numbers from experiments that lived only in a scratch directory on one machine. They now exist as test/SpecializationProbes, which prints the tables in the note: which overload an ordinary Dapper call site binds to, what an argument object costs, whether a non-escaping one gets stack-allocated, and what the two indirections cost. No dependencies, no database, and deliberately absent from Build.csproj to match test/Usage*, so CI is unaffected. Also states plainly that the database measurements are a different matter. The four-stack table, the emitted-shape validation, the CacheCommand comparison and the timings all come from a harness in a private repository, run against one machine -- so they are evidence for planning and are not independently verifiable by a reader of this note. Better to say so than to let the two kinds of claim sit side by side looking equally checkable.
1 parent c52b67e commit 3fdf1af

7 files changed

Lines changed: 922 additions & 0 deletions

File tree

‎notes/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ build time. See [type-vs-generic.md](type-vs-generic.md).
3030
| [test-suite-audit.md](test-suite-audit.md) | the Dapper test files as acceptance corpus, and what blocks each |
3131
| [harness-baseline.md](harness-baseline.md) | real numbers from the suite with AOT enabled (Dapper repo, `aot-harness` branch) |
3232
| [dynamicparameters-design.md](dynamicparameters-design.md) | phase 3 item 1: delegate to the bag's own vanilla protocol; needs a small Dapper-side API |
33+
| [provider-specialization.md](provider-specialization.md) | a performance direction: emit against the concrete provider the consumer already references, rather than the agnostic ADO.NET surface |
3334
| [generator-audit.md](generator-audit.md) | **fix-first gate**: the capture model snapshots Roslyn symbols/nodes — retention + cache defeat |
3435

3536
## Scope: the public API, by observable behavior

‎notes/provider-specialization.md‎

Lines changed: 586 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using System;
2+
using System.Runtime.CompilerServices;
3+
4+
// Does the anonymous args object actually stop allocating when it stays generic and does not
5+
// escape? That is the hypothesis behind wanting a TArgs overload at all, so it is worth testing
6+
// rather than assuming.
7+
public static class Escape
8+
{
9+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
10+
private static int Generic<TArgs>(TArgs args, Func<TArgs, int> read) => read(args);
11+
12+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
13+
private static int Erased(object args) => ((dynamic) args).id;
14+
15+
public static void Run()
16+
{
17+
Console.WriteLine();
18+
Console.WriteLine("Does a non-escaping generic args object get stack-allocated?");
19+
20+
// generic, inlineable, field read only -- the shape a TArgs interceptor would have
21+
Sum(static () => { var a = new { id = 42 }; return a.id; }, "inlined, never crosses a boundary");
22+
23+
// the same object handed to something typed as object, as today's interceptor does
24+
Sum(static () => { var a = new { id = 42 }; return Keep(a); }, "passed as object (today's shape)");
25+
}
26+
27+
[MethodImpl(MethodImplOptions.NoInlining)]
28+
private static int Keep(object o) => o.GetHashCode() & 0;
29+
30+
private static void Sum(Func<int> body, string what)
31+
{
32+
for (var i = 0; i < 200; i++) _ = body(); // warm up and tier up
33+
var before = GC.GetAllocatedBytesForCurrentThread();
34+
var total = 0;
35+
for (var i = 0; i < 10_000; i++) total += body();
36+
var after = GC.GetAllocatedBytesForCurrentThread();
37+
Console.WriteLine($" {what,-38} {(after - before) / 10_000.0,5:0.0} B per iteration (sum {total})");
38+
}
39+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
using System;
2+
using System.Data.Common;
3+
using System.Diagnostics;
4+
5+
public class FakeDb : DbConnection
6+
{
7+
public override string ConnectionString { get; set; } = "";
8+
public override string Database => "";
9+
public override string DataSource => "";
10+
public override string ServerVersion => "";
11+
public override System.Data.ConnectionState State => System.Data.ConnectionState.Open;
12+
public override void ChangeDatabase(string databaseName) { }
13+
public override void Close() { }
14+
public override void Open() { }
15+
protected override DbTransaction BeginDbTransaction(System.Data.IsolationLevel il) => null!;
16+
protected override DbCommand CreateDbCommand() => null!;
17+
}
18+
19+
/// <summary>
20+
/// Does either indirection allocate per call, and what does the delegate fallback cost?
21+
/// </summary>
22+
/// <remarks>
23+
/// Behind the "passing the binder in" section of notes/provider-specialization.md. Two results
24+
/// worth knowing before choosing: neither indirection allocates, and the function pointer measured
25+
/// <em>slower</em> than the delegate -- most likely because the JIT can speculatively inline through
26+
/// a delegate with a stable target and cannot do so for a pointer arriving as a parameter.
27+
/// <para>Note also that the function pointer needs <c>AllowUnsafeBlocks</c> in the <em>consumer's</em>
28+
/// project: without it, generated code using one fails with CS0214, and that is a compilation-wide
29+
/// switch a generated file cannot opt into on its own.</para>
30+
/// </remarks>
31+
public static unsafe class IndirectionCost
32+
{
33+
private static int s_sink;
34+
35+
private static T Cast<T>(object obj, Func<T> shape) => (T) obj;
36+
37+
// the per-shape binder a generator would emit
38+
private static void AddArgs(DbConnection cnn, object args)
39+
{
40+
var typed = Cast(args, static () => new { id = default(int) });
41+
s_sink += typed.id;
42+
}
43+
44+
// created once, at type init; no unsafe needed
45+
private static readonly Action<DbConnection, object> s_addArgs = AddArgs;
46+
47+
private static void SharedViaPointer(DbConnection cnn, object args, delegate*<DbConnection, object, void> bind)
48+
=> bind(cnn, args);
49+
50+
private static void SharedViaDelegate(DbConnection cnn, object args, Action<DbConnection, object> bind)
51+
=> bind(cnn, args);
52+
53+
private static void SharedInline(DbConnection cnn, object args)
54+
{
55+
var typed = Cast(args, static () => new { id = default(int) });
56+
s_sink += typed.id;
57+
}
58+
59+
public static void Run()
60+
{
61+
var cnn = new FakeDb();
62+
const int Warm = 200_000, Iter = 20_000_000;
63+
64+
// warm up all three
65+
for (var i = 0; i < Warm; i++) { SharedInline(cnn, new { id = 1 }); SharedViaPointer(cnn, new { id = 1 }, &AddArgs); SharedViaDelegate(cnn, new { id = 1 }, s_addArgs); }
66+
67+
Console.WriteLine("Allocation per call (the 24 B args object is the caller's, in all three):");
68+
long b0 = GC.GetAllocatedBytesForCurrentThread();
69+
for (var i = 0; i < 100_000; i++) SharedInline(cnn, new { id = 1 });
70+
long b1 = GC.GetAllocatedBytesForCurrentThread();
71+
for (var i = 0; i < 100_000; i++) SharedViaPointer(cnn, new { id = 1 }, &AddArgs);
72+
long b2 = GC.GetAllocatedBytesForCurrentThread();
73+
for (var i = 0; i < 100_000; i++) SharedViaDelegate(cnn, new { id = 1 }, s_addArgs);
74+
long b3 = GC.GetAllocatedBytesForCurrentThread();
75+
Console.WriteLine($" inline body {(b1 - b0) / 100_000.0,5:0.0} B");
76+
Console.WriteLine($" via delegate* parameter {(b2 - b1) / 100_000.0,5:0.0} B");
77+
Console.WriteLine($" via static readonly delegate {(b3 - b2) / 100_000.0,5:0.0} B");
78+
79+
Console.WriteLine();
80+
Console.WriteLine("Cost per call, same work, different indirection:");
81+
var sw = Stopwatch.StartNew();
82+
for (var i = 0; i < Iter; i++) SharedInline(cnn, new { id = 1 });
83+
sw.Stop(); var t0 = sw.Elapsed.TotalNanoseconds / Iter;
84+
sw.Restart();
85+
for (var i = 0; i < Iter; i++) SharedViaPointer(cnn, new { id = 1 }, &AddArgs);
86+
sw.Stop(); var t1 = sw.Elapsed.TotalNanoseconds / Iter;
87+
sw.Restart();
88+
for (var i = 0; i < Iter; i++) SharedViaDelegate(cnn, new { id = 1 }, s_addArgs);
89+
sw.Stop(); var t2 = sw.Elapsed.TotalNanoseconds / Iter;
90+
Console.WriteLine($" inline body {t0,5:0.00} ns");
91+
Console.WriteLine($" via delegate* parameter {t1,5:0.00} ns (+{t1 - t0:0.00})");
92+
Console.WriteLine($" via static readonly delegate {t2,5:0.00} ns (+{t2 - t0:0.00})");
93+
Console.WriteLine($" (sink {s_sink})");
94+
}
95+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Data;
4+
using System.Runtime.CompilerServices;
5+
6+
// Which overload does an ordinary Dapper call site bind to, if a generic-args overload is added
7+
// alongside the existing object?-based one? Nothing here talks to a database; the only question is
8+
// what the compiler does with real-looking call sites.
9+
10+
public sealed class Customer { public int Id { get; set; } }
11+
12+
public sealed class CustomerArgs { public int Id { get; set; } }
13+
14+
public sealed class DynamicParametersLike { }
15+
16+
public sealed class FakeConnection : IDbConnection
17+
{
18+
public string ConnectionString { get; set; } = "";
19+
public int ConnectionTimeout => 0;
20+
public string Database => "";
21+
public ConnectionState State => ConnectionState.Open;
22+
public IDbTransaction BeginTransaction() => throw new NotSupportedException();
23+
public IDbTransaction BeginTransaction(IsolationLevel il) => throw new NotSupportedException();
24+
public void ChangeDatabase(string databaseName) { }
25+
public void Close() { }
26+
public IDbCommand CreateCommand() => throw new NotSupportedException();
27+
public void Open() { }
28+
public void Dispose() { }
29+
}
30+
31+
public static class Existing
32+
{
33+
public static IEnumerable<T> Query<T>(this IDbConnection cnn, string sql, object? param = null)
34+
{
35+
Log.Bound("Query<T>(string, object?) [today]");
36+
return [];
37+
}
38+
39+
public static int Execute(this IDbConnection cnn, string sql, object? param = null)
40+
{
41+
Log.Bound("Execute(string, object?) [today]");
42+
return 0;
43+
}
44+
45+
// Dapper's dynamic-returning Query: no explicit type argument at the call site.
46+
public static IEnumerable<object> Query(this IDbConnection cnn, string sql, object? param = null)
47+
{
48+
Log.Bound("Query(string, object?) -> dynamic [today]");
49+
return [];
50+
}
51+
52+
public static object? ExecuteScalar(this IDbConnection cnn, string sql, object? param = null)
53+
{
54+
Log.Bound("ExecuteScalar(string, object?) [today]");
55+
return null;
56+
}
57+
}
58+
59+
public static class Candidates
60+
{
61+
// A: two type parameters, TResult only in the return position.
62+
public static IEnumerable<TResult> Query<TResult, TArgs>(this IDbConnection cnn, string sql, TArgs param)
63+
{
64+
Log.Bound("Query<TResult, TArgs> [candidate A]");
65+
return [];
66+
}
67+
68+
// B: one type parameter, inferable from the argument. No OverloadResolutionPriority, to find out
69+
// whether ordinary overload resolution already prefers it.
70+
public static int Execute<TArgs>(this IDbConnection cnn, string sql, TArgs param)
71+
{
72+
Log.Bound("Execute<TArgs>(string, TArgs) [candidate B]");
73+
return 0;
74+
}
75+
76+
// C: the same trick on the dynamic-returning Query, which also has no explicit type argument.
77+
public static IEnumerable<object> Query<TArgs>(this IDbConnection cnn, string sql, TArgs param)
78+
{
79+
Log.Bound("Query<TArgs>(string, TArgs) [candidate C]");
80+
return [];
81+
}
82+
83+
public static object? ExecuteScalar<TArgs>(this IDbConnection cnn, string sql, TArgs param)
84+
{
85+
Log.Bound("ExecuteScalar<TArgs>(string,TArgs) [candidate D]");
86+
return null;
87+
}
88+
}
89+
90+
public static class Log
91+
{
92+
public static void Bound(string what) => Console.WriteLine($" -> {what}");
93+
}
94+
95+
/// <summary>
96+
/// Which overload does an ordinary Dapper call site bind to, if a generic-args overload is added
97+
/// alongside the existing object?-based one -- and what does the argument object actually cost?
98+
/// </summary>
99+
/// <remarks>
100+
/// Behind the "the args object is not the prize" section of notes/provider-specialization.md. The
101+
/// headline is that an explicit type argument (Query&lt;Customer&gt;) excludes a two-parameter
102+
/// overload from candidacy outright, because C# has no partial inference and an anonymous type
103+
/// cannot be named -- so the dominant Dapper read shape cannot reach one by construction.
104+
/// </remarks>
105+
internal static class OverloadBinding
106+
{
107+
public static void Run()
108+
{
109+
var cnn = new FakeConnection();
110+
object? nullArgs = null;
111+
object boxedArgs = new CustomerArgs { Id = 1 };
112+
var bag = new DynamicParametersLike();
113+
114+
Say("1. Query<Customer>(sql, new { id }) -- the dominant Dapper read shape");
115+
_ = cnn.Query<Customer>("select ...", new { id = 1 });
116+
117+
Say("2. Execute(sql, new { id }) -- no explicit type argument anywhere");
118+
_ = cnn.Execute("update ...", new { id = 1 });
119+
120+
Say("3. Execute(sql) -- no args");
121+
_ = cnn.Execute("update ...");
122+
123+
Say("4. Execute(sql, null) -- null cannot infer TArgs");
124+
_ = cnn.Execute("update ...", null);
125+
126+
Say("5. Execute(sql, objectTypedLocal) -- static type is object");
127+
_ = cnn.Execute("update ...", boxedArgs);
128+
129+
Say("6. Execute(sql, typedArgsClass) -- an ordinary named class");
130+
_ = cnn.Execute("update ...", new CustomerArgs { Id = 1 });
131+
132+
Say("7. Execute(sql, dynamicParametersLike) -- the bag shape Dapper handles specially");
133+
_ = cnn.Execute("update ...", bag);
134+
135+
Say("8. Execute(sql, nullObjectLocal) -- null in an object?-typed local");
136+
_ = cnn.Execute("update ...", nullArgs);
137+
138+
Say("9. Query(sql, new { id }) -- dynamic result, no explicit type argument");
139+
_ = cnn.Query("select ...", new { id = 1 });
140+
141+
Say("10. ExecuteScalar(sql, new { id }) -- same shape again");
142+
_ = cnn.ExecuteScalar("select ...", new { id = 1 });
143+
144+
Console.WriteLine();
145+
Console.WriteLine("What does the args object actually cost?");
146+
Measure("new { id = 42 }", static () => new { id = 42 });
147+
Measure("new { id = 42, name = \"x\" }", static () => new { id = 42, name = "x" });
148+
Measure("new CustomerArgs()", static () => new CustomerArgs { Id = 42 });
149+
150+
}
151+
152+
private static void Say(string what)
153+
{
154+
Console.WriteLine();
155+
Console.WriteLine(what);
156+
}
157+
158+
private static void Measure<T>(string what, Func<T> make)
159+
{
160+
_ = make(); // JIT and warm up
161+
var before = GC.GetAllocatedBytesForCurrentThread();
162+
for (var i = 0; i < 1000; i++)
163+
_ = make();
164+
var after = GC.GetAllocatedBytesForCurrentThread();
165+
Console.WriteLine($" {what,-28} {(after - before) / 1000.0,6:0.0} B per instance");
166+
}
167+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace SpecializationProbes;
2+
3+
internal static class Program
4+
{
5+
private static void Main()
6+
{
7+
OverloadBinding.Run();
8+
Escape.Run();
9+
IndirectionCost.Run();
10+
}
11+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<!--
4+
The probes behind notes/provider-specialization.md. Each one answers a language or runtime
5+
question that the note would otherwise be asserting from memory, and each prints the table that
6+
appears there, so the claims can be re-checked rather than believed.
7+
8+
Nothing here touches a database or references Dapper: these are compiler and JIT questions.
9+
Deliberately not listed in Build.csproj, matching test/Usage*, so it costs CI nothing.
10+
11+
Run it with `dotnet run` from this directory, in Release.
12+
-->
13+
14+
<PropertyGroup>
15+
<OutputType>Exe</OutputType>
16+
<TargetFramework>net10.0</TargetFramework>
17+
<Nullable>enable</Nullable>
18+
<LangVersion>latest</LangVersion>
19+
<!-- required by the function-pointer probe; see IndirectionCost.cs for why that matters -->
20+
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
21+
</PropertyGroup>
22+
23+
</Project>

0 commit comments

Comments
 (0)