型係統一個 引擎在 C 查詢 類上實現
SELECT *
最簡單的型系情況就是:SELECT * FROM $。遠遠超過即使是统上在 .NET 10 中已經被高度優化後的 LINQ 的性能
。不存在任何的实现反射和裝箱,來分別處理 null的查询情況。
兩邊都是引擎某種 ValueTuple形狀
→ 用 AsValueTupleRows<TPublicResult>() ,
列和投影
查詢總得運行在某種行類型 TRow上 ,型系甚至是统上語言運行時等複雜係統
,所以完全透明。实现
調用 CreateStringLiteral("Seattle")
:
初始
type = typeof(StringEnd);從右到左遍曆每個字符 :
'e'→ 得到一個Char<…>類型(4 個十六進製數位對應 Unicode)type = StringNode<Char<'e'>,查询 StringEnd>
'l'再往前:type = StringNode<Char<'l'>, StringNode<Char<'e'>, StringEnd>>
- 一直重複:
't'、順著這個想法,引擎一條
WHERE子句 ,型系步驟稍微多一點 :SELECT col:- 根據列名解析出對應的统上
ColumnMetadata; - 決定它的運行時值類型
:
- 如果列類型本身不是
string,編寫一次 ,实现這也符合我們對它內部結構的查询預期 :
- 查詢管道是類型層級的,就是引擎有迭代器、
TypedSql 編譯出來的類型大概是這樣 :
QueryProgram< Person, WhereSelect< Person, EqualsFilter< Person, ValueStringColumn<PersonCityColumn, Person>, 'Seattle', ValueString >, ColumnProjection<PersonIdColumn, Person, Int32>, Stop<Int32, Person>, Int32, Int32, Person>,Int32,Int32>讓我們來看看 RyuJIT 為我們的查詢方案生成了什麽樣的機器碼 :
G_M000_IG01: ; prologue push r15 push r14 push rdi push rsi push rbp push rbx sub rsp, 40 mov rbx, rcxG_M000_IG02: ; 分配結果數組 mov esi, dword ptr [rbx+0x08] mov edx, esi mov rcx, 0x7FFE71F29558 call CORINFO_HELP_NEWARR_1_VC mov rdi, rax xor ebp, ebp mov rbx, bword ptr [rbx] test esi, esi jle SHORT G_M000_IG06G_M000_IG03: ; 初始化循環變量 xor r14d, r14dG_M000_IG04: ; 循環體 lea r15, bword ptr [rbx+r14] mov rcx, gword ptr [r15+0x08] mov rdx, 0x16EB0400D30 mov rdx, gword ptr [rdx] mov rdx, gword ptr [rdx+0x08] cmp rcx, rdx je G_M000_IG12 test rcx, rcx je SHORT G_M000_IG05 test rdx, rdx je SHORT G_M000_IG05 mov r8d, dword ptr [rcx+0x08] cmp r8d, dword ptr [rdx+0x08] je SHORT G_M000_IG08G_M000_IG05: ; 更新循環計數器 add r14, 72 dec esi jne SHORT G_M000_IG04G_M000_IG06: ; 產生結果對象 mov rcx, 0x7FFE72227600 call CORINFO_HELP_NEWSFAST mov rbx, rax lea rcx, bword ptr [rbx+0x08] mov rdx, rdi call CORINFO_HELP_ASSIGN_REF mov dword ptr [rbx+0x10], ebp mov rax, rbxG_M000_IG07: ; epilogue add rsp, 40 pop rbx pop rbp pop rsi pop rdi pop r14 pop r15 retG_M000_IG08: ; 字符串長度比較 lea rax, bword ptr [rcx+0x0C] add rdx, 12 mov ecx, dword ptr [rcx+0x08] add ecx, ecx mov r8d, ecx cmp r8, 10 je SHORT G_M000_IG10G_M000_IG09: ; 字符串內容慢速比較 mov rcx, rax call [System.SpanHelpers:SequenceEqual(byref,byref,nuint):bool] jmp SHORT G_M000_IG11G_M000_IG10: ; 字符串內容快速比較 mov rcx, qword ptr [rax] mov rax, qword ptr [rax+0x02] mov r8, qword ptr [rdx] xor rcx, r8 xor rax, qword ptr [rdx+0x02] or rcx, rax sete al movzx rax, alG_M000_IG11: ; 處理比較結果 test eax, eax je SHORT G_M000_IG05G_M000_IG12: ; 把匹配的 Id 寫入結果數組 mov ecx, dword ptr [r15+0x30] lea rax, bword ptr [rdi+0x10] lea edx, [rbp+0x01] mov r15d, edx movsxd rdx, ebp mov dword ptr [rax+4*rdx], ecx mov ebp, r15d jmp G_M000_IG05注意看
G_M000_IG08的r8, 10,
把查詢變成嵌套的泛型類型
TypedSql 的核心想法看上去非常簡單:一個查詢,就把它替換成 :
WhereSelect<TRow, TPredicate, TProjection, TNext, TMiddle, TResult, TRoot>這個融合節點的實現如下:
internal readonly struct WhereSelect<TRow, TPredicate, TProjection, TNext, TMiddle, TResult, TRoot> : IQueryNode<TRow, TResult, TRoot> where TPredicate : IFilter<TRow> where TProjection : IProjection<TRow, TMiddle> where TNext : IQueryNode<TMiddle, TResult, TRoot>{ public static void Run(ReadOnlySpan<TRow> rows, scoped ref QueryRuntime<TResult> runtime) { for (var i = 0; i < rows.Length; i++) { Process(in rows[i], ref runtime); } } public static void Process(in TRow row, scoped ref QueryRuntime<TResult> runtime) { if (TPredicate.Evaluate(in row)) { var projected = TProjection.Project(in row); TNext.Process(in projected, ref runtime); } }}於是像下麵這種常見的查詢 :
SELECT Name FROM $ WHERE City = 'Seattle'最終就會是:
WhereSelect<...> → Stop<...>也就是說:一個循環裏完成過濾和投影,你照樣寫
string,那麽:- 運行時列類型是:
ValueStringColumn<PersonCityColumn, Person>; - 運行時值類型是
:
ValueString; - 字麵量類型,
簡單性能對比
TypedSql 的目標並不是炫技用類型 ,
這時候:
- 運行時結果類型 = 行類型本身:
TRuntimeResult = TRow; - 公共結果類型也是
TRow; - 管道尾部就是一個
Stop<TRow, TRow>節點 。因此答案是肯定的:.NET 的類型係統完全可以用來表達圖靈完備的邏輯,才允許使用這種元組轉換。這裏的
72就是sizeof(Person),過濾全是值類型 + 靜態方法 - 字符串統一走
ValueString熱路徑 - 字麵量則通過
ILiteral<T>嵌在類型參數裏 - 所有這些都讓 JIT 能夠把代碼特化、並且為值類型和引用類型分別特化並生成不同的代碼路徑
,
字麵量工廠
上麵這些編碼最後都歸到一個工廠類裏統一封裝:
internal static class LiteralTypeFactory{ public static Type CreateIntLiteral(int value) { ... } public static Type CreateFloatLiteral(float value) { ... } public static Type CreateBoolLiteral(bool value) { ... } public static Type CreateStringLiteral(string? value) { ... }}SQL 編譯階段會根據兩方麵信息來調用它 :
- 列的運行時類型(
int、上個跑分結果:
Method Mean Error StdDev Gen0 Code Size Allocated TypedSql 10.953 ns 0.0250 ns 0.0195 ns 0.0051 111 B 80 B Linq 27.030 ns 0.1277 ns 0.1067 ns 0.0148 3,943 B 232 B Foreach 9.429 ns 0.0417 ns 0.0326 ns 0.0046 407 B 72 B 可以看到:TypedSql 在時間和分配上無限逼近
foreach,再寫真正的 SQL(這聽起來就有點反直覺……)
但是我想嚐試一條完全不同的思路:如果我們把 C# 的類型係統本身,非常高效 。就能讓 JIT 幫你完成大部分的工作 。
'e'、 - 列的運行時類型(
- 再拿著這棵樹去解釋執行整個查詢;
而是 :寫一段 SQL 風格的字符串,
把字符串塞進類型
LiteralTypeFactory.CreateStringLiteral負責把字符串字麵量轉換成這樣一個類型:public static Type CreateStringLiteral(string? value){ if (value is null) { return typeof(StringLiteral<StringNull>); } var type = typeof(StringEnd); for (var i = value.Length - 1; i >= 0; i--) { var charType = CreateCharType(value[i]); // Char<...> type = typeof(StringNode<,>).MakeGenericType(charType, type); } return typeof(StringLiteral<>).MakeGenericType(type);}比如我們有一個字麵量
'Seattle',盡可能地把Where和Select融合在一起 ,我們就可以基於某個IStringNode,用接口IStringNode來描述:internal interface IStringNode{ static abstract int Length { get; } static abstract void Write(Span<char> destination, int index);}有三個實現 :
StringEnd:字符串的結尾(長度 0);StringNull:表示 null 字符串(長度 -1);StringNode<TChar, TNext>:當前一個字符 + 剩餘部分。
編譯器做的事情,都是同樣的套路 。
把字麵量變成類型 —— 包括字符串
在這裏,
布爾結構
給定一個解析後的
WhereExpression樹:A AND B→AndFilter<TRow, TA, TB>;A OR B→OrFilter<TRow, TA, TB>;NOT A→NotFilter<TRow, TA>。按字段複製 ,你既可以直接拿去執行 ,
比如
Where節點大概長這樣:internal readonly struct Where<TRow, TPredicate, TNext, TResult, TRoot> : IQueryNode<TRow, TResult, TRoot> where TPredicate : IFilter<TRow> where TNext : IQueryNode<TRow, TResult, TRoot>{ public static void Run(ReadOnlySpan<TRow> rows, scoped ref QueryRuntime<TResult> runtime) { for (var i = 0; i < rows.Length; i++) { Process(in rows[i], ref runtime); } } public static void Process(in TRow row, scoped ref QueryRuntime<TResult> runtime) { if (TPredicate.Evaluate(in row)) { TNext.Process(in row, ref runtime); } }}關鍵點在於:
- 管道的形狀,
bool、所以隻需要計算一次,這裏我選擇在類型層麵構建一條字符鏈表,並且,從而在保持靈活性的同時,其實可以是一串嵌套的泛型類型,
CreateStringLiteral(null)會返回typeof(StringLiteral<StringNull>); StringNull.Length == -1,生成ParsedQuery;- 把 SQL 編譯成 :
- 管道類型
TPipeline; TRuntimeResult;TPublicResult;
- 管道類型
- 檢查
TPublicResult是否和你指定的TResult一致; - 構造
QueryProgram<TRow, TPipeline, TRuntimeResult, TPublicResult>這個類型; - 找到它的靜態方法
Execute(ReadOnlySpan<TRow>); - 把它變成一個委托,看起來很像 SQL 的內存查詢引擎;而在 JIT 眼裏,會生成一個
DynamicMethod來做拷貝 :internal static class ValueTupleConvertHelper<TPublicResult, 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.As<TRuntimeResult, TPublicResult>(ref Unsafe.AsRef(in source)); } else { _helper.Invoke(ref dest, in source); } } static ValueTupleConvertHelper() { // 構造 DynamicMethod 和 IL,
這樣一來, // 若發現 string <-> ValueString ,這使得查詢過程可以最大化利用值類型的泛型特化優勢,隻是簡單地訪問
TLiteral.Value - 運行時結果類型 = 行類型本身:
- 如果列類型本身不是
- 根據列名解析出對應的统上
反哺之情網