この記事が日本マイクロソフト賞 最優秀賞に選ばれました。
はじめに
.NET 6でBlazor WebAssemblyにAOT (Ahead-of-time) コンパイルがサポートされました。従来のBlazor WebAssembyアプリでは.NETのコードはWebAssemblyに実装された.NET中間言語インタープリタを使用して実行されるため、.NETコードの実行は通常の.NETランタイムよりも遅くなります。AOTコンパイルによって.NETのコードをWebAssemblyに直接コンパイルすることでパフォーマンスの向上が期待されます。
この記事の手順では最初にBlazor WebAssemblyでサンプルアプリを作成して処理時間を計測してから、次にAOTコンパイルして同様に処理時間を計測します。また、Azure Static Web Appsを利用してアプリをAOTコンパイルしてデプロイします。最後に参考として実際に開発したアプリでAOTコンパイルによる高速化の結果を紹介します。
Blazor WebAssembly で Conway's Game of Life
まず最初にBlazor WebAssemblyで Conway's Game of Life をシミュレーションするアプリを作成します。
以下の記事を参考にして、途中経過やプログレスバーの表示、ボタンクリックの制御、セルの初期配置などのコードを追加しました。
アプリの作成
以下のコマンドを実行してAOTコンパイルのために必要なワークロードをインストールします。
> dotnet workload install wasm-tools
Visual Studio 2022でBlazor WebAssemblyのプロジェクトを作成して、Pages/Index.razor
を以下のように編集します。アプリの動作のために編集するのはこのファイルだけです。
@page "/"
<PageTitle>Index</PageTitle>
<p>
@if (watch.IsRunning)
{
<button class="btn btn-primary" disabled>Start</button>
<button class="btn btn-warning" disabled>Reset</button>
}
else
{
<button class="btn btn-primary" @onclick="Start">Start</button>
<button class="btn btn-warning" @onclick="Reset">Reset</button>
}
</p>
<p>
<div class="progress" style="height: 25px;">
@if (watch.IsRunning)
{
<div class="progress-bar" role="progressbar" style="width: @progress%"
aria-valuenow="@progress" aria-valuemin="0" aria-valuemax="100">
</div>
}
else if (progress == 100)
{
<div class="progress-bar bg-success" role="progressbar" style="width: @progress%"
aria-valuenow="@progress" aria-valuemin="0" aria-valuemax="100">
Elapsed Time: @(watch.ElapsedMilliseconds / 1000.0) seconds
</div>
}
</div>
</p>
<style type="text/css">
table.board td {
width: 10px;
height: 10px;
border: 1px solid black;
}
</style>
<p>
<table class="board">
@for (int i = 0; i < boardSize; i++)
{
<tr>
@for (int j = 0; j < boardSize; j++)
{
var row = i;
var col = j;
@if (watch.IsRunning)
{
<td style="background-color:@(board[row, col] ? "black" : "white")">
</td>
}
else
{
<td @onclick="() => board[row, col] = !board[row, col]"
style="background-color:@(board[row, col] ? "black" : "white")">
</td>
}
}
</tr>
}
</table>
</p>
@code {
private static int boardSize = 100;
private bool[,] board = new bool[boardSize, boardSize];
private int progress = 0;
private static int iterations = 300;
private System.Diagnostics.Stopwatch watch = new();
protected override void OnInitialized()
{
board = LoadDefaultBoard();
}
private async Task Start()
{
progress = 0;
watch = new();
watch.Start();
for (int i = 0; i < iterations; i++)
{
var newBoard = new bool[boardSize, boardSize];
for (int row = 0; row < boardSize; row++)
{
for (int col = 0; col < boardSize; col++)
{
if (board[row, col])
{
if (GetNeighbors(row, col) is 2 or 3)
{
newBoard[row, col] = true;
}
}
else
{
if (GetNeighbors(row, col) is 3)
{
newBoard[row, col] = true;
}
}
}
}
progress = (i + 1) * 100 / iterations;
board = newBoard;
StateHasChanged();
await Task.Delay(1);
}
watch.Stop();
}
private void Reset()
{
board = LoadDefaultBoard();
}
private int GetNeighbors(int row, int col)
{
int neighbors = 0;
for (int i = row - 1; i < row + 2; i++)
{
for (int j = col - 1; j < col + 2; j++)
{
if (i >= 0 && i < boardSize && j >= 0 && j < boardSize && board[i, j])
{
neighbors++;
}
}
}
if (board[row, col])
{
neighbors--;
}
return neighbors;
}
private bool[,] LoadDefaultBoard()
{
var defaultBoard = new bool[boardSize, boardSize];
int[,] positions = new int[,] {
// Gosper glider gun
{ 5, 1 }, { 6, 1 }, { 5, 2 }, { 6, 2 }, { 5, 11 }, { 6, 11 }, { 7, 11 }, { 4, 12 }, { 8, 12 },
{ 3, 13 }, { 9, 13 }, { 3, 14 }, { 9, 14 }, { 6, 15 }, { 4, 16 }, { 8, 16 }, { 5, 17 },
{ 6, 17 }, { 7, 17 }, { 6, 18 }, { 3, 21 }, { 4, 21 }, { 5, 21 }, { 3, 22 }, { 4, 22 },
{ 5, 22 }, { 2, 23 }, { 6, 23 }, { 1, 25 }, { 2, 25 }, { 6, 25 }, { 7, 25 }, { 3, 35 },
{ 4, 35 }, { 3, 36 }, { 4, 36 },
// Penta-decathlon
{ 10, 80 }, { 10, 81 }, { 10, 82 }, { 10, 83 }, { 10, 84 }, { 10, 85 }, { 10, 86 }, { 10, 87 },
{ 10, 88 }, { 10, 89 },
// small infinite growth pattern
{ 30, 40 }, { 30, 41 }, { 30, 42 }, { 30, 43 }, { 30, 44 }, { 30, 45 }, { 30, 46 }, { 30, 47 },
{ 30, 49 }, { 30, 50 }, { 30, 51 }, { 30, 52 }, { 30, 53 }, { 30, 57 }, { 30, 58 }, { 30, 59 },
{ 30, 66 }, { 30, 67 }, { 30, 68 }, { 30, 69 }, { 30, 70 }, { 30, 71 }, { 30, 72 }, { 30, 74 },
{ 30, 75 }, { 30, 76 }, { 30, 77 }, { 30, 78 },
};
for (int i = 0; i < positions.GetLength(0); i++)
{
defaultBoard[positions[i, 0], positions[i, 1]] = true;
}
return defaultBoard;
}
}
動作確認
Visual Studio 2022のツールバーのデバッグ > デバッグなしで開始
を選択するとアプリが起動して以下のような画面が表示されます。
起動した画面でStart
ボタンをクリックするとシミュレーションが開始して、完了するとプログレスバーの中央に処理時間が表示されます。
公開ファイルを作成
デフォルトの設定で公開ファイルを作成して処理時間を計測します。
ビルド
Visual Studio 2022のツールバーでビルド > "プロジェクト名"の発行
をクリックします。
ターゲットの設定
表示された画面でビルドする場所を指定します。まずはローカルにビルドするのでフォルダー
を選択して次へ
ボタンをクリックします。
場所の設定
ビルドするファイルを保存するフォルダーを指定して完了
ボタンをクリックします。
ビルドを開始
発行
ボタンをクリックするとビルドが開始します。
結果を確認
ビルドが完了したら結果を確認します。場所の設定
で指定したフォルダーのパスを指定してIIS Expressを起動します。
> cd 'C:\Program Files\IIS Express\'
> .\iisexpress.exe /path:<場所の設定で指定したフォルダーのパス>
IIS Expressが起動したらWebブラウザでhttp://localhost:8080
にアクセスして、シミュレーションを実行できるようになります。
AOTコンパイルを有効にして公開ファイルを作成
次はアプリをAOTコンパイルして高速化をします。
AOTコンパイルを有効化
Visual Studio 2022のソリューションエクスプローラー
でプロジェクトを右クリックしてプロパティ
を選択します。ビルド > 全般 > Ahead-of-time (AOT) compilation
の項目にチェックをして変更を保存します。
ビルドして結果を確認
さきほどと同様にVisual Studio 2022のツールバーでビルド > "プロジェクト名"の発行
をクリックして、ビルド手順を実行していきます。AOTコンパイルを有効にすると、さきほどよりもビルド時間がかかるようになります。
ビルドが完了したらIIS Expressを起動してシミュレーションを実行します。今度はさきほどよりも早く処理が完了して、AOTコンパイルによって高速化されていることが確認できます。
処理時間の比較
処理時間について計測して比較しました。実行したPCのCPUはRyzen 7 3700X、メモリは16GB、OSはWindows 11、WebブラウザにはEdgeを利用しました。
処理時間 | |
---|---|
AOTコンパイルあり | 9.163 秒 |
AOTコンパイルなし | 12.575 秒 |
AOTコンパイルを有効にした方が約1.4倍高速になっていました。冒頭で紹介した記事のプログラムは4.72倍の高速化をしたと記載がありますが、あのプログラムとの大きな違いとして作成したサンプルアプリでは途中経過の状態を表示していて、そのために大きな高速化ができなかった可能性があります。
AOTコンパイルによってどの程度の高速化が実現するかは処理の内容によります。この記事の最後に私が開発したアプリでのパフォーマンスの比較をしましたので、そちらも参考にしてください。
この記事のコードをそのまま.NET MAUI Blazorで動作させて処理時間の計測をした結果をBlazor Advent Calendar 2021の18日目に投稿しました。.NET MAUI Blazorの処理時間は5.306秒で、Blazor WebAssemblyのAOTコンパイルよりも高速に動作しました。AOTコンパイルよりも高速化させる必要があってユーザーがデスクトップアプリをインストールできる場合は、.NET MAUI Blazorに移行することが有力な選択肢になりそうです。
処理時間 | |
---|---|
.NET MAUI Blazor | 5.306 秒 |
Blazor WebAssemlby(AOTコンパイルあり) | 9.163 秒 |
Blazor WebAssemlby(AOTコンパイルなし) | 12.575 秒 |
私はなんとなく.NET MAUI Blazorのほうが動作が速そうだなと思って処理速度を測定して、記事を書いたときは.NET MAUI Blazorの動作が速い理由をうまく言語化できなかったのですが、そんな私のかわりに@jsakamotoさんが解説してくれました。
Blazor Advent Calendar 2021 - 18日目、来ました!
— @jsakamoto (@jsakamoto) December 18, 2021
"Blazor Wasm(AOT) vs NET MAUI Blazor (ライフゲームの処理速度を比較)"https://t.co/5apdvMIyxm #Qiita
なるほど、MAUI も Blazor Server 同様、MSIL は JIT で機械語でOSネイティブに実行されるので、AOT されてても Wasm よりは速いんですね!
Azure Static Web Appsにデプロイ
Blazor WebAssemblyのアプリを公開する際にはWebサーバにURLのルーティングなどの設定をする必要がありますが、Azure Static Web Appsであれば簡単にBlazor WebAssemblyのアプリをデプロイすることができます。また、GitHub ActionsでAOTコンパイルを有効にしたビルドを実行することが可能なので、デプロイ作業も簡単になります。
URLルーティングの定義
Azure Static Web Appsで適切にURLルーティングができるように設定ファイルを作成します。
{
"navigationFallback": {
"rewrite": "/index.html"
}
}
GitHubにリポジトリを作成
GitHubにリポジトリを作成して、作成したアプリをリポジトリにpush
します。
Azure Static Web Appsの設定
Azure PortalからAzure Static Web Appsの設定をします。Azure Static Web AppsはAzure Portalでは静的 Web アプリ
という表記になっているようです。
アプリの作成画面で基本情報を指定
Azure PortalでAzure サービスから静的 Web アプリ
を選択して作成
ボタンをクリックすると作成画面が表示されます。作成画面ではサブスクリプション、リソースグループ、名前を設定します。ホスティングプランには今回はFree
を指定します。
デプロイの詳細でGitHub
を選択してGitHubアカウントでサインイン
ボタンをクリックします。GitHubにログインする画面が開くので認証します。
リポジトリの指定
認証が完了すると以下のような入力画面が表示されるので、GitHubのリポジトリの情報を指定します。
ビルドの詳細を設定
リポジトリの情報が指定されると、同じ画面にビルドの詳細
を設定する入力項目が追加されます。ビルドのプリセット
にはBlazor
を指定します。アプリの場所
はリポジトリでBlazor WebAssemblyのプロジェクトファイルが格納されているフォルダーを指定します。
確認および作成
ボタンをクリックすると作成が開始され、しばらくしたらデプロイが完了しました
と表示されます。
この時点ではGitHub Actionsでビルドが進行していて、まだアプリを動作させることはできません。GitHubのリポジトリのページのActions
でビルドの状態を確認することができます。
ビルドが成功するとWebブラウザでアクセスすることが可能になります。アプリに割り当てられたURLはAzure PortalのAzure Static Web Appsの画面で確認することができます。
GitHub Actionsでタイムアウトが発生した場合の解決
GitHub Actionsでタイムアウトのエラーが発生して、以下のようなエラーメッセージが表示されることがあります。
---End of Oryx build logs---
Oryx has timed out while building app, the current limit is 15 minutes. Failing build.For further information, please visit the Azure Static Web Apps documentation at https://docs.microsoft.com/en-us/azure/static-web-apps/
If you believe this behavior is unexpected, please raise a GitHub issue at https://github.com/azure/static-web-apps/issues/
Exiting
このエラーの解決方法についてはこちらで言及されていました。
.github/workflows
以下にあるYAMLファイルにbuild_timeout_in_minutes
を追加してビルドに必要な時間を指定することでタイムアウトを回避することができます。
参考:実際に開発したアプリでパフォーマンスを比較
最後に、実際に開発したアプリでのAOTコンパイルのパフォーマンスを紹介します。
開発したアプリの概要
私が関係していた大学の研究プロジェクトでは2005年頃からマークシート処理システムを開発して利用してきました。マークシートというと学力テストを思い浮かべる人が多いと思いますが、その他にも学校で実施するアンケート(学校評価)、公共施設やスポーツイベントの来場者アンケート、自治体の住民アンケート調査、東日本大震災の避難所でのヘルスアセスメントなど、多岐にわたって活用されてきました。
2019年にはマークシートを処理するデスクトップアプリをUWPで開発して利用していたのですが、企業や自治体が管理しているPCへのインストールができない場合があるなどの問題がありました。Webアプリのように導入時のトラブルが少なく、デスクトップアプリのように高速に動作する環境を目指して、新しいマークシート処理システム Mark2 をBlazor WebAssemblyで開発しました。すでにスポーツイベントでの来場者アンケートや自治体が実施する住民アンケート調査で早速活用されました。
マークシート処理でAOTコンパイルに関する処理時間の比較
この記事で示したアプリの例ではAOTコンパイルによって約1.4倍の高速化をするという結果でした。Mark2でマークシートを処理した時間は以下のようになり、AOTコンパイルによって 約6倍 の高速化をしています。
処理枚数 | AOTコンパイルあり | AOTコンパイルなし |
---|---|---|
10枚 | 4.644 秒 | 27.379 秒 |
100枚 | 41.708 秒 | 241.446 秒 |
このようにAOTコンパイルによって高速化する程度は処理内容によって大きく変わります。
おわりに
Blazor WebAssemblyのAOTコンパイルによって作成したサンプルアプリを高速化することができました。また、Azure Static Web AppsにBlazor WebAssemblyのアプリをデプロイする手順を示して、最後は実際に開発したアプリでのパフォーマンスからAOTコンパイルの高速化の程度は処理の内容によって大きく変わることを見てきました。
世の中には様々なWebアプリケーションフレームワークがありますが、その中でもBlazor WebAssemblyはユニークなポジションで、.NET 6でAOTコンパイルがサポートされたことによって、その特徴がさらに強化された印象があります。ぜひこのまま進化してほしいと思っています。
Blazor Advent Calendar 2021
Blazor Advent Calendar 2021に参加してBlazor WebAssemblyのAOTコンパイルやAzure Static Web Appsに関連する記事を投稿しましたので、関心のある方はこちらもご覧くださればと思います。
その他にはBlazor WebAssemblyでONNX Runtime Webを使って機械学習のモデルをロードして手書き数字認識をしたり(ONNX Runtime WebはMark2の手書き数字認識でも使っています)、Auth0でユーザー認証した際のローディング画面のカスタマイズについての記事を投稿しました。