エンジニアとしての市場価値を測りませんか?PR

企業からあなたに合ったオリジナルのスカウトを受け取って、市場価値を測りましょう

2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WebAssembly / WasmAdvent Calendar 2024

Day 15

vite-react-comlink-worker-assemblyscript-webgpu-boilerplateを作った

Last updated at Posted at 2024-12-15

これは何?

image.png

もっとAssemblyScript(WebAssembly)の利用性を広げよう!ということで、GitHub上でvite-react-comlink-worker-assemblyscript-webgpu-boilerplateのプロジェクトを公開しました(名前長っ!)。

これは、その名の通り、Vite+React+Comlink/WebWorker+AssemblyScript+WebGPUを組み合わせたものを開発する際のボイラープレートとなるような、設定ファイルおよびアプリを開発するためのサンプルコード一式です。

以下、この記事で説明をするように、このプロジェクトをビルドすると、Reactアプリとして作られたサンプルコード一式を動作させることができます。具体的には、

  1. JavaScript
  2. JavaScript + WebWorker
  3. AssemblyScript
  4. AssemblyScript + WebWorker
  5. WebGPU Compute Shader

というような5つの実装方式を比較しながら、平均化画像フィルタを例としたデモを動作させることができるアプリになっています。クリックして開いて、実際に動作内容を試してみてください。

  • それぞれの実装は、3x3の行列で隣り合う画素の値の平均化をするという画像処理を行うものになっています(要するに、画像に「ぼかし」処理の一種を施すという内容です)
  • ユーザがスライダーでの操作をすることにより、0回から〜500回までの任意の回数で、画像処理の適用回数を変化させることができるようになっています
  • 画像処理の繰り返し適用中の進捗状況として、現在処理をしている回および処理の経過時間をUI上で表示するようにしています
  • UIスレッドがブロックされてしまう状況を確認するために、画面上部にデジタル時計を常時表示しています

5つの実装のベンチマーク結果

次の表は、このサンプルコードを MacBook Pro 16インチ 2021(Apple M1 Max 64GB)で動作させた結果を示したものです。「富嶽三十六景 神奈川沖浪裏」の1024x706ピクセルの画像に、3x3平均化画像フィルタ処理を、200回の繰り返し回数で施しています。

実際に動作している様子の動画は、この記事の冒頭のXのポストで表示できるので、ご覧ください。

動画内の該当部分 実装 実行時間 UIスレッドのブロック
0:00-0:08 JavaScript 4.52秒 される
0:09-0:15 JavaScript + WebWorker 4.80秒 されない
0:16-0:18 AssemblyScript 1.84秒 される
0:19-0:21 AssemblyScript + WebWorker 1.82秒 されない
0:22-0:23 WebGPU Compute Shader 0.07秒 されない

実装の違いによる実行時間の違い

  • 単純なJavaScriptでの実装が一番遅く、AssemblyScript → WebGPU Compute Shaderの順に実行時間が短くなっています
  • WebWorkerを使うことによる処理のオーバーヘッドは、ほとんど無視できる程度です

UIスレッドのブロックの有無

  • WebWorkerを使わずに画像処理を実行する場合には、UIスレッドがブロックされ、UIが反応しなくなり、ボタンなどが押せなくなります。このとき、画面上部に表示されている時計が止まっている・別ウィンドウで動かしている時計は動き続けている、ということにも注意をしてください
  • WebWorkerを使うことで、UIスレッドのブロックを解消できます。時計が動き続けていて、進捗確認のためのUI(React Material UIのGaugeコンポーネント)の表示内容が更新されていることを確認してください

WebGPUによる実装との比較

  • 動画の最後(0:22〜)は、WebGPU Compute Shaderの実装での処理の場合を示しています。WebGPUによる処理の速さは圧倒的です。なお、WebGPUは、そもそも非同期なAPIとして設計されていることもあり、UIスレッドのブロックが発生している様子は見られません

