Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions notes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ build time. See [type-vs-generic.md](type-vs-generic.md).
| [test-suite-audit.md](test-suite-audit.md) | the Dapper test files as acceptance corpus, and what blocks each |
| [harness-baseline.md](harness-baseline.md) | real numbers from the suite with AOT enabled (Dapper repo, `aot-harness` branch) |
| [dynamicparameters-design.md](dynamicparameters-design.md) | phase 3 item 1: delegate to the bag's own vanilla protocol; needs a small Dapper-side API |
| [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 |
| [generator-audit.md](generator-audit.md) | **fix-first gate**: the capture model snapshots Roslyn symbols/nodes — retention + cache defeat |

## Scope: the public API, by observable behavior
Expand Down
586 changes: 586 additions & 0 deletions notes/provider-specialization.md

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions test/SpecializationProbes/Escape.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System;
using System.Runtime.CompilerServices;

// Does the anonymous args object actually stop allocating when it stays generic and does not
// escape? That is the hypothesis behind wanting a TArgs overload at all, so it is worth testing
// rather than assuming.
public static class Escape
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int Generic<TArgs>(TArgs args, Func<TArgs, int> read) => read(args);

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int Erased(object args) => ((dynamic) args).id;

public static void Run()
{
Console.WriteLine();
Console.WriteLine("Does a non-escaping generic args object get stack-allocated?");

// generic, inlineable, field read only -- the shape a TArgs interceptor would have
Sum(static () => { var a = new { id = 42 }; return a.id; }, "inlined, never crosses a boundary");

// the same object handed to something typed as object, as today's interceptor does
Sum(static () => { var a = new { id = 42 }; return Keep(a); }, "passed as object (today's shape)");
}

[MethodImpl(MethodImplOptions.NoInlining)]
private static int Keep(object o) => o.GetHashCode() & 0;

private static void Sum(Func<int> body, string what)
{
for (var i = 0; i < 200; i++) _ = body(); // warm up and tier up
var before = GC.GetAllocatedBytesForCurrentThread();
var total = 0;
for (var i = 0; i < 10_000; i++) total += body();
var after = GC.GetAllocatedBytesForCurrentThread();
Console.WriteLine($" {what,-38} {(after - before) / 10_000.0,5:0.0} B per iteration (sum {total})");
}
}
95 changes: 95 additions & 0 deletions test/SpecializationProbes/IndirectionCost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using System;
using System.Data.Common;
using System.Diagnostics;

public class FakeDb : DbConnection
{
public override string ConnectionString { get; set; } = "";
public override string Database => "";
public override string DataSource => "";
public override string ServerVersion => "";
public override System.Data.ConnectionState State => System.Data.ConnectionState.Open;
public override void ChangeDatabase(string databaseName) { }
public override void Close() { }
public override void Open() { }
protected override DbTransaction BeginDbTransaction(System.Data.IsolationLevel il) => null!;
protected override DbCommand CreateDbCommand() => null!;
}

/// <summary>
/// Does either indirection allocate per call, and what does the delegate fallback cost?
/// </summary>
/// <remarks>
/// Behind the "passing the binder in" section of notes/provider-specialization.md. Two results
/// worth knowing before choosing: neither indirection allocates, and the function pointer measured
/// <em>slower</em> than the delegate -- 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.
/// <para>Note also that the function pointer needs <c>AllowUnsafeBlocks</c> in the <em>consumer's</em>
/// project: without it, generated code using one fails with CS0214, and that is a compilation-wide
/// switch a generated file cannot opt into on its own.</para>
/// </remarks>
public static unsafe class IndirectionCost
{
private static int s_sink;

private static T Cast<T>(object obj, Func<T> shape) => (T) obj;

// the per-shape binder a generator would emit
private static void AddArgs(DbConnection cnn, object args)
{
var typed = Cast(args, static () => new { id = default(int) });
s_sink += typed.id;
}

// created once, at type init; no unsafe needed
private static readonly Action<DbConnection, object> s_addArgs = AddArgs;

private static void SharedViaPointer(DbConnection cnn, object args, delegate*<DbConnection, object, void> bind)
=> bind(cnn, args);

private static void SharedViaDelegate(DbConnection cnn, object args, Action<DbConnection, object> bind)
=> bind(cnn, args);

private static void SharedInline(DbConnection cnn, object args)
{
var typed = Cast(args, static () => new { id = default(int) });
s_sink += typed.id;
}

public static void Run()
{
var cnn = new FakeDb();
const int Warm = 200_000, Iter = 20_000_000;

// warm up all three
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); }

