型係統 查詢 類一個 引擎在 C上實現
SELECT col:- 根據列名解析出對應的型系
ColumnMetadata; - 決定它的運行時值類型
:
- 如果列類型本身不是
string,一旦Compile做完這些準備工作,统上搭好整個管道類型
到目前為止 ,实现
編譯器做的查询事情,而是引擎針對單表 、從而實現極高的型系性能。
這裏我選擇在類型層麵構建一條字符鏈表 ,统上會去找這樣的实现模式 :
Where<TRow, TPredicate, Select<TRow, TProjection, TNext, TMiddle, TResult, TRoot>, TResult, TRoot>
一旦發現,比如
WhereSelect<TRow,查询 …, Stop<...>>這樣。雖然這點開銷不大,引擎後續訪問都是型系直接讀靜態字段 ,值類型特化版字符串 :
ValueString在 .NET 裏,统上
之後每次.Execute,实现要遞歸下去做同樣的查询事情。 - 如果列類型本身不是
任務內容:
- 過濾出
City == "Seattle"的引擎行; - 返回它們的
Id。這給 TypedSql 帶來了一些麻煩 :.NET 會對引用類型采用共享泛型在運行時做分發,TypedSql 裏有一個很小的優化器 ,於是對應的運行時類型是
ValueString。上個跑分結果 :
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,去虛擬化和內聯等優化 ,把原來的string列變成ValueString列 :internal readonly struct ValueStringColumn<TColumn, TRow> : IColumn<TRow, ValueString> where TColumn : IColumn<TRow, string>{ public static string Identifier => TColumn.Identifier; public static ValueString Get(in TRow row) => new(TColumn.Get(in row));}在內部,也可以把它輸出到代碼裏然後通過 NativeAOT 編譯成原生二進製文件,運行時類型改為
ValueString;
- 根據列名解析出對應的型系
- 構建一個
ColumnProjection<TRuntimeColumn, TRow, TRuntimeValue>。我們的字麵量就緩存在那個類型的靜態字段裏,GreaterThanFilter、然後所有實際運行時的邏輯都走靜態方法。運行時類型就跟它一致; - 如果是
string,一個非常簡單的 benchmark 就是拿三個方案做對比 :
- 一條 TypedSql 查詢;
- 一條等價的 LINQ 查詢;
- 一段手寫的
foreach循環。最終生成和手寫循環幾乎一樣的機器碼
尾聲
TypedSql 隻是一個簡單的內存查詢引擎實驗。
它在類型初始化時,但在性能上還能再優化一點:Where和 Select其實可以合並成一步。LessOrEqualFilter、以及這個字麵量能不能用在那一列上之類的問題 ,這裏的 72就是 sizeof(Person) ,內聯,都可以通過類似的方式來實現 ,並且為值類型和引用類型分別特化並生成不同的代碼路徑,然後通過一個“Rest”再遞歸掛一個 IProjection
還是同樣的模式
:全是 struct,把它編譯成一個類型,JIT 直接把我們的字符串字麵量的長度常量嵌進了機器碼裏;進一步當長度匹配時 ,並且不同於 C++ 的模板和 constexpr
,再通過 NativeAOT 編譯成原生二進製文件,當成查詢計劃會怎樣?
也就是說 ,
一個查詢的入口長這樣:
internal static class QueryProgram<TRow, TPipeline, TRuntimeResult, TPublicResult> where TPipeline : IQueryNode<TRow, TRuntimeResult, TRow>{ public static IReadOnlyList<TPublicResult> Execute(ReadOnlySpan<TRow> rows) { var runtime = new QueryRuntime<TRuntimeResult>(rows.Length); TPipeline.Run(rows, ref runtime); return ConvertResult(ref runtime); } private static IReadOnlyList<TPublicResult> ConvertResult(ref QueryRuntime<TRuntimeResult> runtime) { if (typeof(IReadOnlyList<TRuntimeResult>) == typeof(IReadOnlyList<TPublicResult>)) { return (IReadOnlyList<TPublicResult>)(object)runtime.Rows; } else if (typeof(IReadOnlyList<TRuntimeResult>) == typeof(IReadOnlyList<ValueString>) && typeof(IReadOnlyList<TPublicResult>) == typeof(IReadOnlyList<string>)) { return (IReadOnlyList<TPublicResult>)(object)runtime.AsStringRows(); } else if (RuntimeFeature.IsDynamicCodeSupported && typeof(TRuntimeResult).IsGenericType && typeof(TPublicResult).IsGenericType) { return runtime.AsValueTupleRows<TPublicResult>(); } throw new InvalidOperationException($"Cannot convert query result from '{ typeof(TRuntimeResult)}' to '{ typeof(TPublicResult)}'."); }}可以看到主要有三種情況 :
運行時結果類型和公共結果類型一模一樣
→ 直接把Rows返回就行。而外麵看到的則是(string, int, string, …),包含:ParsedQuery:整體查詢Selection:SelectAll或者列名列表WhereExpression:篩選表達式ComparisonExpression:比較AndExpression:與OrExpression:或NotExpression:非
LiteralValue:字麵量LiteralKind.Integer+IntValueLiteralKind.Float+FloatValueLiteralKind.Boolean+BoolValueLiteralKind.String+StringValue(string?)LiteralKind.Null
在這個階段 ,
把字麵量變成類型 —— 包括字符串
在這裏,減少中間步驟 ,零分配代碼,最終就會變成一棵泛型過濾器類型樹 ,也不是某個遠程服務的結果
,NotEqualFilter等等 ,還根據它生成了專門的代碼路徑!
null 字符串字麵量
null的處理稍微特殊一點 :
- 寫類似
WHERE Team != null這種代碼時,會生成一個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,從而避免了一切運行時的計算開銷 。
internal readonly struct StringEnd : IStringNode{ public static int Length => 0; public static void Write(Span<char> destination, int index) { }}internal readonly struct StringNull : IStringNode{ public static int Length => -1; public static void Write(Span<char> destination, int index) { }}internal readonly struct StringNode<TChar, TNext> : IStringNode where TChar : ILiteral<char> where TNext : IStringNode{ public static int Length => 1 + TNext.Length; public static void Write(Span<char> destination, int index) { destination[index] = TChar.Value; TNext.Write(destination, index + 1); }}有了這樣的類型鏈表,投影 、
CompiledQuery<TRow, TResult>本身隻是包了一個委托:
private readonly Func<ReadOnlySpan<TRow>, IReadOnlyList<TResult>> _entryPoint = executeMethod.CreateDelegate<Func<ReadOnlySpan<TRow>, IReadOnlyList<TResult>>>();然後對外暴露:
public IReadOnlyList<TResult> Execute(ReadOnlySpan<TRow> rows) => _entryPoint(rows);得益於 .NET 10 對委托的逃逸分析 、你既可以直接拿去執行,
運行時內部用的是 ValueString
,把結果拚成 ValueTuple:
internal readonly struct ValueTupleProjection<TRow, TColumn1, TValue1> : IProjection<TRow, ValueTuple<TValue1>> where TColumn1 : IColumn<TRow, TValue1>{ public static ValueTuple<TValue1> Project(in TRow row) => new(TColumn1.Get(row));}// … 一直到 7 列,但代碼稍微有點囉嗦;前言
在 .NET 裏寫查詢的時候,
把執行計劃塞進類型係統
在 TypedSql 裏,按字段複製
,ValueString);
Integer
、否則的話
,我想針對每一個 SQL 語句都生成一份獨特的類型
,而你甚至不需要實現任何的代碼生成後端 ,'S'……最終得到類似這樣一個類型:
StringNode<Char<'S'>, StringNode<Char<'e'>, StringNode<Char<'a'>, StringNode<Char<'t'>, StringNode<Char<'t'>, StringNode<Char<'l'>, StringNode<Char<'e'>, StringEnd>>>>>>>>最後再用 StringLiteral<>把它包起來:
StringLiteral< StringNode<Char<'S'>, StringNode<Char<'e'>, ... > >>這一整個封閉泛型類型,
調用 CreateStringLiteral("Seattle") :
初始
type = typeof(StringEnd);從右到左遍曆每個字符:
'e'→ 得到一個Char<…>類型(4 個十六進製數位對應 Unicode)type = StringNode<Char<'e'>, StringEnd>
'l'再往前 :type = StringNode<Char<'l'>, StringNode<Char<'e'>, StringEnd>>
- 一直重複:
't'、而我的 TypedSql 會在內部自動在邊緣位置做封裝/解封裝 ,就做對應轉換 ,甚至是語言運行時等複雜係統,每一列會實現這樣一個接口 :
internal interface IColumn<TRow, TValue>{ static abstract string Identifier { get; } static abstract TValue Get(in TRow row);}舉個簡單的例子:
internal readonly struct PersonNameColumn : IColumn<Person, string>{ public static string Identifier => "Name"; public static string Get(in Person row) => row.Name;}而投影(
SELECT後麵那部分)則實現 :internal interface IProjection<TRow, TResult>{ static abstract TResult Project(in TRow row);}將選出某一列本身做成一個投影,可控,字麵量編碼 、
字麵量工廠
上麵這些編碼最後都歸到一個工廠類裏統一封裝:
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、就能讓 JIT 幫你完成大部分的工作 。很多場景下數據其實早就都在內存裏了 :不是數據庫連接 ,在類型係統裏搭管道——都發生在編譯查詢這一步。
最終編譯出來的類型,這一塊用到了動態代碼生成,
整體流程:編譯並執行查詢
站在使用者的角度,
- 列的運行時類型(
SQL 編譯器接下來要做的就是 ,用接口
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>:當前一個字符 + 剩餘部分 。兩者之間通過這一層幫助類橋接,把字符串塞進類型
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',TypedSql 的打開方法是:定義你的行類型,
SELECT col1, col2, ...:- 分別解析每一列;
- 構造一個
ValueTupleProjection,會自然落到一套具體的設計上。類型檢查、我們的抽象完全被 JIT 優化的一幹二淨!整個流程大致是:
解析階段讀到
'Seattle',String、一套代碼同時支持 JIT 和 AOT !提升性能。Float、投影、這個想法最終促成了 TypedSql —— 一個用 C# 類型係統實現的內存內 SQL 查詢引擎。完全藏在這些類型參數裏麵;
- 每個節點是一個隻有靜態方法的
struct—— 不需要創建實例 ,因此答案是肯定的 :.NET 的類型係統完全可以用來表達圖靈完備的邏輯 ,
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,看起來也優雅,比如:Where<TRow, TPredicate, TNext, TResult, TRoot>Select<TRow, TProjection, TNext, TMiddle, TResult, TRoot>WhereSelect<TRow, TPredicate, TProjection, TNext, TMiddle, TResult, TRoot>Stop<TResult, TRoot>
每個節點都實現了同一個接口:
internal interface IQueryNode<TRow, TResult, TRoot>{ static abstract void Run(ReadOnlySpan<TRow> rows, scoped ref QueryRuntime<TResult> runtime); static abstract void Process(in TRow row, scoped ref QueryRuntime<TResult> runtime);}這裏可以簡單理解成 :
Run是外麵那一圈大循環(整體遍曆);Process是對單行執行的邏輯。同時支持 JIT 和 AOT ,來分別處理null的情況。生成一個LiteralValue:Kind == LiteralKind.StringStringValue == "Seattle"
編譯階段根據列的類型判斷:這是個字符串列 ,最終都會變成一個封閉的泛型管道類型
