21
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Conway's Game of Life

はじめに

.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を以下のように編集します。アプリの動作のために編集するのはこのファイルだけです。

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

AOTコンパイルを有効化

ビルドして結果を確認

さきほどと同様に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さんが解説してくれました。

Azure Static Web Appsにデプロイ

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

URLルーティングの定義

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

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の作成画面

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

リポジトリの指定

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

リポジトリの設定画面

ビルドの詳細を設定

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

ビルドの詳細設定画面

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

この時点ではGitHub Actionsでビルドが進行していて、まだアプリを動作させることはできません。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でユーザー認証した際のローディング画面のカスタマイズについての記事を投稿しました。

21
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?