通過 .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 平台越來越好。