ベンチマーク結果についての考察

  • 単純な実装では、画像処理などのような重めのタスクを実行すると、その処理が動いている最中はUIスレッドがブロックされて、ユーザビリティを損なう形になってしまうことがあります。こうした状況では、そうしたタスクをUIスレッドからオフロードし、WebWorker側で動作させることができます
    • このとき、WebWorkerを直接使うコードを書くのは、コードがとても煩雑になるので、Comlinkなどのライブラリを使うのが良いでしょう
  • UIスレッドがブロックされてしまう時間を短縮するためには、JavaScript(TypeScript)でのコードをAssemblyScriptでのコードに置き換え、WebAssemblyにコンパイルすることで、より効率的に動作させることができます
    • AssemblyScriptは、CやRustと比べるとJavaScript(TypeScript)に字面がとても近いです。そのため、新規にコードを開発するにしても、既存のコードを変換するにしても、人間や生成AIが作業をする際の手間・ミスがずっと少なくなるでしょう
    • なお、AssemblyScriptを使うとしても、UIスレッド側でなくWebWorker側で動作させることを併せて検討するべきです
  • 現状でのベストプラクティスは、「Comlink/WebWorkerで処理をオフロード」と「TypeScriptで書かれたコードのAssemblyScriptへの移植」の組み合わせである、ということのように思われます
    • このとき、上流(ホスト側)から下流(WebWorker型・AssemblyScript内)の関数を呼び出すだけでなくて、下流(WebWorker側・AssemblyScript内)から上流(ホスト側)の関数をコールバックする機能が、しっかり使えているか・開発をする上で使いやすいしくみになっているか、ということについても、確認をしておくべきです
  • ともあれ、もしもWebGPUを使ってよいような状況であるならば、AssemblyScriptではなくWebGPUのCompute Shaderを使うということを、まず検討するべきところです
    • 特に、今回のような画像処理のタスクでは、AssemblyScript+WebWorkerでの所要時間が1.82秒であったのに対して、WebGPU Compute Shaderでの所要時間はわずか0.07秒という結果でした。WebGPUでの開発は正直ハードルが高いのですが、この結果を見ると、多少の苦労をしてでも、チャレンジしてみたほうがよい気がしてきますね

AssemblyScript+WebWorker版の動作シーケンス

この記事の以下のセクションでは、AssemblyScript+WebWorker版について詳しく解説します。

最上流に位置するReactコンポーネントから、最下流のWebAssemblyで管理されるメモリに至るまでの、多段的なモジュール間で、どのようにメッセージがやりとりされているかを、シーケンス図の形で示します。

図中、次の3つのoptionalなシーケンス部分に注意してください。

  • ユーザが「Start」ボタンを押下:画像idがホストのEventHandlerという浅めの場所に戻されていること
  • 進捗情報の通知:WebAssemblyモジュール側からホスト側へのコールバックが行われて、UIが更新されていること
  • 画像生成の完了:WebAssemblyモジュール側でのメモリ共有による配列のビューの作成、WebAssemblyモジュール側のメモリ上のオブジェクト削除、WebWorkerを超えた画像データの委譲、ホスト側へのPromiseが返されて、生成された画像の表示が行われていること

一見すると、とても面倒くさい構成になっているのですが、実際には、ComlinkやAssemblyScriptのおかげで、直感的に理解しやすい、冗長性の少ないコードでの開発ができるようになります。

設定項目の抜粋

package.jsonの設定

package.jsonに、

package.json
  "type": "module"

を追加します。これは、このプロジェクトで、ES Modulesを使うための設定です。

そのほかの設定の詳細は、package.jsonを参照してください。

vite.config.ts の設定

ビルド環境としては、Viteを使っています。

  • vite-plugin-assemblyscript-asc を使うことで、ViteからAssemblyScriptのソースコードを.wasmファイルにビルドできます。
  • vite-plugin-restart を併用することで、vite-plugin-assemblyscript-ascがホットリロード機能に未対応であることを補います。
  • vite-plugin-comlink を併用し、TypeScriptのコードからComlinkWorker型を利用した開発をするための設定をします。
  • このほか、Reactアプリの開発ならば、@vitejs/plugin-react を組み込むことなどをしています。

これらの設定の詳細は、vite.config.tsを参照してください。

tsconfig.jsonの設定

プロジェクトルートのtsconfig.jsoncompilerOptions以下に、次のものを追加します。

tsconfig.json
  "compilerOptions":{
    // ...略
    "target": "esnext",
    "module": "esnext",
    "lib": ["dom", "esnext", "webworker"],
    "types": ["node", "vite/client", "vite-plugin-comlink/client", "@webgpu/types"],
    // ...略
  },
  • "target": "esnext""module": "esnext"のように揃えて設定をします。"lib"にも"esnext"を追加します。
  • "lib"には、WebWorkerに対応するために、"webworker"を設定します。
  • "types"には、
    • vite.config.tsでの型を解釈できるように "vite/client"を設定します。
    • コード内でComlinkWorkerという型を使えるように、"vite-plugin-comlink/client"を設定します。
    • WebGPUを使う場合は、"@webgpu/types"も追加しておきましょう。

