通過 .NET NativeAOT 實現用戶體驗升級

前言

TypedocConverter 是我先前因幫助維護 monaco-editor-uwp 但苦於 monaco editor 的 API 實在太多,手寫 C# 的類型綁定十分不划算而發起的一個項目。

這個工具可以將 typedoc 根據 TypeScript 生成的 JSON 文件直接生成對應的 C# 類型綁定程式碼,並提供完整的 JSON 序列化支援,因此使用這個工具可以大大降低移植 TypeScript 庫到 .NET 上的困難。(至於為什麼是從 typedoc 而不是從 TypeScript 直接 parse,其實只是因為太懶了不想寫 TypeScript 的 parser)

TypedocConverter 使用 F# 編寫,雖然使用 .NET 5 可以做到程式集裁剪後使用單文件自託管發布,但是我一直在想如果能使用 AOT 技術將整個程式編譯為 native binary 那就好了,這樣的話用戶在使用的時候將不需要運行 .NET 的運行時,也不需要 JIT,而是直接運行機器程式碼。

工具除了功能性之外,最重要的就是用戶體驗,這樣做將大大提升程式的啟動速度(雖然原本已經夠快了,但是我想將 100ms 的啟動時間縮短到不到 1ms),使得用戶使用該工具時不需要任何的等待。

AOT 方案調研

.NET 一直以來都有一個叫做 CoreRT 的項目,使用該工具可以將 .NET 程式集編譯到 native binary,然而這個項目自從 2018 年官方就沒有再積極維護。但是由於社區的強烈呼聲以及某個微軟的合作夥伴的項目需要 AOT 技術,並表示如果沒有這項技術將不再使用 .NET,於是這個項目原地復活,以 NativeAOT 的名字轉移到了 runtimelab 並作為 .NET 6 的 P0(最高) 優先順序實驗性工作項(即提供帶支援的官方 preview,而不再是原來的萬年 alpha),目前支援 win-x64linux-x64osx-x64,對於 ARM64 和移動和瀏覽器平台的支援在計劃當中。

借著這個契機,我決定使用該方案將項目編譯為原生鏡像。

NativeAOT 原理

.NET 的 NativeAOT 的思路其實很簡單:

  • 首先需要一個 AOT 友好的、用於 NativeAOT 的核心庫 (System.Private.CoreLib)實現,提供類型和實現查找、類型解析等方法
  • 掃描程式集,記錄用到的類型和方法
  • 調用 RyuJIT 介面,生成類型的元數據,為所有的方法生成程式碼,最終產生出 obj 二進位文件
  • 調用鏈接器(MSVC 或 clang),將產生的 obj 與 GC 和系統庫等鏈接成為最終的可執行文件

