Blazor WebAssemblyのAOTコンパイルによる高速化とAzure Static Web Appsへのデプロイ

:::note info
この記事が日本マイクロソフト賞 最優秀賞に選ばれました。:tada::tada::tada:
:::

https://blog.qiita.com/adventcalendar-2021-presents-sponsor-winners/

![Conway's Game of Life](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2238166/b23c18e7-33a8-4dfa-dd45-c615197c2ff7.gif)


# はじめに

.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* をシミュレーションするアプリを作成します。

https://ja.wikipedia.org/wiki/%E3%83%A9%E3%82%A4%E3%83%95%E3%82%B2%E3%83%BC%E3%83%A0

以下の記事を参考にして、途中経過やプログレスバーの表示、ボタンクリックの制御、セルの初期配置などのコードを追加しました。

https://blog.elmah.io/ahead-of-time-compilation-for-blazor-wasm/

## アプリの作成

以下のコマンドを実行してAOTコンパイルのために必要なワークロードをインストールします。

```
> dotnet workload install wasm-tools
```

Visual Studio 2022でBlazor WebAssemblyのプロジェクトを作成して、`Pages/Index.razor`を以下のように編集します。アプリの動作のために編集するのはこのファイルだけです。

```csharp: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のツールバーの`デバッグ > デバッグなしで開始`を選択するとアプリが起動して以下のような画面が表示されます。

![起動画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2238166/c7bc45b7-5b8a-453b-6a18-4f4ef47de280.png)

起動した画面で`Start`ボタンをクリックするとシミュレーションが開始して、完了するとプログレスバーの中央に処理時間が表示されます。

![結果の表示画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2238166/b18f52cb-8cc5-b5a1-20e1-0268264390ab.png)

# 公開ファイルを作成

デフォルトの設定で公開ファイルを作成して処理時間を計測します。

## ビルド

Visual Studio 2022のツールバーで`ビルド > "プロジェクト名"の発行`をクリックします。

### ターゲットの設定

表示された画面でビルドする場所を指定します。まずはローカルにビルドするので`フォルダー`を選択して`次へ`ボタンをクリックします。

![ターゲットの設定](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2238166/04bc2726-bf5e-9e28-c489-9bd04a718257.png)

### 場所の設定

ビルドするファイルを保存するフォルダーを指定して`完了`ボタンをクリックします。

![場所の設定](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2238166/c201ff4d-058d-c7a8-3b05-2a6a64056e28.png)

### ビルドを開始

`発行`ボタンをクリックするとビルドが開始します。

![ビルドを開始](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2238166/c1f3a5f9-a2ac-ab3e-f626-9fb5e1cbbafb.png)

## 結果を確認

ビルドが完了したら結果を確認します。`場所の設定`で指定したフォルダーのパスを指定してIIS Expressを起動します。

```powershell
> 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`の項目にチェックをして変更を保存します。

![AOTコンパイルを有効化](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2238166/4fde9427-a3ce-7804-b1d7-38e652c2e606.png)

## ビルドして結果を確認

さきほどと同様に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倍高速になっていました。[冒頭で紹介した記事](https://blog.elmah.io/ahead-of-time-compilation-for-blazor-wasm/)のプログラムは4.72倍の高速化をしたと記載がありますが、あのプログラムとの大きな違いとして作成したサンプルアプリでは途中経過の状態を表示していて、そのために大きな高速化ができなかった可能性があります。

:::note info
AOTコンパイルによってどの程度の高速化が実現するかは処理の内容によります。この記事の最後に私が開発したアプリでのパフォーマンスの比較をしましたので、そちらも参考にしてください。
:::

:::note warn
この記事のコードをそのまま.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 秒 |

https://qiita.com/kaorumori/items/7375d278b6e5cd6851eb

私はなんとなく.NET MAUI Blazorのほうが動作が速そうだなと思って処理速度を測定して、記事を書いたときは.NET MAUI Blazorの動作が速い理由をうまく言語化できなかったのですが、そんな私のかわりに@jsakamotoさんが解説してくれました。

<blockquote class="twitter-tweet" data-conversation="none" data-cards="hidden" data-partner="tweetdeck"><p lang="ja" dir="ltr">Blazor Advent Calendar 2021 - 18日目、来ました!<br>&quot;Blazor Wasm(AOT) vs NET MAUI Blazor (ライフゲームの処理速度を比較)&quot;<a href="https://t.co/5apdvMIyxm">https://t.co/5apdvMIyxm</a> <a href="https://twitter.com/hashtag/Qiita?src=hash&amp;ref_src=twsrc%5Etfw">#Qiita</a> <br>なるほど、MAUI も Blazor Server 同様、MSIL は JIT で機械語でOSネイティブに実行されるので、AOT されてても Wasm よりは速いんですね!</p>&mdash; @jsakamoto (@jsakamoto) <a href="https://twitter.com/jsakamoto/status/1472031788721917954?ref_src=twsrc%5Etfw">December 18, 2021</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# Azure Static Web Appsにデプロイ

Blazor WebAssemblyのアプリを公開する際にはWebサーバにURLのルーティングなどの設定をする必要がありますが、Azure Static Web Appsであれば簡単にBlazor WebAssemblyのアプリをデプロイすることができます。また、GitHub ActionsでAOTコンパイルを有効にしたビルドを実行することが可能なので、デプロイ作業も簡単になります。

## URLルーティングの定義

Azure Static Web Appsで適切にURLルーティングができるように設定ファイルを作成します。

```json:wwwroot/staticwebapp.config.json
{
  "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`を指定します。

![Azure Static Web Appsの作成画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2238166/6e6316cc-7974-ba40-b832-d66ee829660d.png)


デプロイの詳細で`GitHub`を選択して`GitHubアカウントでサインイン`ボタンをクリックします。GitHubにログインする画面が開くので認証します。

### リポジトリの指定

認証が完了すると以下のような入力画面が表示されるので、GitHubのリポジトリの情報を指定します。

![リポジトリの設定画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2238166/21f5b355-bd20-35c6-971e-efa151c361ca.png)

### ビルドの詳細を設定

リポジトリの情報が指定されると、同じ画面に`ビルドの詳細`を設定する入力項目が追加されます。`ビルドのプリセット`には`Blazor`を指定します。`アプリの場所`はリポジトリでBlazor WebAssemblyのプロジェクトファイルが格納されているフォルダーを指定します。

![ビルドの詳細設定画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2238166/10a31064-297d-e80c-456a-a78a2ca6ff30.png)

`確認および作成`ボタンをクリックすると作成が開始され、しばらくしたら`デプロイが完了しました`と表示されます。

この時点ではGitHub Actionsでビルドが進行していて、まだアプリを動作させることはできません。GitHubのリポジトリのページの`Actions`でビルドの状態を確認することができます。

![GitHub Actionsを実行中](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2238166/4c45aa5d-32bb-ff37-0495-9d7496d735a1.png)

ビルドが成功すると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

このエラーの解決方法についてはこちらで言及されていました。

https://github.com/Azure/static-web-apps/issues/113#issuecomment-686585769

`.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 秒 |

:::note info
このように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](https://qiita.com/advent-calendar/2021/blazor)に参加してBlazor WebAssemblyのAOTコンパイルやAzure Static Web Appsに関連する記事を投稿しましたので、関心のある方はこちらもご覧くださればと思います。

https://qiita.com/kaorumori/items/552a2acfddcbec254159

https://qiita.com/kaorumori/items/0689ca7bed4d6b47d3a6

https://qiita.com/kaorumori/items/7375d278b6e5cd6851eb

その他にはBlazor WebAssemblyでONNX Runtime Webを使って機械学習のモデルをロードして手書き数字認識をしたり（ONNX Runtime WebはMark2の手書き数字認識でも使っています）、Auth0でユーザー認証した際のローディング画面のカスタマイズについての記事を投稿しました。

https://qiita.com/kaorumori/items/0fd59d6765bac2bc87f6

https://qiita.com/kaorumori/items/51e7082d0eddff433002