Console.WriteLine("Allocation per call (the 24 B args object is the caller's, in all three):");
long b0 = GC.GetAllocatedBytesForCurrentThread();
for (var i = 0; i < 100_000; i++) SharedInline(cnn, new { id = 1 });
long b1 = GC.GetAllocatedBytesForCurrentThread();
for (var i = 0; i < 100_000; i++) SharedViaPointer(cnn, new { id = 1 }, &AddArgs);
long b2 = GC.GetAllocatedBytesForCurrentThread();
for (var i = 0; i < 100_000; i++) SharedViaDelegate(cnn, new { id = 1 }, s_addArgs);
long b3 = GC.GetAllocatedBytesForCurrentThread();
Console.WriteLine($" inline body {(b1 - b0) / 100_000.0,5:0.0} B");
Console.WriteLine($" via delegate* parameter {(b2 - b1) / 100_000.0,5:0.0} B");
Console.WriteLine($" via static readonly delegate {(b3 - b2) / 100_000.0,5:0.0} B");

Console.WriteLine();
Console.WriteLine("Cost per call, same work, different indirection:");
var sw = Stopwatch.StartNew();
for (var i = 0; i < Iter; i++) SharedInline(cnn, new { id = 1 });
sw.Stop(); var t0 = sw.Elapsed.TotalNanoseconds / Iter;
sw.Restart();
for (var i = 0; i < Iter; i++) SharedViaPointer(cnn, new { id = 1 }, &AddArgs);
sw.Stop(); var t1 = sw.Elapsed.TotalNanoseconds / Iter;
sw.Restart();
for (var i = 0; i < Iter; i++) SharedViaDelegate(cnn, new { id = 1 }, s_addArgs);
sw.Stop(); var t2 = sw.Elapsed.TotalNanoseconds / Iter;
Console.WriteLine($" inline body {t0,5:0.00} ns");
Console.WriteLine($" via delegate* parameter {t1,5:0.00} ns (+{t1 - t0:0.00})");
Console.WriteLine($" via static readonly delegate {t2,5:0.00} ns (+{t2 - t0:0.00})");
Console.WriteLine($" (sink {s_sink})");
}
}
167 changes: 167 additions & 0 deletions test/SpecializationProbes/OverloadBinding.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Runtime.CompilerServices;

// Which overload does an ordinary Dapper call site bind to, if a generic-args overload is added
// alongside the existing object?-based one? Nothing here talks to a database; the only question is
// what the compiler does with real-looking call sites.

public sealed class Customer { public int Id { get; set; } }

public sealed class CustomerArgs { public int Id { get; set; } }

public sealed class DynamicParametersLike { }

public sealed class FakeConnection : IDbConnection
{
public string ConnectionString { get; set; } = "";
public int ConnectionTimeout => 0;
public string Database => "";
public ConnectionState State => ConnectionState.Open;
public IDbTransaction BeginTransaction() => throw new NotSupportedException();
public IDbTransaction BeginTransaction(IsolationLevel il) => throw new NotSupportedException();
public void ChangeDatabase(string databaseName) { }
public void Close() { }
public IDbCommand CreateCommand() => throw new NotSupportedException();
public void Open() { }
public void Dispose() { }
}

public static class Existing
{
public static IEnumerable<T> Query<T>(this IDbConnection cnn, string sql, object? param = null)
{
Log.Bound("Query<T>(string, object?) [today]");
return [];
}

public static int Execute(this IDbConnection cnn, string sql, object? param = null)
{
Log.Bound("Execute(string, object?) [today]");
return 0;
}

// Dapper's dynamic-returning Query: no explicit type argument at the call site.
public static IEnumerable<object> Query(this IDbConnection cnn, string sql, object? param = null)
{
Log.Bound("Query(string, object?) -> dynamic [today]");
return [];
}

public static object? ExecuteScalar(this IDbConnection cnn, string sql, object? param = null)
{
Log.Bound("ExecuteScalar(string, object?) [today]");
return null;
}
}