現階段 NativeAOT 基本已經完成,剩餘的部分工作則是一些修補和完善,以及對新版本 .NET 的跟進(目前還沒有跟進 C# 8 之後牽扯到運行時修改的特性,如默認介面方法實現和模組初始化器等等)。

可能你會問這和 .NET Native 技術有何不同?不同之處在於 .NET Native 使用 UTC 編譯器(MSVC 後端)進行程式碼生成,而 NativeAOT 使用 RyuJIT 進行程式碼生成。

關於 .NET NativeAOT 完整的使用文檔可以參考:using-native-aot

針對 NativeAOT 改造項目

NativeAOT 使用非常簡單,只需要修改 csproj 項目文件即可:

<PropertyGroup>
  <IlcOptimizationPreference>Speed</IlcOptimizationPreference>
  <IlcFoldIdenticalMethodBodies>true</IlcFoldIdenticalMethodBodies>
</PropertyGroup>
<ItemGroup>
  <PackageReference Include="Microsoft.DotNet.ILCompiler" Version="6.0.0-*" />
</ItemGroup>

IlcOptimizationPreference 指定 Speed 表示以最大性能為目標生成程式碼(如果指定 Size 則表示以最小程式為目標生成程式碼)。

IlcFoldIdenticalMethodBodies 參數則可以將相同的方法體合併,有助於減小體積。

最後則是新的 Microsoft.DotNet.ILCompiler,這是 NativeAOT 編譯器本體,通過 wildcard 指定 6.0.0-* 版本,這樣每次編譯都會獲取最新的版本。

由於 Microsoft.DotNet.ILCompiler 來自實驗倉庫的 artifacts,而沒有發布在官方的 nuget 源,需要新建 nuget.config 額外將實驗倉庫的 artifacts 作為源引入:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <add key="dotnet-experimental" value="//pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-experimental/nuget/v3/index.json" />
  </packageSources>
</configuration>

如此一來便大功告成了,這就開始編譯:

dotnet publish -c Release -r win-x64

伴隨而來的是大量的警告:

AOT analysis warning IL9700: Microsoft.FSharp.Reflection.FSharpType.MakeFunctionType(Type,Type): Calling 'System.Type.MakeGenericType(Type[])' which has `RequiresDynamicCodeAttribute` can break functionality when compiled fully ahead of time. The native code for this instantiation might not be available at runtime.
AOT analysis warning IL9700: Microsoft.FSharp.Reflection.FSharpValue.MakeFunction(Type,FSharpFunc`2<Object,Object>): Calling 'System.Type.MakeGenericType(Type[])' which has `RequiresDynamicCodeAttribute` can break functionality when compiled fully ahead of time. The native code for this instantiation might not be available at runtime.
...

觀察警告可以發現,這是分析器報出來的,理由很簡單:NativeAOT 是不支援運行時動態程式碼生成的,但是 MakeGenericType 在需要在運行時產生類型,因此可能不受支援。

為什麼說是可能呢?因為 NativeAOT 條件下,不支援運行時產生新的類型,但是對於已經生成程式碼的類型則是完全支援的。

由於項目沒有用到 System.Reflection.Emit 在運行時動態織入 IL,也沒有用到 Assembly.LoadFile 等動態載入程式集,更沒有用到 C++/CLI 和 COM,因此是 NativeAOT 兼容的。

編譯速度尚可,只等待了半分鐘。編譯完成後產生了一個 29mb 的 exe,體積還不夠優秀,但是先運行看看:

> ./TypedocConverter
[Error] No input file
Typedoc Converter Arguments:
--inputfile [file]: input file
--namespace [namespace]: specify namespace for generated code
--splitfiles [true|false]: whether to split code to different files
--outputdir [path]: used for place code files when splitfiles is true
--outputfile [path]: used for place code file when splitfiles is false
--number-type [int/decimal/double...]: config for number type mapping
--promise-type [CLR/WinRT]: config for promise type mapping, CLR for Task and WinRT for IAsyncAction/IAsyncOperation
--any-type [object/dynamic...]: config for any type mapping
--array-type [Array/IEnumerable/List...]: config for array type mapping
--nrt-disabled [true|false]: whether to disable Nullable Reference Types
--use-system-json [true|false]: whether to use System.Text.Json instead of Newtonsoft.Json

一瞬間就運行了起來,完全感受不到啟動時間(體感小於 1ms),這個體驗太爽了。

可是正當我高興的時候,使用一個實際的 JSON 文件對功能進行測試,卻報錯了:

Unhandled Exception: EETypeRva:0x013EC198(System.Reflection.MissingRuntimeArtifactException): MakeGenericMethod() cannot create this generic method instantiation because no code was generated for it: 'Microsoft.FSharp.Collections.ListModule.OfSeq<System.Int32>(System.Collections.Generic.IEnumerable<System.Int32>)'.
   at Internal.Reflection.Core.Execution.ExecutionEnvironment.GetMethodInvoker(RuntimeTypeInfo, QMethodDefinition, RuntimeTypeInfo[], MemberInfo) + 0x144
   at System.Reflection.Runtime.MethodInfos.NativeFormat.NativeFormatMethodCommon.GetUncachedMethodInvoker(RuntimeTypeInfo[], MemberInfo) + 0x50
   at System.Reflection.Runtime.MethodInfos.RuntimeMethodInfo.get_MethodInvoker() + 0xa1
   at System.Reflection.Runtime.MethodInfos.RuntimeNamedMethodInfo`1.MakeGenericMethod(Type[]) + 0x104
   ...

可以看到方法 Microsoft.FSharp.Collections.ListModule.OfSeq<System.Int32>(System.Collections.Generic.IEnumerable<System.Int32> 缺失了。

這是因為 NativeAOT 編譯器並沒有通過程式碼路徑分析出該類型,因此沒有為該類型生成程式碼,導致運行時嘗試創建該類型時由於找不到實現程式碼而出錯。

因此,需要通過 Runtime Directives 指示編譯器生成指定類型和方法的程式碼,方法是創建一個 rd.xml 並引入項目:

 <ItemGroup>
  <RdXmlFile Include="rd.xml" />
 </ItemGroup>

然後在 rd.xml 中編寫需要編譯器額外生成的類型和方法。經過一番試錯之後,我寫出了如下的程式碼:

<Directives>
  <Application>
    <Assembly Name="FSharp.Core" Dynamic="Required All">
      <Type Name="Microsoft.FSharp.Collections.ListModule" Dynamic="Required All">
        <Method Name="OfSeq" Dynamic="Required">
          <GenericArgument Name="System.Int32,System.Private.CoreLib" />
        </Method>
      </Type>
      <Type Name="Microsoft.FSharp.Core.PrintfImpl+Specializations`3[[System.Object,System.Private.CoreLib],[System.Object,System.Private.CoreLib],[System.Object,System.Private.CoreLib]]" Dynamic="Required All">
        <Method Name="CaptureFinal1" Dynamic="Required">
          <GenericArgument Name="System.Object,System.Private.CoreLib" />
        </Method>
        <Method Name="CaptureFinal2" Dynamic="Required">
          <GenericArgument Name="System.Object,System.Private.CoreLib" />
          <GenericArgument Name="System.Object,System.Private.CoreLib" />
        </Method>
        <Method Name="CaptureFinal3" Dynamic="Required">
          <GenericArgument Name="System.Object,System.Private.CoreLib" />
          <GenericArgument Name="System.Object,System.Private.CoreLib" />
          <GenericArgument Name="System.Object,System.Private.CoreLib" />
        </Method>
        <Method Name="OneStepWithArg" Dynamic="Required">
          <GenericArgument Name="System.Object,System.Private.CoreLib" />
        </Method>
        <Method Name="Capture1" Dynamic="Required">
          <GenericArgument Name="System.Object,System.Private.CoreLib" />
          <GenericArgument Name="System.Object,System.Private.CoreLib" />
        </Method>
        <Method Name="Capture2" Dynamic="Required">
          <GenericArgument Name="System.Object,System.Private.CoreLib" />
          <GenericArgument Name="System.Object,System.Private.CoreLib" />
          <GenericArgument Name="System.Object,System.Private.CoreLib" />
        </Method>
        <Method Name="Capture3" Dynamic="Required">
          <GenericArgument Name="System.Object,System.Private.CoreLib" />
          <GenericArgument Name="System.Object,System.Private.CoreLib" />
          <GenericArgument Name="System.Object,System.Private.CoreLib" />
          <GenericArgument Name="System.Object,System.Private.CoreLib" />
        </Method>
      </Type> 
    </Assembly>

    <Assembly Name="System.Linq" Dynamic="Required All">
      <Type Name="System.Linq.Enumerable" Dynamic="Required All">
        <Method Name="ToArray" Dynamic="Required">
          <GenericArgument Name="System.Int32,System.Private.CoreLib" />
        </Method>
      </Type>
    </Assembly>

  </Application>
</Directives>

稍微對上面的東西進行一下解釋:Name 用於指定類型,, 前後分別是類型的完整名稱和類型來自的程式集名稱,.NET 中的各種基礎類型都來源於 System.Private.CoreLibmscorlib。詳細的格式說明可以參考 rd-xml-format

在 .NET 中,編譯器會為所有的值類型的泛型參數特化一份實現,而所有的引用類型參數共享一份實現。這麼做其實原因顯而易見,因為引用類型背後只是一個指針罷了。因此根據這個特點,所有的引用類型都無需指定實際的類型參數,統一指定一個 System.Object 就好了;而對於值類型作為類型參數則需要指出生成什麼類型的程式碼。

經過上面一番折騰之後,重新編譯運行,這次所有的功能均正常了,啟動速度飛快,運行時性能也非常棒,並且純靜態鏈接無需安裝任何運行時就能運行,體驗幾乎和 C++ 編寫出來的程式一樣。

程式體積優化

上面一系列操作之後,雖然啟動和運行速度很快,但是生成的程式大小有 30 mb,還是有些大,那麼接下來在不犧牲運行時程式碼性能的情況下,針對程式體積進行優化。

首先指定 TrimMode為 Link,這可以使 NativeAOT 採用更加激進的程式集剪裁方案,將程式碼路徑中沒有被引用的程式碼以方法為粒度刪掉;另外,想到自己的程式不需要國際化支援,因此可以刪除掉沒有用的多語言支援及其資源文件。

<PropertyGroup>
  <TrimMode>Link</TrimMode>
  <InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>

重新進行編譯,這個時候產生的 exe 大小只有 27mb 了,運行測試:

Unhandled Exception: Newtonsoft.Json.JsonSerializationException: Unable to find a constructor to use for type Definitions+Reflection. A class should either have a default constructor, one constructor with arguments or a constructor marked with the JsonConstructor attribute. Path 'id', line 2, position 6.
   at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.CreateNewObject(JsonReader, JsonObjectContract, JsonProperty, JsonProperty, String, Boolean&) + 0x1d1
   at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.CreateObject(JsonReader, Type, JsonContract, JsonProperty, JsonContainerContract, JsonProperty, Object) + 0x2cc
   at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.CreateValueInternal(JsonReader, Type, JsonContract, JsonProperty, JsonContainerContract, JsonProperty, Object) + 0xa4
   at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.Deserialize(JsonReader, Type, Boolean) + 0x26e
   at Newtonsoft.Json.JsonSerializer.DeserializeInternal(JsonReader, Type) + 0xf8
   at Newtonsoft.Json.JsonConvert.DeserializeObject(String, Type, JsonSerializerSettings) + 0x93
   at Newtonsoft.Json.JsonConvert.DeserializeObject[T](String, JsonSerializerSettings) + 0x2b
   at Program.main$cont@84(JsonSerializerSettings, Definitions.Config, Unit) + 0x31
   at TypedocConverter!<BaseAddress>+0x83a0ca

根據報錯資訊我們知道是 JSON 反序列化過程出了問題,問題在於 Definitions+Reflection 類型被裁剪掉了。由於我知道我自己的程式內進行 JSON 反序列化的目標類型都是來自於我自己的程式集本身,因此不必使用 rd.xml 那麼麻煩,只需要告訴編譯器不要裁剪我自己的程式集中的類型即可(這對於泛型類實例無效,因為泛型類型實現是需要特化的):

<ItemGroup>
  <TrimmerRootAssembly Include="TypedocConverter" />
</ItemGroup>

接下來重新編譯運行,這次沒問題了。

最終程式的大小是 27mb,相比 30mb 並沒有小太多,不過這也正常,畢竟前面寫的 rd.xml 中,由於偷懶,通過 Dynamic="Require All" 保留了 F# 核心庫中的所有類型。如果我去掉 Dynamic="Require All" 的話,最終編譯出 22mb 的二進位文件,但是需要更多的精力調研有哪些類型需要寫進 rd.xml

通過 zip 壓縮之後只剩下 11mb,這個體積我覺得已經不錯了。

當然,要注意的是,Windows 下調試符號文件默認作為單獨的 pdb 文件提供,而在 *inx 下調試符號是直接內嵌到程式二進位數據中的,因此在非 Windows 平台下需要使用 strip 命令將符號裁剪掉,否則你將得到一個非常大的二進位程式文件。

strip ./TypedocConverter

想要看看最終效果的可以去此處下載含 Native 名稱的 Release 文件體驗://github.com/hez2010/TypedocConverter/releases。

已知問題和限制

.NET NativeAOT 預計會在 .NET 6 將會為嘗鮮者提供帶支援的預覽(其實已經足夠穩定),現階段有一些比較影響使用的已知問題,我將在這裡列出。

由於缺少實現而不支援(主要是 C# 8 之後的需要運行時改變的特性),但是短期內會被解決的問題:

  • 不支援含泛型方法的默認介面方法實現
  • 不支援協變返回
  • try-catch 語句中不支援 catch (T),即將泛型參數作為 catch 的異常類型
  • 不支援模組初始化器

短期內不會被解決的問題:

  • 不支援 COM
  • 不支援 C++/CLI

受限於運行時無 JIT 而無法實現的:

  • 運行時動態生成程式碼(如:System.Reflection.Emit
  • 運行時動態載入程式集(如:Assembly.LoadFile
  • 無限泛型遞歸調用

有人可能不理解什麼叫做無限泛型遞歸調用,我通過程式碼解釋一下,假如你編寫了如下程式碼:

public void Foo<T>()
{
    if (bar)
    {
        Foo<U<T>>();
    }
}

那麼會導致編譯器 Stack Overflow。原因是因為程式碼中將 U<T>> 類型代入了 T,如果是不改變泛型嵌套層數調用的話(比如將 U 帶入 T),只需要通過 rd.xml 指定一下用到的類型即可解決;但是對於前後嵌套層數不一致的情況,編譯器在編譯時並不知道你到底會展開多少層程式碼(NativeAOT 編譯器需要在編譯時展開所有的泛型並為涉及到的所有的方法和類型生成程式碼),於是會無限的生成用於 TU<T>U<U<T>>… 的程式碼,最終導致無法完成編譯。 而為什麼有 JIT 的情況下不存在問題呢?是因為可以根據 bar 這個條件在運行時按需產生類型和生成程式碼。

我曾經為 ReactvieX 和 Entity Framework Core 修復過類似的問題,如果想要了解詳情的話可以參考:

GUI 解決方案

由於不支援 COM,意味著 WPF 將無法經過 NativeAOT 編譯為本機程式,但是好在 WPF 的跨平台(基於 Skia 自繪)實現版本 Avalonia 完全不需要 COM,也不包含我上述列出的已知問題,因此今天就已經能夠使用它開發跨平台的 UI 程式。

由於 0.10.0 版本做了大量優化,並引入了編譯時綁定,性能有極大的提升,並且所有動畫都以 60fps 呈現,還自帶一套 Fluent Design 的主題庫,體驗非常舒適。我經過嘗試之後,將自己的可視化通用旅行商問題解算器應用使用 NativeAOT 編譯後得到了一個 40mb 大小的應用程式(無需運行時),可以瞬間啟動且運行時記憶體佔用不到 20mb,什麼才是小而美(戰術後仰)。

SATSP

左側是一個包含接近 70 萬個節點的折線圖,可以 60 fps 的體驗(其實可以更高,但對於桌面 GUI 應用來說 60 fps 渲染是一個默認的設定)隨意滑動、縮放和跟蹤點,完全不帶一點卡頓(某 WebGL 實現的 echart 這時候早已經停止了思考)。

Web 解決方案

自然,ASP.NET Core 是支援 NativeAOT 的(MVC 中的 View 暫時除外),而 Entity Framework Core 由於使用了含泛型的默認介面方法實現暫時不支援 NativeAOT,隨著 NativeAOT 編譯器和庫的更新會解決。

至於重度依賴運行時織入 IL 的 Dapper,可能永遠也不會支援 NativeAOT,畢竟熊掌和魚不可兼得。

當然,通過 Source Generator 將動態生成程式碼轉為靜態生成程式碼不失為一種解決方案。

我將自己的一個沒有使用 ORM,只是使用 Microsoft.Data.Sqlite 的用於人員管理的 Web 服務經過 NativeAOT 編譯,得到了一個 30mb 的程式,運行後瞬間就能提供服務,記憶體佔用只需要 20mb,且首次請求只需要 0.7ms,體驗非常的棒。這意味著在雲原生環境下,尤其是擴容時,新建節點中的應用可以在極短時間內(一秒都不到)啟動並投入使用,而不是都啟動不久了還在等健康檢查的響應。預熱是什麼?不存在的!

總結和展望

毫無疑問,NativeAOT 將能極大的改善 .NET 程式的啟動速度和運行性能,並自帶反破解屬性,真正做到 C# 的編寫效率,C++ 的運行效率。在 .NET 5 的今天這套工具鏈其實發展狀況已經較為成熟了,想用的話已經可以提前體驗,國外其實已經有使用這套工具鏈上線生產項目的例子了。

.NET NativeAOT 目前還在不斷探索各種可能性,其中一個我認為比較有趣的是:

在 NativeAOT 編譯中,先將 IL 藉助 RyuJIT 編譯到 LLVM IR,這個過程會對程式碼進行 IL 特有模式相關的優化;然後將 LLVM IR 編譯到原生二進位程式,這個過程將會通過 LLVM 進行進一步的優化,使得編譯後的體積更小、運行時性能更強。

先前的之前編譯到 LLVM IR 的實驗 LLILC 的問題在於直接 target 到 LLVM IR 導致 RyuJIT 針對 IL 特定模式的優化缺失。而新的實驗當中,RyuJIT 作為「中端」,做好針對 IL 特定模式的優化後再送到 LLVM,避免了該不足之處。

未來 .NET NativeAOT 技術同樣會被帶到移動平台和瀏覽器(WebAssembly)上,對於這套技術以後的發展我也會長期關注和跟進。

最後,希望 .NET 平台越來越好。