これらの設定の詳細は、tsconfig.jsonを参照してください。

src/as/tsconfig.json の設定

AssemblyScriptのソースコードのファイルは src/as/assembly/**/*.ts というようなパスに作成するのが標準構成です。ここで大事なのが、プロジェクトルートに配置した通常のtsconfig.jsonとは別のものとして、AssemblyScriptのソースコード用のsrc/as/tsconfig.jsonというファイルを作成しておくということです。次のような内容です。

src/as/tsconfig.json
{
  "extends": "../../node_modules/assemblyscript/std/assembly.json",
  "compilerOptions": {
    "lib": ["esnext"],
    "module": "esnext",
    "imports": {
      "env": "env"
    }
  },
  "include": ["./**/*.ts"]
}

"extends"で、node_modules以下のassembly.jsonを拡張している点に注意です。これを書いておくことで、開発時にエディタがi32などのAssemblyScriptの型を未知の型であるとして警告を出さないようになります(ただし、一部のアノテーションや型については更新が追いついていないので、@ts-ignoreのお世話になることでしょう)。
また、"compilerOptions"以下も、それぞれ、コールバック関数の設定などで、必要なものになっています。

これらの設定の詳細は、src/as/tsconfig.jsonを参照してください。

asconfig.jsonの設定

このファイルでは、AssemblyScriptのコンパイラであるascのための設定をします。
この"target"以下において、"outFile".wasmファイルを、
"textFile"".wat"ファイルを出力するための、パスとファイル名を設定します。

今回、これらのファイルでは、./buildディレクトリ以下に出力するよう構成しましょう
.gitignore./buildを加えておきましょう)。

以下は、コンパイルオプションのオプションの部分です。
例によって"bindings": "esm"が重要です。

asconfig.json
  "options": {
    "bindings": "esm",
    "enable": [],
    "exportRuntime": true,
  }

細かい部分は、状況に応じて設定を変えることになります。
もしAssemblyScriptでv128などのSIMD型を用いたコードを書いてみたいならば、
"enable": ["simd"]のように追加しておきましょう。

これらの設定の詳細は、asconfig.jsonを参照してください。

コード

AssemblyScriptで書かれた画像処理のコード

拡張子は.tsで、一見するとTypeScriptのコードなですが、AssemblyScriptのコードです。number型ではなくu32型で「符号なし32ビット整数」を指定しているところなど、微妙にTypeScriptそのものではない点に注意してください。

またここで、クラスASImageObjectに、コンストラクタが作成されているので、このASImageObjectクラスのインスタンスを、AssemblyScript側のモジュールとホスト側との間で受け渡しする際には、コピーのかわりに透過的ではない参照opaque reference counted pointerが渡される、ということも確認してください。

次のように使い分けることになるでしょう。

  • AssemblyScript内でライフサイクルが完了するようなオブジェクト: コピーされるオブジェクト(コンストラクタなしで宣言)
  • AssemblyScript内でライフサイクルが完了しないオブジェクト:opaque reference counted pointerが渡される(コンストラクタありで宣言)

AssemblyScript側で生成して保持しているオブジェクトに対して、ホスト側からの複数回でのメソッド呼び出しを行う形で利用をするような場合には、後者の形で宣言する必要がある、ということです。

ASImageObject.ts
class ASImageObject {
  static nextId: u32 = 0;
  width: u32;
  height: u32;
  // モジュール境界を超えて配列のコピーをやり取りするのはなるべく避けたい
  data: Uint8ClampedArray;
  
  /**
   * ASImageObjectをインスタンス化する
   * @param width 画像の幅
   * @param height 画像の高さ
   */
  constructor(width: u32, height: u32, buffer: ArrayBuffer) { 
    // コンストラクタをつくり、「opaque reference渡し」にする
    this.id = ASImageObject.nextId++;
    this.width = width;
    this.height = height;
    this.data = Uint8ClampedArray.wrap(buffer, 0, width * height * 4); // 1ピクセルあたり4バイト(RGBA)
  }
}

AssemblyScriptがascコンパイルをして、モジュール側からホスト側に公開をできるものは、変数、関数、列挙型だけです。つまり、上のASImageObjectクラス自体は公開できません。そのため、まず、ASImageObjectクラスのオブジェクトを保持するために使う内部的なクラスASImageObjectsをつくります。

このASImageObjectsクラスは、シングルトンのインスタンスで、u32型のidをキーに、ASImageObjectを値とするMapがその実体です。

