.NET Lambda の Native AOT を実際に試してみた

2022.12.12

いわさです。

この記事は 「AWS Community Builders Advent Calendar 2022」の 12 日目の記事となります。
私は 2022 年から AWS Community Builder (Serverless) として活動しています。
AWS Community Builder とはという点は以下をご参照ください。

.NET Lambda の Native AOT 対応

先日の AWS re:Invent 2022 で Java Lambda の SnapStart によるコールドスタート改善が発表されましたね。
実は .NET についてもコールドスタート改善につながる機能が re:Invent 2022 のセッションで紹介されていました。

簡単にいうと通常 Just-In-Time (JIT) で動作する .NET アプリケーションに対して Ahead-Of-Time (AOT) で実行出来るオプションを有効化して、実行時コンパイルのオーバーヘッドを無くしてコールドスタート減らそうぜというアプローチです。

上記は re:Invent セッションで取り上げられていましたが、実は re:Invent の直前に AWS の .NET 向けツールでもサポートされたアップデートが出ています。

こちらツールが対応したということですぐに利用が可能です。
ということで本日は実際に試してみました。

注意事項など

前提として .NET 7 は LTS ではないので Lambda のマネージドラインタイムとしてサポートされていません。
よってカスタムランタイムとしてデプロイする必要があります。

デプロイ方法ですが SAM CLI のテンプレートで Native AOT が有効化されたものが用意されていますのでこちらを使って、.NET 7 カスタムランタイムや .NET 6 マネージドの Lambda 関数をデプロイし cURL のコールドスタートとウォームスタート時の応答時間をいくつか確認してみます。

先に共有しておきますが、Native AOT でコールドスタート爆速!良かったね!をやりたかったのですが、どうもそうではないようで、別途深堀りが必要そうだという結果になっています。おそらくモジュールサイズなどなど関係しているのかなと思っています。
とはいえ各種アップデート記事やセッション動画では Native AOT 最高という印象を受けやすいかもしれないので、今回の記事は「慎重にな...」という意味で作成しました。

実行・検証結果

以降は単純に作成と検証した結果になっています。
冗長かなという気もしますので末尾の「まとめ」までスキップしてもらえればと思います。

ちなみに Native AOT はクロスコンパイルをサポートしていないので Cloud9 からビルド・デプロイをしています。
詳細は以下もご確認ください。

構築にあたり環境構築などは以下を参考にセットアップしています。

コールドスタートを意図的に発生する方法ですが、簡単な方法としては Lambda 関数を構成設定(タイムアウトなど)を変更することでリセットしコールドスタートを意図的に発生させることが出来ます。re:Invent 2022 のセッションで紹介されていました。

.NET 6 (マネージドランタイム)

% curl -w "time_total: %{time_total}\n" -o /dev/null -s  https://6i3i8qd8ol.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
time_total: 2.066566
% curl -w "time_total: %{time_total}\n" -o /dev/null -s  https://6i3i8qd8ol.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
time_total: 0.447176
% curl -w "time_total: %{time_total}\n" -o /dev/null -s  https://6i3i8qd8ol.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
time_total: 0.360940
% curl -w "time_total: %{time_total}\n" -o /dev/null -s  https://6i3i8qd8ol.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
time_total: 0.369549

.NET 7 (カスタムランタイム) + Native AOT

Native AOT を有効化する場合はプロジェクトファイルのPublishAotオプションがtrueになっています。

hoge1212dotnet/dotnet7aot/src/HelloWorld/HelloWorld.csproj

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net7.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <AWSProjectType>Lambda</AWSProjectType>
        <AssemblyName>bootstrap</AssemblyName>
        <!-- This property makes the build directory similar to a publish directory and helps the AWS .NET Lambda Mock Test Tool find project dependencies. -->
        <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>

        <!-- PublishAot tells the compiler to publish native AOT binaries. -->
        <PublishAot>true</PublishAot>
        <!-- StripSymbols tells the compiler to strip debugging symbols from the final executable if we're on Linux and put them into their own file. 
    This will greatly reduce the final executable's size.-->
        <StripSymbols>true</StripSymbols>
    </PropertyGroup>

:

この場合、ひとつのパッケージファイルとしてデプロイされます。

% curl -w "time_total: %{time_total}\n" -o /dev/null -s https://6wri6pc73i.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
time_total: 1.280774
% curl -w "time_total: %{time_total}\n" -o /dev/null -s https://6wri6pc73i.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
time_total: 0.343845
% curl -w "time_total: %{time_total}\n" -o /dev/null -s https://6wri6pc73i.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
time_total: 0.346572
% curl -w "time_total: %{time_total}\n" -o /dev/null -s https://6wri6pc73i.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
time_total: 0.338850

.NET 7 (カスタムランタイム)

そこで Native AOT を有効化しないオプションで実行してみたところ、もっと速くなりました。
しかも半分くらいの速度ですね。ちなみに Lambda のメモリサイズなどはすべて同条件(128 MB)です。

期待値としては Native AOT より遅くなってほしかったのですが...

% curl -w "time_total: %{time_total}\n" -o /dev/null -s https://saq7zu93tl.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
time_total: 0.629793
% curl -w "time_total: %{time_total}\n" -o /dev/null -s https://saq7zu93tl.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
time_total: 0.199925
% curl -w "time_total: %{time_total}\n" -o /dev/null -s https://saq7zu93tl.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
time_total: 0.180931
% curl -w "time_total: %{time_total}\n" -o /dev/null -s https://saq7zu93tl.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
time_total: 0.157480

まとめ

試してみたところ以下のようになりました。

環境 コールドスタート ウォームスタート
.NET 6 マネージドランタイム 2 秒 0.4 秒
.NET 7 カスタムランタイム Native AOT 1.2 秒 0.34 秒
.NET 7 カスタムランタイム 0.62 秒 0.18 秒

計測してみたところ、.NET 7 のコールドスタートの速度は .NET 6 マネージドラインタイムよりも改善されています。非コールドスタート時の速度も気持ち少し良くなってる気がしますね。
ただ、これがカスタムランタイムのおかげか、.NET 7 のおかげか、Native AOT のおかげかはわからない状態です。

さらに、期待に反して .NET 7 同士の比較だと Native AOT を使わないほうが速くなってしまいました。
ちなみに、ReadyToRun のドキュメントではコード量が少ないと AOT によるモジュールサイズの増加に伴って、逆にパフォーマンスが低下するという記述があります。
今回はデフォルトの最小限の関数だったのでもう少し色々と実装したコードで試してみると違う結果が出そうです。
そのあたりも今後試してみたいと思います。

この検証結果からちょっと不完全燃焼な感じはしますが、少なくともsam initのデフォルトテンプレートのコードだと Native AOT を有効化したほうが遅くなったので、何でも無条件に Native AOT 化すれば良さそうではないということがわかりました。
Native AOT を採用するべき条件がいくつかありそうなので今後それらを探していきたいと思います。