public static class Candidates
{
// A: two type parameters, TResult only in the return position.
public static IEnumerable<TResult> Query<TResult, TArgs>(this IDbConnection cnn, string sql, TArgs param)
{
Log.Bound("Query<TResult, TArgs> [candidate A]");
return [];
}

// B: one type parameter, inferable from the argument. No OverloadResolutionPriority, to find out
// whether ordinary overload resolution already prefers it.
public static int Execute<TArgs>(this IDbConnection cnn, string sql, TArgs param)
{
Log.Bound("Execute<TArgs>(string, TArgs) [candidate B]");
return 0;
}

// C: the same trick on the dynamic-returning Query, which also has no explicit type argument.
public static IEnumerable<object> Query<TArgs>(this IDbConnection cnn, string sql, TArgs param)
{
Log.Bound("Query<TArgs>(string, TArgs) [candidate C]");
return [];
}

public static object? ExecuteScalar<TArgs>(this IDbConnection cnn, string sql, TArgs param)
{
Log.Bound("ExecuteScalar<TArgs>(string,TArgs) [candidate D]");
return null;
}
}

public static class Log
{
public static void Bound(string what) => Console.WriteLine($" -> {what}");
}

/// <summary>
/// Which overload does an ordinary Dapper call site bind to, if a generic-args overload is added
/// alongside the existing object?-based one -- and what does the argument object actually cost?
/// </summary>
/// <remarks>
/// Behind the "the args object is not the prize" section of notes/provider-specialization.md. The
/// headline is that an explicit type argument (Query&lt;Customer&gt;) excludes a two-parameter
/// overload from candidacy outright, because C# has no partial inference and an anonymous type
/// cannot be named -- so the dominant Dapper read shape cannot reach one by construction.
/// </remarks>
internal static class OverloadBinding
{
public static void Run()
{
var cnn = new FakeConnection();
object? nullArgs = null;
object boxedArgs = new CustomerArgs { Id = 1 };
var bag = new DynamicParametersLike();

Say("1. Query<Customer>(sql, new { id }) -- the dominant Dapper read shape");
_ = cnn.Query<Customer>("select ...", new { id = 1 });

Say("2. Execute(sql, new { id }) -- no explicit type argument anywhere");
_ = cnn.Execute("update ...", new { id = 1 });

Say("3. Execute(sql) -- no args");
_ = cnn.Execute("update ...");

Say("4. Execute(sql, null) -- null cannot infer TArgs");
_ = cnn.Execute("update ...", null);

Say("5. Execute(sql, objectTypedLocal) -- static type is object");
_ = cnn.Execute("update ...", boxedArgs);

Say("6. Execute(sql, typedArgsClass) -- an ordinary named class");
_ = cnn.Execute("update ...", new CustomerArgs { Id = 1 });

Say("7. Execute(sql, dynamicParametersLike) -- the bag shape Dapper handles specially");
_ = cnn.Execute("update ...", bag);

Say("8. Execute(sql, nullObjectLocal) -- null in an object?-typed local");
_ = cnn.Execute("update ...", nullArgs);

Say("9. Query(sql, new { id }) -- dynamic result, no explicit type argument");
_ = cnn.Query("select ...", new { id = 1 });

Say("10. ExecuteScalar(sql, new { id }) -- same shape again");
_ = cnn.ExecuteScalar("select ...", new { id = 1 });

Console.WriteLine();
Console.WriteLine("What does the args object actually cost?");
Measure("new { id = 42 }", static () => new { id = 42 });
Measure("new { id = 42, name = \"x\" }", static () => new { id = 42, name = "x" });
Measure("new CustomerArgs()", static () => new CustomerArgs { Id = 42 });

}

private static void Say(string what)
{
Console.WriteLine();
Console.WriteLine(what);
}

private static void Measure<T>(string what, Func<T> make)
{
_ = make(); // JIT and warm up
var before = GC.GetAllocatedBytesForCurrentThread();
for (var i = 0; i < 1000; i++)
_ = make();
var after = GC.GetAllocatedBytesForCurrentThread();
Console.WriteLine($" {what,-28} {(after - before) / 1000.0,6:0.0} B per instance");
}
}
11 changes: 11 additions & 0 deletions test/SpecializationProbes/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace SpecializationProbes;

internal static class Program
{
private static void Main()
{
OverloadBinding.Run();
Escape.Run();
IndirectionCost.Run();
}
}
23 changes: 23 additions & 0 deletions test/SpecializationProbes/SpecializationProbes.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<!--
The probes behind notes/provider-specialization.md. Each one answers a language or runtime
question that the note would otherwise be asserting from memory, and each prints the table that
appears there, so the claims can be re-checked rather than believed.

Nothing here touches a database or references Dapper: these are compiler and JIT questions.
Deliberately not listed in Build.csproj, matching test/Usage*, so it costs CI nothing.

Run it with `dotnet run` from this directory, in Release.
-->

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<!-- required by the function-pointer probe; see IndirectionCost.cs for why that matters -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

</Project>
Loading