ASImageObjects.ts
import { ASImageObject } from "./ASImageObject";

class ASImageObjects {
  static instances: Map<u32, ASImageObject> = new Map();
  constructor() {}

/**
 * シングルトンを返す
 * @return 画像管理用オブジェクト
 */
  static getSingleton(): Map<u32, ASImageObject> {
    return ASImageObjects.instances;
  }
}

また、このASImageObjectsで管理されているASImageObjectのインスタンスたちを操作するための関数群一式(createImageObject, getImageObjectPtrLen, getImageObjectWidthHeight, deleteImageObject)を作っていきます。

asImageObjectFunctions.ts
import { ASImageObject } from "./ASImageObject";
import { ASImageObjects } from "./ASImageObjects";

/**
 * ImageObjectをインスタンス化し、マップにセットし、idを返す
 * @param width 画像の幅
 * @param height 画像の高さ
 * @return 画像のid
 */
export function createImageObject(width: u32, height: u32): u32 {
  const id = nextId++;
  const image = new ASImageObject(width, height);
  ASImageObjects.getSingleton().set(id, image);
  return id;
}

/**
 * 指定したidのImageObjectのデータ配列のメモリ上の位置と長さを返す
 * @param id 画像のid
 * @return 位置、長さ
 */
export function getImageObjectPtrLen(id: u32): usize[] {
  const imageObject = ASImageObjects.getSingleton().get(id);
  if (!imageObject) throw new Error("Invalid ImageObject ID");
  return [imageObject.data.dataStart, imageObject.data.length];
}

/**
 * 指定したidのImageObjectの幅と高さを返す
 * @param id 画像のid
 * @return 幅、高さ
 */
export function getImageObjectWidthHeight(id: u32): u32[] {
  const imageObject = ASImageObjects.getSingleton().get(id);
  if (!imageObject) throw new Error("Invalid ImageObject ID");
  return [imageObject.width, imageObject.height];
}

/**
 * 指定したidのImageObjectをメモリ上から削除する
 * @param id 画像のid
 */
export function deleteImageObject(id: u32): void {
  ASImageObjects.getSingleton().delete(id);
}

次に、AssemblyScript版のapplyAverageFilter関数を作成します。

asApplyAverageFilter.ts
import { ASImageObjects } from "./ASImageObjects";

/**
 * 指定したidのImageObjectに平均化フィルタを施す
 * @param id 画像のid
 * @param iteration 繰り返し適用回数
 */
export function applyAverageFilter(id: u32, iteration: i32): void {
  const imageObject = ASImageObjects.getSingleton().get(id);
  if (!imageObject) throw new Error("Invalid ImageObject ID");

  const width: i32 = imageObject.width;
  const height: i32 = imageObject.height;
  const data = imageObject.data;

  const copy = new Uint8ClampedArray(u32(width * height * 4));

  for (let c = 0; c < iteration; c++) {
    postProgressMessage(c, iteration);
    copy.set(data);

    for (let y: i32 = 1; y < height - 1; y++) {
         for (let x = 1; x < width - 1; x++) {
          let r: u32 = 0,
              g: u32 = 0,
              b: u32 = 0,
              a: u32 = 0;

          for (let dy = -1; dy <= 1; dy++) {
            for (let dx = -1; dx <= 1; dx++) {
              const index: u32 = u32((y + dy) * width + (x + dx)) * 4;
              r += unchecked(copy[index]);
              g += unchecked(copy[index + 1]);
              b += unchecked(copy[index + 2]);
              a += unchecked(copy[index + 3]);
            }
          }

          const i: u32 = u32((y * width + x) * 4);
          unchecked((data[i] = u32(r / 9)));
          unchecked((data[i + 1] = u32(g / 9)));
          unchecked((data[i + 2] = u32(b / 9)));
          unchecked((data[i + 3] = u32(a / 9)));
       }
    }
  }
  postProgressMessage(iteration, iteration);
}

// @ts-ignore: decorator
@external("env", "postProgressMessage")
export declare function postProgressMessage(value: u32, maxValue: u32): void;

applyAverageFilter関数では、iterationの回数だけ、画像処理を重ねがけしています。このループの中でpostProgressMessage関数を呼んでいます。postProgressMessage関数は、実際には、AssemblyScriptで実装されているのではなく、ホスト側(WebWorker側)で実装されるものになります。

