
一个查询的入口长这样Copyinternal static class QueryProgramTRow, TPipeline, TRuntimeResult, TPublicResultwhere TPipeline : IQueryNodeTRow, TRuntimeResult, TRow{public static IReadOnlyList Execute(ReadOnlySpan rows){var runtime new QueryRuntime(rows.Length);TPipeline.Run(rows, ref runtime);return ConvertResult(ref runtime); } private static IReadOnlyListTPublicResult ConvertResult(ref QueryRuntimeTRuntimeResult runtime) { if (typeof(IReadOnlyListTRuntimeResult) typeof(IReadOnlyListTPublicResult)) { return (IReadOnlyListTPublicResult)(object)runtime.Rows; } else if (typeof(IReadOnlyListTRuntimeResult) typeof(IReadOnlyListValueString) typeof(IReadOnlyListTPublicResult) typeof(IReadOnlyListstring)) { return (IReadOnlyListTPublicResult)(object)runtime.AsStringRows(); } else if (RuntimeFeature.IsDynamicCodeSupported typeof(TRuntimeResult).IsGenericType typeof(TPublicResult).IsGenericType) { return runtime.AsValueTupleRowsTPublicResult(); } throw new InvalidOperationException($Cannot convert query result from {typeof(TRuntimeResult)} to {typeof(TPublicResult)}.); }}可以看到主要有三种情况运行时结果类型和公共结果类型一模一样→ 直接把 Rows 返回就行。运行时内部用的是 ValueString外面希望看到 string→ 调用 AsStringRows它会把内部的 ValueString[] 包装一下对外返回 string?靠隐式转换。两边都是某种 ValueTuple 形状→ 用 AsValueTupleRows()底层交给 ValueTupleConvertHelper 去做拷贝和字段转换。ValueTupleConvertHelper用动态 IL 在元组之间搬运字段#ValueTupleConvertHelperTPublicResult, TRuntimeResult 的职责是在两个兼容形状的 ValueTuple 之间搬运字段识别并处理 string ↔ ValueString 的转换如果 ValueTuple 有 Rest嵌套元组要递归下去做同样的事情。它在类型初始化时会生成一个 DynamicMethod 来做拷贝Copyinternal static class ValueTupleConvertHelperTPublicResult, TRuntimeResult{private delegate void CopyDelegate(ref TPublicResult dest, ref readonly TRuntimeResult source);private static readonly CopyDelegate _helper default!; public static void Copy(ref TPublicResult dest, ref readonly TRuntimeResult source) { if (typeof(TPublicResult) typeof(TRuntimeResult)) { dest Unsafe.AsTRuntimeResult, TPublicResult(ref Unsafe.AsRef(in source)); } else { _helper.Invoke(ref dest, in source); } } static ValueTupleConvertHelper() { // 构造 DynamicMethod 和 IL按字段复制 // 若发现 string - ValueString就做对应转换 // 遇到 Rest 字段时递归。 }}这样运行时内部可以用一个对自己更舒服的元组类型比如 (ValueString, int, ValueString, …)而外面看到的则是 (string, int, string, …)两者之间通过这一层帮助类桥接成本也很低。这使得查询过程可以最大化利用值类型的泛型特化优势同时对外还不需要暴露这些内部细节达到了性能和易用性的平衡。不过需要注意的是这一块用到了动态代码生成所以在一些受限环境比如 AOT下可能无法使用因此 TypedSql 会在编译阶段检查这一点确保只有在支持动态代码的环境下才允许使用这种元组转换。否则的话就只能退回到直接让运行时结果类型和公共结果类型一致的方式。整体流程编译并执行查询#站在使用者的角度入口一般会是这样的Copyvar compiled QueryEngine.CompilePerson, string(“SELECT Name FROM $ WHERE City ! ‘Seattle’”);CompileTRow, TResult 在内部会做这么几件事解析 SQL生成 ParsedQuery把 SQL 编译成管道类型 TPipelineTRuntimeResultTPublicResult检查 TPublicResult 是否和你指定的 TResult 一致构造 QueryProgramTRow, TPipeline, TRuntimeResult, TPublicResult 这个类型找到它的静态方法 Execute(ReadOnlySpan)把它变成一个委托塞进 CompiledQueryTRow, TResult。CompiledQueryTRow, TResult 本身只是包了一个委托Copyprivate readonly FuncReadOnlySpan, IReadOnlyList _entryPoint executeMethod.CreateDelegateFuncReadOnlySpan, IReadOnlyList();然后对外暴露Copypublic IReadOnlyList Execute(ReadOnlySpan rows) _entryPoint(rows);得益于 .NET 10 对委托的逃逸分析、去虚拟化和内联等优化这一层委托调用可以说几乎没有任何开销。在 JIT 看来一旦 Compile 做完这些准备工作以后每次 Execute 就只是一次直接的静态调用调入一个所有类型参数已经封死的泛型方法这个方法里面再调用一串全是 struct 和静态方法组成的管道。最终编译出来的类型你既可以直接拿去执行也可以把它输出到代码里然后通过 NativeAOT 编译成原生二进制文件一套代码同时支持 JIT 和 AOT使用和性能测试#快速上手#和很多轻量级查询库类似TypedSql 的打开方法是定义你的行类型例如Copypublic sealed record Person(int Id,string Name,int Age,string City,float Salary,string Department,bool IsManager,int YearsAtCompany,string Country,string? Team,string Level);为每一列实现一个 IColumnPerson, TValue把这些列注册到 Person 对应的 schema 里然后就可以编译并运行查询例如Copy// 编译一次var wellPaidManagers QueryEngine.CompilePerson, Person(“”SELECT * FROM $WHERE Department ‘Engineering’AND IsManager trueAND YearsAtCompany 5AND Salary 170000AND Country ‘US’“”);// 针对不同数据集多次执行var result wellPaidManagers.Execute(allPeople.AsSpan());要是你只需要一部分列也可以返回元组Copyvar seniorTitles QueryEngine.CompilePerson, (string Name, string City, string Level)(“”SELECT Name, City, Level FROM $WHERE Level ‘Senior’ AND City ‘Seattle’“”);foreach (var (name, city, level) in seniorTitles.Execute(allPeople.AsSpan())){Console.WriteLine($“{name} in {city} [{level}]”);}所有重活——解析 SQL、字面量编码、在类型系统里搭管道——都发生在编译查询这一步。之后每次 .Execute都只是跑一遍已经专门化好的静态管道没有任何的运行时分发没有任何的虚拟调用不存在任何的反射和装箱完全是 JIT 能看懂的强类型、零分配代码从而实现极高的性能。简单性能对比#TypedSql 的目标并不是炫技用类型而是想试试看在保持 SQL 风格外壳的情况下我们能让生成的代码离一个手写循环有多近。一个非常简单的 benchmark 就是拿三个方案做对比一条 TypedSql 查询一条等价的 LINQ 查询一段手写的 foreach 循环。任务内容过滤出 City “Seattle” 的行返回它们的 Id。TypedSql 编译出来的类型大概是这样CopyQueryProgramPerson,WhereSelectPerson,EqualsFilterPerson,ValueStringColumnPersonCityColumn, Person,‘Seattle’,ValueString,ColumnProjectionPersonIdColumn, Person, Int32,StopInt32, Person,Int32,Int32,Person,Int32,Int32让我们来看看 RyuJIT 为我们的查询方案生成了什么样的机器码CopyG_M000_IG01: ; prologuepush r15push r14push rdipush rsipush rbppush rbxsub rsp, 40mov rbx, rcxG_M000_IG02: ; 分配结果数组mov esi, dword ptr [rbx0x08]mov edx, esimov rcx, 0x7FFE71F29558call CORINFO_HELP_NEWARR_1_VCmov rdi, raxxor ebp, ebpmov rbx, bword ptr [rbx]test esi, esijle SHORT G_M000_IG06G_M000_IG03: ; 初始化循环变量xor r14d, r14dG_M000_IG04: ; 循环体lea r15, bword ptr [rbxr14]mov rcx, gword ptr [r150x08]mov rdx, 0x16EB0400D30mov rdx, gword ptr [rdx]mov rdx, gword ptr [rdx0x08]cmp rcx, rdxje G_M000_IG12test rcx, rcxje SHORT G_M000_IG05test rdx, rdxje SHORT G_M000_IG05mov r8d, dword ptr [rcx0x08]cmp r8d, dword ptr [rdx0x08]je SHORT G_M000_IG08G_M000_IG05: ; 更新循环计数器add r14, 72dec esijne SHORT G_M000_IG04G_M000_IG06: ; 产生结果对象mov rcx, 0x7FFE72227600call CORINFO_HELP_NEWSFASTmov rbx, raxlea rcx, bword ptr [rbx0x08]mov rdx, rdicall CORINFO_HELP_ASSIGN_REFmov dword ptr [rbx0x10], ebpmov rax, rbxG_M000_IG07: ; epilogueadd rsp, 40pop rbxpop rbppop rsipop rdipop r14pop r15retG_M000_IG08: ; 字符串长度比较lea rax, bword ptr [rcx0x0C]add rdx, 12mov ecx, dword ptr [rcx0x08]add ecx, ecxmov r8d, ecxcmp r8, 10je SHORT G_M000_IG10G_M000_IG09: ; 字符串内容慢速比较mov rcx, raxcall [System.SpanHelpers:SequenceEqual(byref,byref,nuint):bool]jmp SHORT G_M000_IG11G_M000_IG10: ; 字符串内容快速比较mov rcx, qword ptr [rax]mov rax, qword ptr [rax0x02]mov r8, qword ptr [rdx]xor rcx, r8xor rax, qword ptr [rdx0x02]or rcx, raxsete almovzx rax, alG_M000_IG11: ; 处理比较结果test eax, eaxje SHORT G_M000_IG05G_M000_IG12: ; 把匹配的 Id 写入结果数组mov ecx, dword ptr [r150x30]lea rax, bword ptr [rdi0x10]lea edx, [rbp0x01]mov r15d, edxmovsxd rdx, ebpmov dword ptr [rax4*rdx], ecxmov ebp, r15djmp G_M000_IG05注意看 G_M000_IG08 的 r8, 10这里的 10 就是字符串字面量 ‘Seattle’ 的长度JIT 直接把我们的字符串字面量的长度常量嵌进了机器码里进一步当长度匹配时JIT 又生成了代码跳转到 G_M000_IG10这段代码专门处理长度为 10 的字符串的快速比较路径。也就是说JIT 不仅把字面量的值嵌进去了还根据它生成了专门的代码路径再注意看循环计数器的更新部分G_M000_IG05 里的 add r14, 72这里的 72 就是 sizeof(Person)JIT 直接把行类型的大小常量也嵌进去了避免了运行时的计算而 dec esi 更是直接把递增的循环优化成了递减减少了一次比较指令。上述代码的逻辑等价于Copyint length elements.Length;Span values new int[length];int count 0;for (int i length - 1; i 0; i–){var elem elements[i];var city elem.City;if (city null)continue;if (city.Length 10 city Seattle) { values[length - 1 - count] elem.Id; count; }}return values[…count];看到了吗跟你手写的循环几乎一模一样我们的抽象完全被 JIT 优化的一干二净上个跑分结果Method Mean Error StdDev Gen0 Code Size AllocatedTypedSql 10.953 ns 0.0250 ns 0.0195 ns 0.0051 111 B 80 BLinq 27.030 ns 0.1277 ns 0.1067 ns 0.0148 3,943 B 232 BForeach 9.429 ns 0.0417 ns 0.0326 ns 0.0046 407 B 72 B可以看到TypedSql 在时间和分配上无限逼近 foreach远远超过即使是在 .NET 10 中已经被高度优化后的 LINQ 的性能。这也符合我们对它内部结构的预期