このことは、最後から2行目で、@externalアノテーションを用いて設定されています。これはつまり、

  「このAssemblyScriptのモジュールにおいて `postProgressMessage`関数を呼び出すときには、
    `"env"`というモジュール名前空間の、`postProgressMessage`関数を呼んでね!」

という意味です。ここでの"env"というのは、AssemblyScriptにおける特別なモジュール名前空間の名称で、ようするにglobalThisのことを指しています(環境変数などのことではありません)。

なお、The AssemblyScript BookのHost bindingsのUsing ESM bindingsのページには、
@external("./otherfile.js", "myFunction")というような表記で、任意のJSファイル内の関数を設定できるように書いてあるのですが、私が試した限りでは、うまく設定できませんでした。

また、現時点では、node_modules/assemblyscript/std/assembly.jsonでチェックされる内容は、実際のAssemblyScriptのコンパイラで指定できる文法よりも古いため、@externalなどのデコレーターが正しく認識されずに「TS1206: Decorators are not valid here.」のエラーが表示されてしまいます。そうした場合には、上の例のように、「// @ts-ignore: decorator」をつけて、エラーの出力を抑制しておきましょう。

このASImageObjectの関数を公開するために、次のようにindex.tsを作成し、exportをしておきます。

index.ts
export {
  createASImageObject,
  getImageObjectPtrLen,
  deleteImageObject,
  getImageObjectWidthHeight,
} from "./asImageObjectFunctions";

export { applyAverageFilter } from "./asApplyAverageFilter";

関数のみをexportし、クラスはexportしないようにしてください。
クラスをexportする内容でascを実行すると、次のようなエラーが発生します。

 WARNING AS235: Only variables, functions and enums become WebAssembly module exports.
    :
  3 │ export class ASImageObjects {
    │              ~~~~~~~~~~~~~
    └─ in ASImageObjects.ts(3,14)

つまり、ascでexportできるのは、変数、関数、列挙型だけである、ということなのです。以前のバージョンのAssemblyScriptでは、クラスのエクスポートについても可能とされていたのですが、開発が進められるうちに、いろいろな複雑性や矛盾が生じてしまい、現在のバージョンではエクスポート不可能なように仕様が変更された、という経緯があるようです。

Comlink経由で実行されるWebWorkerのコード

AssemblyScript側からコールバックされる関数の型です。

ProgressMonitor.ts
export type ProgressMonitor = (params: {
  /**
  * @param value 進捗状況を表す値
  */
  value: number;
  /**
  * @param valueMax 進捗状況を表す値の最大値、valueがこの値になったら終了
  */
  valueMax: number;
}) => Promise<void>;

ImageProcessor型を定義します。
この型のJavaScript版の実装をしたものがJSImageProcessor、AssemblyScript版の実装をしたものがASImageProcessorといった感じになります。

ImageProcessor.ts
import { ProgressMonitor } from "./ProgressMonitor";

export type ImageProcessor = {
  /**
  * AssemblyScript側モジュールにおいて、ImageObjectインスタンスを作成し初期化する。
  * AssemblyScript側モジュール上のImageObjectインスタンスのidを、このプロセッサのidとして設定する。
  * @param width 幅
  * @param height 高さ
  * @param buffer 画像データのバッファ
  */
  initialise: (width: number, height: number, buffer: ArrayBuffer) => Promise<void>;

  /**
  * 指定したidのImageObjectに平均化フィルタを施す
  * @param id 画像のid
  * @param iteration 繰り返し適用回数
  */
  applyAverageFilter: (iteration: number, options: {
        isWorker?: boolean;
=    }, progressMonitor: ProgressMonitor) => Promise<void>;

   /**
   * WebWorker側から呼び出し元に画像データの所有権を委譲する
   * @return AssemblyScript側と共有した画像をもとに配列のビューを作成し、画像の所有権を委譲する
   */    
  transfer: () => Promise<Uint8ClampedArray>;
}

こちらがそのASImageProcessorです。
冒頭近く、import * as wasm from "../../../build/vite-react-comlink-worker-assemblyscript-boilerplate/assets";の行部分に、注意してください。

ASImageProcessor.ts
import * as Comlink from "comlink";

import * as wasm from "../../../build/vite-react-comlink-worker-assemblyscript-boilerplate/assets";

import { ProgressMonitor } from "../ProgressMonitor";
import { ImageProcessor } from "../ImageProcessor";

/**
* このオブジェクトは、AssemblyScript側モジュール上のImageObjectインスタンスひとつに紐づけて利用する。
*/
export class ASImageProcessor implements ImageProcessor {
  public id!: number;

  constructor() {
    super();
  }

/**
  * AssemblyScript側モジュールにおいて、ImageObjectインスタンスを作成し初期化する。
  * AssemblyScript側モジュール上のImageObjectインスタンスのidを、このプロセッサのidとして設定する。
  * @param width 幅
  * @param height 高さ
  * @param buffer 画像データのバッファ
  */
  public async initialize(
    width: number,
    height: number,
    buffer: ArrayBuffer,
  ): Promise<void> {
    this.id = wasm.createImageObject(width, height);
    const data = new Uint8ClampedArray(buffer);
    wasm.setImageObjectData(this.id, data);
  }

 /**
   * AssemblyScript側モジュールにおいて、ImageObjectインスタンスに平均化フィルタを適用する
   * @param iteration 繰り返し適用回数
   * @param progressMonitor 進捗状況を通知するためのコールバック関数
   */
  public async applyAverageFilter(
    iteration: number,
    progressMonitor: ProgressMonitor,
  ): Promise<void> {
    (globalThis as any).postProgressMessage = function postProgressMessage(
      value: number,
      valueMax: number,
    ): void {
      progressMonitor({ value, valueMax });
    };
    wasm.applyAverageFilter(this.id, options.simd || false, iteration);
  }

/**
 * WebWorker側から呼び出し元に画像データの所有権を委譲する
 * @return AssemblyScript側と共有した画像をもとに配列のビューを作成し、画像の所有権を委譲する
 */
  public async transfer(): Promise<Uint8ClampedArray> {
    const [ptr, len] = wasm.getImageObjectPtrLen(this.id);
    wasm.deleteImageObject(this.id);
    return new Uint8ClampedArray(wasm.memory.buffer, ptr, len);
  }

/**
 * このプロセッサの利用を終了する
 */
  public close(): void {
    self.close();
  }
}

// ASImageProcessorのオブジェクトをWebWorkerで共有される実体となるように登録する
Comlink.expose(new ASImageProcessor());

(globalThis as any).postProgressMessage = functionというように、globalThisをany型扱いにして、強引に関数をセットしているところがポイントです。ここが、先に示したAssemblyScriptのコードASImageObject.tsの末尾の2行で、@externalというアノテーションでコールバック関数を設定した内容に対応しています。

また、ここで示したASImageProcessor.tsの最終行、Comlink.expose(new ASImageProcessor());で、WebWorkerとしてホスト側に共有するものは、ASImageProcessorのインスタンスであるというように設定しています(つまり、このWebアプリにおけるそれぞれのWebWorkerは、それぞれの種類ごとに一回しか使われないものなので、オブジェクトを使い捨てにすることを前提にしたコードとしています)。

Reactコンポーネント側のコード

WebWorkerのコードを相対URL経由で読み込み、ComlinkWorkerのインスタンスを作成します。new URL()をするときの第二引数に import.meta.url をつけること、new ComlinkWorker()をするときの第二引数に{type: "module"}をつけることがポイントです。

app.tsxの一部
const asImageWorkerProcessor = new ComlinkWorker<ASImageProcessor>(
  new URL("./as/ASImageProcessor", import.meta.url),
  {
    type: "module",
  },
);
ImageFilterBenchmark.tsxの一部
import { proxy } from "comlink";

//...略...

export const ImageFilterBenchmark = ({
  title,
  processor,
  iteration,
  options,
}: {
  title: string;
  processor: ImageProcessor;
  iteration: number;
  options?: {
    isWorker?: boolean;
    simd?: boolean;
  };
}) => {
  const preloadImageObject = usePreloadImageObject();
  const [targetImageObject, setTargetImageObject] = useState<JSImageObject>();
  const [isStarted, setStarted] = useState<boolean>(false);
  const [progress, setProgress] = useState<number>(0);
  const [isFinished, setFinished] = useState<boolean>(false);
  const [elapsedTime, setElapsedTime] = useState<number>(0);

  const [, incrementActiveCount] = useAtom(incrementActiveCountAtom);
  const [, decrementActiveCount] = useAtom(decrementActiveCountAtom);

  // 進捗状況の更新(終了処理を含む)
  const updateProgress = useCallback(
    (width: number, height: number, startedTime: number) =>
      ({ value }: { value: number }): Promise<void> => {
        setProgress(value);
        setElapsedTime(performance.now() - startedTime);
        if (value === iteration) {
          processor.transfer().then((array) => {
            const updatedImageObject = new JSImageObject(
              width,
              height,
              new Uint8ClampedArray(array),
            );
            setTargetImageObject(updatedImageObject);
            setFinished(true);
            decrementActiveCount();
          });
        }
        return Promise.resolve();
      },
    [iteration, processor, decrementActiveCount],
  );

  // 画像処理プロセッサでの処理の実施
  const startProcessor = useCallback(() => {
    incrementActiveCount();
    setStarted(true);
    setElapsedTime(0);
    const startedTime = performance.now();
    const [width, height] = [
      preloadImageObject.width,
      preloadImageObject.height,
    ];
    processor.initialize(width, height, preloadImageObject.getData().slice(0));
    processor.applyAverageFilter(
      iteration,
      options || {},
      options?.isWorker
        ? proxy(updateProgress(width, height, startedTime))
        : updateProgress(width, height, startedTime),
    );
  }, [processor, iteration, options, incrementActiveCount, preloadImageObject]);

  const reset = () => {
    setStarted(false);
    setProgress(0);
    setFinished(false);
    setElapsedTime(0);
    setTargetImageObject(undefined);
  };

  return (
    <>
      <Stack direction="column" marginBottom={5}>
        <Stack alignItems="center" spacing={0}>
          {!isStarted ? (
            <>
              <Typography>{title}</Typography>
              <Button variant="outlined" onClick={startProcessor}>
                Start
              </Button>
            </>
          ) : !isFinished ? (
            <>
              <ProgressMeter value={progress} valueMax={iteration} />
              <ImageCaption>{(elapsedTime / 1000).toFixed(2)} sec</ImageCaption>
            </>
          ) : (
            <>
              <ImageViewer
                scale={0.3}
                imageObject={
                  targetImageObject ? targetImageObject : preloadImageObject
                }
              />
              <LinearProgress value={100} variant={"determinate"} />
              <Stack direction="row" spacing={2}>
                <Typography>{title}</Typography>
                <ImageCaption>
                  {(elapsedTime / 1000).toFixed(2)} sec
                </ImageCaption>
                <Button variant="outlined" onClick={reset} size={"small"}>
                  Reset
                </Button>
              </Stack>
            </>
          )}
        </Stack>
      </Stack>
    </>
  );
};

ざっくりとコードの主要部分を抜粋しました。細かく説明をするとキリがないので、このくらいにしておきます...すみません。

(あとで少し書き足すかも)

動かし方

git clone https://github.com/kubohiroya/vite-react-comlink-worker-assemblyscript-webgpu-boilerplate.git
cd vite-react-comlink-worker-assemblyscript-webgpu-boilerplate
pnpm install
pnpm run build
pnpm run dev

ここまでを実行して、http://locahost:4200/ をアクセスすると、サンプルアプリを利用できます。

さらに、

pnpm run preview

とすると、ビルド済みの内容をプレビューできます。

AssemblyScript+WebWorkerへの移行ガイド

このボイラープレートを用いた移行のためのガイドを示します。
次の示すようなフローで、より高パフォーマンスな実装へと移行することを検討してみてはどうでしょうか。

(1) JavaScript版の実装

  • src/benchmark/js/JSImageProcessor.ts
  • 開発の出発点としての、最もシンプルな実装です
  • ImageObjectオブジェクトのメソッドを呼ぶためのラッパー的な実装になっています
  • この時点では、ソースコード冒頭のimport * as Comlink from "comlink";や、末尾のComlink.expose(new JSImageProcessor());は追記していなくてOKです
  • このコードをもとに、(2-a)のための追記をした後でも、(1)の用途でのコードとしてそのまま使えます
  • このクラスを使うときには、単純にnewしてから使います:app.tsxの31行目あたり

(2-a) JavaScript + WebWorker版の実装

  • src/benchmark/js/JSImageProcessor.ts
  • (1)のコードと、ほぼ同じファイル内容を、そのまま使えます。ただし、ComlinkWorker経由での読み込みに対応させるため、ソースコード冒頭にimport * as Comlink from "comlink";を、末尾にComlink.expose(new JSImageProcessor());をつけるように修正します
  • このクラスを使うときには、(1)における単純なnewをする方法を修正して、new ComlinkWorker経由で読み込むようにします:app.tsxの34行目あたり

(2-b) AssemblyScript版の実装

  • src/benchmark/as/ASImageProcessor.ts
  • (1)のコードをもとに、JavaScript(TypeScript)の関数ではなく、AssemblyScript側の関数を呼ぶように修正したものです
  • AssemblyScriptをうまく動かせるかどうかを、ひとまずWebWorkerを介さない実装として検証するためのバージョンです
    • ASImageProcessor.tsの3行目import * as wasm from "../../../build/vite-react-comlink-worker-assemblyscript-boilerplate/assets";という部分で、AssemblyScriptのwasmファイルをvite-plugin-assemblyscript-ascの支援のもとで読み込みしています
  • この時点では、ソースコード冒頭のimport * as Comlink from "comlink";や、末尾のComlink.expose(new ASImageProcessor());はなくてOKです
  • このクラスは単純にnewしてから使います:app.tsxの32行目あたり

(3) AssemblyScript +WebWorker版の実装

  • src/benchmark/as/ASImageProcessor.ts
  • AssemblyScriptを、Comlink経由でWebWorkerを介して動作させる、最終形にあたるバージョンです
  • (2-b)のコードと、ほぼ同じファイル内容を、そのまま使えます。ただし、この時点では、ソースコード冒頭のimport * as Comlink from "comlink";や、末尾のComlink.expose(new ASImageProcessor());をつけています
  • このクラスを使うときには、(2-b)における使い方を修正して、new ComlinkWorker経由で読み込むようにします:app.tsxの42行目あたり

(4) おまけ:WebGPU Compute Shader版の実装

どうせならばWebGPUまで実装を進めましょう!
ChatGPTに「このTypeScriptのコードをWGSLで書き直して、WebGPUで実行できるようにしたものを一式を提供してください。」とお願いすれば、おおむね期待通りのコードを得られるだろうと思います。

あわせて読みたい

本記事と類似の内容として、2018年12月に@3846masa(Masahiro Miyashiro)さんによって書かれた、「Comlink + Rust で言語とスレッドの垣根を越えた WebAssembly 開発」があります。こちらには、Webpack+Rust/WebAssembly+Comlink/WebWorkerという構成でZIP展開アプリの開発をした例が書かれています。

本記事は、2024年版の構成として、WebpackをViteに、RustをAssemblyScriptに置き換えた形となっています。また本記事は、一般的な画像処理を行う内容を例とし、進捗表示のためのコールバック関数を使う方法の解説を加えるなど、実用上でも重要になるようなポイントをいろいろ盛り込みました。Vite+Comlink+AssemblyScriptの組み合わせが、スッキリとコードが書け、大幅な性能の向上が期待でき、業務での開発に導入できるようなレベルにまで成熟してきていると言ってもよいような段階にあることを、実感してもらえることだろうと思います。

おわりに

本記事により、Webアプリの開発者ならば誰もが経験する「処理が遅くてUIスレッドがブロックしてしまう」という悩みについて、WebWorkerを導入する→WebAssemblyを導入する→WebGPUを導入する、という解決策があること示しました。

必要に応じて、本記事で紹介したボイラープレートを導入しながら、

  • (ViteでReactを開発する場合に)
  • Comlink経由で重い処理内容をオフロードし、
  • さらに別スレッドでAssemblyScriptでの処理するようにし、
  • その処理の途中や、処理が終わったタイミングで非同期に通知をしてもらう

という流れに載せて改修を進めればよい、ということです。

このボイラープレートで利用している各種技術の組み合わせには、設定ミスをしがちなポイントが、非常に細かいところで、とてもたくさんあります。それぞれの技術の組み合わせ方や、設定の仕方などを、具体的に参照できるものとして提示しようと考えました。さらには、それぞれの実装方式を、同一の処理結果を得られるものとして、共通のインターフェイスでの、差分的なコード群として開発しました。これら複数種類の実装それぞれをReactコンポーネントとして、Webブラウザの画面上で並べて動かして比べられるようにしました。

これらが、どなたかのお役に立つようであれば、幸いです。

追記

それにしても、あらためて、ベンチマークの結果を見ると、WebAssemblyに向いている処理と、WebGPU Compute Shaderに向いている処理との、それぞれがあるのだということがわかります。ユーザの誰もがWebGPU対応ブラウザを使えるわけではないからこそ、WebAssemblyが必要なのでしょうが、WebGPU Compute Shaderを使って解決できる内容については、なるべくならばWebGPUを使ったほうがいい、ということが言えそうですね。

2
0
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

Qiita Advent Calendar is held!

Qiita Advent Calendar is an article posting event where you post articles by filling a calendar 🎅

Some calendars come with gifts and some gifts are drawn from all calendars 👀

Please tie the article to your calendar and let's enjoy Christmas together!

2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?