🧭

TypeScriptのサブセット的AssemblyScriptで作るWebAssemblyの例と注意点

2024/03/23に公開

要約

WebAssemblyのプログラムを開発するための言語の一つとして、TypeScriptのサブセットのような言語「AssemblyScript」が現在進化中です。異なる言語間でのBinding (引数,結果等の連携)は面倒な場合が多いのですが、AssemblyScriptJavaScript/HTMLとのデータ連携はimport文だけでよいので非常に簡単です。JavaScriptやTypeScriptに馴染んでいる方には始めやすい言語ですので、今後の進化に期待したい技術です。

この記事では、AssebmlyScriptの入門のための簡単な例、本記事を書いている現時点(2024年3月)でのCompileオプションの注意点など、以下の内容をこの記事で解説しています。

  1. 整数を渡して整数を取得する簡単な例と、コンパイルの注意点
  2. 文字列を渡して文字列を取得する簡単な例と、コンパイルの注意点
  3. WebAssemblyモジュールを本物の物理Webサーバーに移行する際の注意点
  4. 実際のJavaScriptアプリをAssemblyScriptに書き直した例
  5. AssemblyScriptの実行速度
  6. まとめ

AssemblyScriptの公式サイトには、AssemblyScriptを既に実用運用している事例も複数ありました。この記事を書いている現在、AssemblyScriptはまだVersion 0.27.25ですので、足りないと感じる点もありますが、成長中を暖かく見守りつつ使える部分は使ってみてください。

参考資料

  • フロントエンド向けWebAssembly入門
    WebAssemblyの高速化の原理、C/C++のWebAssembly化のEmscriptenを使用したダウンロードして体験できるコードと解説を詳しく書いています。AI機械学習に応用したデモも体験できて、WebAssemblyが実践的に勉強できました。

フロントエンド向けWebAssembly入門

  • The Assembly Script Book (公式ホームページ)
    AssemblyScriptの公式解説です。FAQ、言語のデータ型、文法、コンパイルオプション、標準ライブラリなどを記載しています。文法解説はだいぶ省略した書き方をしていますので、TypeScriptやJavaScriptの文法と照らし合わせながら読む必要があります。

https://www.assemblyscript.org/introduction.html

1.整数を渡して整数を取得する簡単な例

1.1. AssemblyScriptコード例

まずはAssemblyScriptの雰囲気をつかむために、簡単なソースコードを見てみましょう。

以下はJavaScriptから整数をAssemblyScriptに渡し、計算結果の整数をJvaScriptに返す例です。
compute()関数は「指定されたxの階乗+1」を計算します。
例えばcompute(4)の結果は(4 x 3 x 2 x 1) + 1 = 25になります。

先頭にexport がついている関数compute()が、AssemblyScriptからJavaScriptにexportされる関数(JavaScriptから見たらimportされる関数)です。exportされた関数だけがJavaScriptから利用可能です。
拡張子はTypeScriptと同じで「.ts」です。

simple.ts
//ファイル名:  simple.ts 
/**
 * 指定されたxの階乗+1を計算する。
 * 例: compute(4) = 4! + 1 = (4 x 3 x 2 x 1) + 1 = 25
 * @param x 
 * @returns 
 */
export function compute(x : i32): i32 {
    const res:i32 = factorial(x) + 1;
    return res;
}

/**
 * 階乗を計算。 例: functional(4) = 4! = 4 x 3 x 2 x 1 = 24
 * @param x 
 * @returns 
 */
function factorial(x : i32): i32 {
    let res:i32 = x;
    while (--x > 1) {
        res = res * x;
    }
    return res;
}

ここでは例として「simple.ts」というファイルの中に関数を書いています。1つのファイルの中に複数の関数を書いて、複数の関数をexportすることができます。拡張子はTypeScriptと同じ「.ts」にしないとコンパイルできません。

AssemblyScriptの文法を学ぶための参考情報は参考資料で記載した「The Assembly Script Book」をご覧ください。まだ成長中の言語なので、文法説明はかなり簡略化されています。
https://www.assemblyscript.org/introduction.html

AssemblyScriptはTypeScriptに良く似ていいますが、データ型は非常に細かく指定でき、必要最低限のデータ量に抑えることができます。i32は 「32bitのinteger」の意味です。このコードの例ではi32をi64「64bit integer」にしても動きます。TypeScript風に「number」と書くこともでき、i64と同じに扱われますが、TypeScriptとの違いはi64は整数であり不動小数点は扱えない点です。

1.2. AssemblyScriptのコンパイル

1.2.1. AssemblyScriptの導入

AssemblyScriptは npmコマンドで導入されます。
グローバルに導入する場合のコマンドは以下の通りです。

npm init
npm install -g assemblyscript

カレント・ディレクトリーにも導入もできます。

npm init
npm install --save-dev assemblyscript

1.2.1.フォルダー構成

プロジェクト毎に標準のフォルダー構成を作成するため、プロジェクトを作りたいカレントディレクトリー上で以下のコマンドを打ちます。

npx asint . 

このコマンドで作成される標準のフォルダー構成は以下の通りです。

.                        : プロジェクトのルートフォルダー
├── asconfig.json        : Compile Optionを設定。この記事では使いません
├── assembly             : .tsソースファイルを保管するフォルダー
│   ├── index.ts   
│   └── tsconfig.json
├── build                : コンパイルの出力結果を保管
├── index.html 
├── package-lock.json
├── package.json
└── tests
    └── index.js

「asconfig.json」ファイルを使用するとデバッグ用とリリース用のコンパイルコマンドのオプション、生成するファイルのパスと名前を指定しておくことができて、コンパイルコマンドが簡単になります。本記事では、各々のコンパイルオプションを明示してご説明できるように、asconfig.jsonを使わずにご説明します。asconfig.jsonの使い方は以下の「Configuration file」をご覧ください。

https://www.assemblyscript.org/compiler.html#configuration-file

本記事では説明を簡単にするために、以下のフォルダーだけを使用してご説明します。
プロジェクトのルートフォルダー をカレントディレクトリにしている状態でご説明します。
htmlはここに保管します。コンパイル・コマンド, Node.jsのローカルサーバーはこのプロジェクトのルートフォルダー上で起動させます。

.         : プロジェクトのルートフォルダー 
├─assembly    : .tsソースファイルを保管するフォルダー
├─build      : コンパイルの出力結果を保管
└─js             :自分で制作するjavascriptを保管

1.2.2. AssemblyScriptのコンパイル

コンパイルコマンドの一般形は以下の通りです。

asc  ソースコード -o  出力先 -b esm

以下はassemblyフォルダー下にあるsimple.tsをコンパイルするコマンドの例です。
(本記事ではWindowsでこの例を実行していますので、File separatorは全てバックスラッシュ になっています。)

asc assembly\simple.ts -o build\simple.wasm  -b esm

コンパイルコマンドの詳細は以下のAssemblyScriptの公式ホームページ上の「The AssemblyScript Book」の「Using Compiler」をご覧ください。

https://www.assemblyscript.org/compiler.html#compiler-options

AssemblyScriptのソースファイル「simple.ts」をコンパイルすると、コマンドで指定したbuildフォルダーの直下に以下の3個のファイルを生成します。

ファイル名 説明
simple.wasm コンパイルされたWebAssemblyモジュール
simple.js Binding(データ連携)のためのJavaScriptグルーコード
simple.d.ts 型定義ファイル

1.2.3. コンパイルオプションとBind用グルーコード(simple.js)

コマンド・オプション「--bidings esm 」略して「-b esm」は、JavaScriptとAssemblyScriptの間のBinding(データ連携)のために必要なコンパイルです。これを指定するとBindingに必須なJavaScriptのグルーコード(接着用のコード)を生成します。

「ESM」は「ECMASCript Modules」、「ES Modules」の略で、ご存じのようにJavaScriptを分割してimport文で読み込めるモジュール化したものです。このオプションによってBindingのためのJavaScriptのコードを生成し、JavaScriptからimport文を書くだけでデータ連携ができるようになります。
公式ページの解説は以下の「Using ESM bindings」をご覧ください。型定義ファイルについても、下記をご覧ください。

https://www.assemblyscript.org/compiler.html#host-bindings

上記の「-b esm」オプションを指定してコンパイルすると、下記のようなJavascriptで書かれたBinding用グルーコードが指定されたフォルダー(build)下に、WASMモジュールと一緒に「simple.js」ファイルとして生成されます。

ファイル「simple.js」

simple.js
async function instantiate(module, imports = {}) {
  const { exports } = await WebAssembly.instantiate(module, imports);
  return exports;
}
export const {
  memory,
  compute,
} = await (async url => instantiate(
  await (async () => {
    try { return await globalThis.WebAssembly.compileStreaming(globalThis.fetch(url)); }
    catch { return globalThis.WebAssembly.compile(await (await import("node:fs/promises")).readFile(url)); }
  })(), {
  }
))(new URL("simple.wasm", import.meta.url));

コンパイルで生成されたWebAssemblyの実行モジュール「simple.wasm」と、上記の「simple.js」は同じフォルダーの置いたままにしておいてください。Binding用グルーコード「simple.js」を移動させるとのWebAssemblyモジュール「simple.wasm」への相対位置が変わるので、パスを書き直す必要があります。

上記「simple.js」の上から5~8行目だけ取り出したのが以下のコードですが、JavaScriptから使用する関数と、データ連携のための領域を宣言しています。

export const {
  memory,
  compute,
} =
  • memory : データ連携のためのデータ領域(変数の個数、変数のデータ)です。グルーコードが使用しますが、JavaScriptのアプリから直接使用することはありません。
  • compute : AssemblyScriptからexportされた関数で、これをJavaScriptから使用することがでいます。simple.tsの中のexportしていないfactorial()関数は、ここに載ってきません。

1.2.4. 自動生成される型定義ファイル (simple.d.ts)

コンパイルのBinding オプションで生成される最後の3個目のファイルは型定義ファイルです。データ連携するmemoryとcomputeのJavaScriptから見たデータ型をdeclare文の形式で記述したものです。AssemblyScriptでi32で定義した型がJavaScript用にnumberに置き換えられています。

simple.d.ts
/** Exported memory */
export declare const memory: WebAssembly.Memory;
/**
 * assembly/simple/compute
 * @param x `i32`
 * @returns `i32`
 */
export declare function compute(x: number): number;

1.3. 準備したJavaScript / HTML

1.3.1. JavaScript

実行のために手で作成したHTMLから使用するjavascriptが以下です。
最初のimport文によって、simple.wasmを起動するBinding用グルーコードをimportし、compute関数を利用可能にします。

asmscr_simple.html
import { compute } from "../build/simple.js"; //Binding用jsをimport

const inputNum = document.querySelector("#inp");
const submitButton = document.querySelector("#submit");
const res = document.querySelector("#res");

//ボタンが押された時に
submitButton.addEventListener("click", () => {
  const x = parseInt(inp.value, 10);
  if (isNaN(x)) {
    result.innerText = "数字を入力してください";
  }else{
    const ret = compute(x); //AssemblyScriptの関数を呼び出します
    res.innerText = ret.toString();
  }
});

1.3.2. HTML

以下のHTMLファイル「asmscr_simple.html」がアプリ起動用HTMLです。

asmscr_simple.html
<!DOCTYPE html>
<html lang="lang=ja">
<head>
  <meta charset="utf-8">
  <script type="module" src="./js/apl_simple.js"></script>
  <title>AssemblyScript Demo1 - Simple</title>
</head>

<body>
  <h1>AssemblyScript Demo1 - Simple</h1>
  <p>AssemblyScriptの技術デモ1</p>
  <p>指定した値の階乗 + 1を計算します。</p>
  <p>例: 入力=4 → 結果 = 4! + 1 = (4 x 3 x 2 x 1) + 1 = 25</p>
  <p>1~12の入力値に対して計算します。13以上は桁あふれで正しく計算できません</p>
  <div>
    <p>入力 = 
      <input id="inp" type="text" value="0" maxlength="4">
      <button id="submit">計算を実行</button>
    </p>
    <P>結果 = <span id="res"></span></P>
  </div>
</body>
</html>

1.4. 実行

導入済みのNode.jsのローカルサーバーを「npm start」コマンドで起動して、前述の「asmscr_simple.html」を表示します。ファイル名を事前に「index.js」にrenameしておいても良いです。

以下はHML画面で、入力に「7」を設定し、「計算を実行」を押して、計算し終わった結果「5041」を表示している画面です。(7!+1 = (7 * 6 * 5 * 4 * 3 * 2 * 1) + 1 = 5041)
デモ1の実行画面

  • このアプリを筆者のHomePageのWebサーバーに導入したものが下記です。HTMLにはサイトの共通ヘッダーをつけています。

https://tonbiwing.com/ep/ref/tech/asdemo/asmscr_simple.html

2.文字列を渡して文字列を取得する簡単な例と、コンパイルの注意点

2.1. 例の説明

次の例では、JavaScriptから1個の文字列の引数をAssemblyScriptの関数に渡し、結果を文字列の配列(要素数は可変)で返します。
AssemblyScriptはコンパイル・オプションによってGC (Garbage Collection)ができると公式ドキュメントに書いてありますが、GCはImplementation phaseであるとも書いてあります。GCが起きないまでもオブジェクトを動的に生成し、動的にメモリを確保させて動作するのか検証してみました。AssemblyScriptの標準ライブラリーにあるStringクラスとMapクラスを明示的に使い、String配列も動的に作成することでArrayクラスも暗黙的に使い、オブジェクトを動的に生成する例です。

アプリの内容

現代の関東地方の6つの県の名前と「東京都」のいづれかを指定すると、奈良時代から江戸時代まで、何という名前の国だったのかを表示するものです。例えば茨木県は昔は常陸国(ひたちのくに)でした。千葉県は下総国(しもうさのくに)、上総国(かずさのくに)、安房国(あわのくに)の三つに分かれていました。東京都(当時は江戸)は武蔵国(むさしのくに)の一部でした。

正確には国と県の境界は同じではありませんが、AssemblyScriptには何の関係もありませんので、どうぞ細かいことは気にしないでください。(気になる方は、以下の江戸時代の地図をご覧ください。廃藩置県によって千葉県と茨城県の間の境界、神奈川県の境界が昔の国境とは異なっています。千葉県は国が統合され、武蔵国は分割されています。)
https://tonbiwing.com/ep/dzi/dzi-jpn00-jpn.html

アプリの実行画面

デモ2実行画面

以下がファイル「kuni.ts」に記述したソースコードです。検索した結果の昔の国名の文字列を、"+"の箇所で分解して要素数可変の文字列配列にして返しています。

kuni.ts
/**
 * 現在の関東地方の都、県を引数で渡し、江戸時代までの国の名前を文字列配列で返す。
 * @param ken 現代の県、都の名前
 * @return 国の名前の配列(要素数は可変)
 */
export function kenToKuni(ken:string): string[] {
    const map = new Map<string, string>();
    map.set("東京都","武蔵国");
    map.set("埼玉県","武蔵国");
    map.set("千葉県","下総国+上総国+安房国");
    map.set("茨木県","常陸国");
    map.set("栃木県","下野国");
    map.set("群馬県","上野国");
    map.set("神奈川県","武蔵国+相模国");

    if (map.has(ken)) {
        const allKuni = map.get(ken);
        const res: string[] =  allKuni.split("+");
        return res;
    } else {
        const notfound: string[] = ["#関東地方には存在しません#"];
        return notfound;
    }
}

ソースコードで使用している標準ライブラリーのクラスはStringとMapですが、配列処理にArrayも使用されています。

標準ライブラリー

ちなみに公式ページには標準ライブラリーとして以下の18個が記載されています。
Globals, Array, ArrayBuffer, console, crypto, DataView, Date, Error, Heap, Math, Map, Number, process, Set, StaticArray, String, Symnbol, TypedArray

公式ページの以下の「Using the language」の画面の左側のメニューに「Standard library」が掲載されています。Libraryの一覧をまとめたページは無くて、個々のLibraryのページを見る必要があります。下記の「「Using the language」の「Concepts」の2つ下のメニューから始まります。
標準ライブラリーのメニュー

https://www.assemblyscript.org/concepts.html

2.2. コンパイルオプションの注意点

String型、配列、オブジェクトなどの、「reference-types」即ち実際のデータを置いた実体を「参照するデータ型」を使ったり、「gc」Garbage Collectionが必要な場合は、コンパイルオプションとしてを以下を指定するように公式ドキュメントに書いてあります。(正確には、defaultではdisableされているので、これを指定してenableにしろと書いてあります。)

--enable

以下の公式ドキュメントの「Compiler options」の「Features」の「--enable」を参照してください。

https://www.assemblyscript.org/compiler.html#compiler-options

何のデータ型が「referece-types」に該当するのかは、Using the languageのTypesに掲載されています。

https://www.assemblyscript.org/types.html

ところが、--enable をつけても、つけなくても生成されるコードは同じで、-enable無しでも動いてしまいました。
とても小さいアプリなので、Objectを生成してもメモリを再利用する必要が出る前に終わってしまうため、Garbage Collectionは発生していないでしょう。一方「参照するデータ型」や動的なメモリ領域は確保は必要なのですが、--enable無しで正常に動作してしました。
フォルダー構成を自動的に生成する「npx asint . 」コマンドが自動生成するコンパイル・オプションを設定した「asconfig.json」にも「enable」は指定されていません。

一連の状況証拠からすると、enableがdefaultに変わっており、文書更新が追い付いていないのでしょう。成長中の言語なのでおおらかな気持ちで見守りましょう。
念のために--enableをつけたコンパイルコマンドは以下になります。「--enable」をつけなくても、おそらく大丈夫です。

コンパイルコマンド一般形

asc  ソースコード -o  出力先 -b esm  --enable

この例でのコンパイル・コマンド(Windowsで実行した例です)

asc  assembly\kuni.ts  -o build\kuni.wasm  -b esm   --enable

2.3. Bindng用グルーコード (kuni.js)

「kuni.ts」をコンパイルした結果、約80行のJavascriptのBinding用グルーコード「kuni.js」が生成されました。開発者はこれをimportするだけなので、この中身を気にする必要はありません。

受け渡す変数が数値のようなprimitiveな型でなく、String型を引数にして結果型を可変要素数のString型変数になり複雑になった途端に、データ連携は複雑になり、グルーコードも増加しています。実際のコードの一部を以下に掲載します。

kuni.jsの一部
async function instantiate(module, imports = {}) {
  const adaptedImports = {
    env: Object.assign(Object.create(globalThis), imports.env || {}, {
      abort(message, fileName, lineNumber, columnNumber) {
        // ~lib/builtins/abort(~lib/string/String | null?, ~lib/string/String | null?, u32?, u32?) => void
        message = __liftString(message >>> 0);
        fileName = __liftString(fileName >>> 0);
        lineNumber = lineNumber >>> 0;
        columnNumber = columnNumber >>> 0;
        (() => {
          // @external.js
          throw Error(`${message} in ${fileName}:${lineNumber}:${columnNumber}`);
        })();
      },
    }),
  };
  const { exports } = await WebAssembly.instantiate(module, adaptedImports);
  const memory = exports.memory || imports.env.memory;
  const adaptedExports = Object.setPrototypeOf({
    kenToKuni(ken) {
      // assembly/kuni/kenToKuni(~lib/string/String) => ~lib/array/Array<~lib/string/String>
      ken = __lowerString(ken) || __notnull();
      return __liftArray(pointer => __liftString(__getU32(pointer)), 2, exports.kenToKuni(ken) >>> 0);
    },
  }, exports);
  function __liftString(pointer) {
    if (!pointer) return null;
    const
      end = pointer + new Uint32Array(memory.buffer)[pointer - 4 >>> 2] >>> 1,
      memoryU16 = new Uint16Array(memory.buffer);
    let
      start = pointer >>> 1,
      string = "";
    while (end - start > 1024) string += String.fromCharCode(...memoryU16.subarray(start, start += 1024));
    return string + String.fromCharCode(...memoryU16.subarray(start, end));
  }
  function __lowerString(value) {
    if (value == null) return 0;
    const
      length = value.length,
      pointer = exports.__new(length << 1, 2) >>> 0,
      memoryU16 = new Uint16Array(memory.buffer);
    for (let i = 0; i < length; ++i) memoryU16[(pointer >>> 1) + i] = value.charCodeAt(i);
    return pointer;
  }
 : 以下省略

JavaScriptは型制約を緩く簡単に見せるために、内部的には複雑なデータ構造を持っており、それを隠蔽している様子がうかがえます。このグルーコードは、まるでJavaScriptのデータの内部構造をHackingしているような感じです。

2.4. 準備したJavaScript

以下のJavaScriptファイル「apl_kuni.js」の冒頭のimport文で、グルーコードをimportして、kenToKuni関数が使えるようにしています。

apl_kuni.js
import { kenToKuni } from "../build/kuni.js";

//DOMからHTML要素を取得する
const inputNum = document.querySelector("#inp");
const submitButton = document.querySelector("#submit");
const res = document.querySelector("#res");

//計算ボタンのクリックイベント処理
submitButton.addEventListener("click", () => {
  const ken = inp.value;
  const ret = kenToKuni(ken);
  res.innerText = ret;
});

2.5. 準備したHTML

以下のHTMLファイル「asmscr_kuni.html」がアプリ起動用HTMLです。

asmscr_kuni.html
<!DOCTYPE html>
<html lang="lang=ja">
<head>
  <meta charset="utf-8">
  <script type="module" src="./js/apl_kuni.js"></script>
  <title>AssemblyScript Demo2 - KenToKuni</title>
</head>

<body>
  <h1>AssemblyScript Demo2 - KenToKuni</h1>
  <p>AssemblyScriptの技術デモ2</p>
  <p>関東地方の以下の県または都を指定して、江戸時代の国の名前を取得します</p>
  <p>茨木県、栃木県、群馬県、千葉県、埼玉県、東京都、神奈川県</p>
  <div>
    <p>県名 = 
      <input id="inp" type="text" value="" maxlength="4">
      <button id="submit">検索開始</button>
    </p>
    <P>国名 = <span id="res"></span></P>
  </div>
</body>
</html>

2.6. 実行

Node.jsのローカルサーバーを「npm start」コマンドで起動して、前述のHTML「asmscr_kuni.html」を表示させます。htmlファイルを事前に「index.html」のRenameしておいても良いでしょう。
以下はHML画面で、県名に「千葉県」を設定し、「検索開始」を押して、国名に「下総国、上総国、安房国」を表示している画面です。

デモ2実行画面

  • このアプリを筆者のHomePageのWebサーバーに導入したものが下記です。HTMLにはサイトの共通ヘッダーをつけています。

https://tonbiwing.com/ep/ref/tech/asdemo/asmscr_kuni.html

3. WebAssemblyモジュールを本物の物理Webサーバーに移行する際の注意点

これはAssemblyScriptに限らず、WebAssemblyのモジュール全てについて共通な注意点です。

Node.jsのローカルサーバーで動作確認できたWASMモジュールを、本物の物理的Webサーバー上で稼働させる場合、WASMの拡張子「.wasm」がMIMEタイプが登録されていることを確認してください。
Webサーバーの標準のMIMEタイプでは、まだWASMが登録されていないことが多いようです。もしも登録されていなければ以下を登録してください。

拡張子 MIMEタイプ
wasm application/wasm

3.1. MIMEタイプが登録されていない場合のエラー

wasmがMIMEタイプが登録されていないと、ブラウザーの画面上の見かけはエラーが出ず、単に動作しないという状態になります。エラー原因を確認するために以下を行います。

  • Chromeの画面でPF12を押して、Chromeデベロッパーツールを起動します。
  • デベロッパーツール上部のConsoleタブをクリックして、コンソールを表示します。
  • Chromeのリロードボタンを押すと、デベロッパーツールのコンソールに以下のメッセージが表示されます。
Uncaught (in promise) Type Error: Failed to execute 'compile'
on 'WebAssembly': Incorrect response MIME type.
Expected 'application/wasm'.

上記はWebAssembly共通の注意点ですが、ここからはAssemblyScript固有の注意点です。

もしもMIMEタイプが登録されていないとWASMモジュールのロードに失敗するので、AssemblyScriptのグルーコードの現在の実装が原因で、二次的に以下の「CORSポリシー違反」という別なエラーが出ます。これもChromeデベロッパーツールのコンソールで確認できます。

xxx.html:1 Access to script at 'node:fs/promises' from origin
'https://xxxx' has been blocked by CORS policy: Cross origin
requests are only supported for protocol schemes: http, data,
isolated-app, chrome-extension, chrome, https, chrome-untrusted.

これはMIMEタイプさえ登録すれば、本来は本物のWebサーバー上でも起きない問題です。
原因は以下のBinding用グルーコードのtry-catchのcatch側に制御が移り、本番サーバーには無いNode.jsのモジュール「node:fs/promises」を動的ロードしようとするために起きる問題です。

    try { return await globalThis.WebAssembly.compileStreaming(globalThis.fetch(url)); }
    catch { return globalThis.WebAssembly.compile(await (await import("node:fs/promises")).readFile(url)); }

Compile時にCompile Optionを色々変えてみましたが、上記のNode.js前提の部分はコードは変わりませんでした。繰り返しになりますが、MIMEタイプさえ登録してあればAseemblyScriptでも起きない問題です。

前述の通り、1章と2章のAssemblyScriptアプリを実際のWebサーバー上で動かしていますので、実際に動作している様子をご覧いただけます。筆者のHomePageのWebサーバーに載せているので、HTMLにはサイトの共通ヘッダーをつけています。

https://tonbiwing.com/ep/ref/tech/asdemo/asmscr_simple.html

https://tonbiwing.com/ep/ref/tech/asdemo/asmscr_kuni.html

3.2. MIMEタイプの設定方法

MIMEタイプの追加登録方法はWebサーバーによって異なりますので、お使いのWebサーバーの設定方法に従ってください。ただし、Apache系のWebサーバーでは、場合によって以下の即時反映できる設定方法が使えるかもしれません。これはXserverのレンタルサーバーではうまく動作しました。他のApache系のWebサーバーでは確認していません。この設定は自己責任で行ってください。

  • 以下の「.htaccess」ファイルを用意します。
.htaccess
Header set Access-Control-Allow-Origin "*"
Header set Cross-Origin-Opener-Policy "same-origin"
Header set Cross-Origin-Embedder-Policy "require-corp"
AddType application/wasm .wasm
  • HTMLを置いてあるWebサーバーのフォルダーに、この「.htaccess」ファイルを置きます。
  • そうすると、このフォルダー下のhtmlは、即時にMIMEタイプが追加登録されます。
    Webサーバーの設定は再起動しないと反映されない場合もありますが、これは「.htaccess」ファイルを置けば即時にMIMEタイプが登録され、他のフォルダーには影響しないので便利です。
  • ただし、MIMEタイプが登録されていない状態で一度HTMLを見てしまったら、Webブラウザー側のCacheは一度OFFにしてください。MIMEタイプを登録後にリロードして見ても、ブラウザーにCacheされている前の(HTTPヘッダーにMIMIEタイプが無い)ファイルが表示されて、やはり動作しないという事になります。

Cacheを一時的にOffして確認するためには、Chromeのデベロッパーツールを使うことができます。

  • HTML画面を表示している時に、ChromeでPF12でデベロッパーツールを起動します。
  • デベロッパーツールの右上の歯車ボタンか、PF1ボタンを押します。
  • 以下の「設定」画面が表示されますので、「(デベロッパーツールを開いている間に)キャッシュを無効にする」にチェックを入れてください。
  • これでデベロッパーツール起動中はCache offになり、動作を確認することができます。
    開発ツールの設定

4. 実際のJavaScriptアプリをAssemblyScriptに書き直した例

前述の簡単のコードでなく、実際に使用している約230行のJavaScriptのコードをAssemblyScriptに書き直した例をご紹介します。筆者のZennの別の記事「JavaScriptによる緯度経度と地図のXY(平面直角座標)との変換、および地理学入門」でご紹介した浮動小数点・配列を使用したJavaScriptによる数値計算アプリを、AssemblyScriptで書き直しました。

アプリの内容

緯度経度で表された地点を、指定した原点からのXY座標のメートル単位に変換する、あるいはその逆変換をするアプリです。アプリの詳細や、JavaScript版のソースコードは、この記事をご覧ください。

https://zenn.dev/tonbiwing/articles/0a8c2a130058e0

4.1. AssemblyScript用に修正した内容

TypeScriptやJavaScriptの便利な機能を使っているほど、現在の仕様のAssemblyScriptへの書き変え量は多くなるでしょう。筆者が使用したこのJavaScriptアプリは数値計算アプリなので、ほとんどのデータ型は浮動小数点で、for文のループ変数に整数を使うだけで、文字列はありません。古典的な単純な制御構造の文法しか使っていないため、書き変えは簡単でした。

以下が修正した点です。

  • 型定義を設定しました。

    • 単純な浮動小数点は f64 (Floating Point 64)で定義
    • 不動小数点の1次元配列は f64[] (f64の配列)で定義
    • 不動小数点の2次元配列は f64[][] で定義
    • for loopのloop変数はi32で定義 (integer 32bit。64bit(i64)にする必要が無いのでi32にしています)
  • 配列を宣言して初期値を与える場合の書き方を letから varに変更しました。これは補足説明をします。

JavaScriptでは以下は正しい文法です。

let a = [1.1, 2.2, 3.3];

AssemblyScriptでは型をつけるのは当然として、以下のlet文はcompileエラーになりました。

let a:f64[] = [1.1, 2.2, 3.3];

そこで配列の値を書き変える場合はvarに修正してcompileエラーを回避しました。

var a:f64[] = [1.1, 2.2, 3.3];

constで宣言する以下の場合は問題無くCompileできます。

const a:f64[] = [1.1, 2.2, 3.3];

AssemblyScriptではletが全面的に使用できないわけではなく、以下は正しい文法です。

let res:i32 = 1;

上記のlet文の制約は公式文書には書かれていませんが、すぐに想像がつく程度の軽い問題です。ここでもAssebmlyScriptが成長途中であることを感じます。

4.2. AssemblyScriptに書き変え後のソースコード

書き変え後のAssemblyScriptのソースコードは約230行あり、「Gauss-Krüger 投影における経緯度座標及び平面直角座標相互間の座標換算」という、WebAssemblyとは何の関係もない三角関数を多用した数値計算プログラムです。コードの内容はこの記事の趣旨とは関係ないので、AssemblyScriptに書き変え後のソースコードの一部だけを掲載します。

latlonxy.ts
export function latlon2xy(latDegree: f64, lonDegree: f64, zone: f64[]): f64[] {
    const originLat: f64 = zone[0]; //原点緯度(度)
    const originLon: f64 = zone[1]; //原点経度(度)
    const sOverline: f64 = zone[2]; //定数S
    const aOverline: f64 = zone[3]; //定数A
    const coefN: f64 = 0.0016792203946287445; // N : 1/ (2F-1)
    const coef0: f64 = 0.08181919104281579; // = 2√n/(1+n)
    const longerRadius: f64 = 6378137.0; //
    /** α(i) (i=1...,5) : 経度緯度から平面直角座標の変換(latlon2xy)に使う定数 */
    const alpha: f64[] = [0.0, 8.377318247285465E-4, 7.608527848379248E-7, 
        1.1976455002315586E-9, 2.4291502606542468E-12, 5.750164384091974E-15 ];
    const lat: f64 = toRadian(latDegree);
    const lon: f64 = toRadian(lonDegree);
    const diffLon: f64 = lon - toRadian(originLon); //λ-λ0  経度と原点の経度の差分
    const cosDiffLon: f64 = Math.cos(diffLon); //λc = cos( λ - λ0) )
    const sinDiffLon: f64 = Math.sin(diffLon); //λs  = sin( λ - λ0) )  
    //t = sinh( atanh(sin((ϕ) - 2√n/(1+n) * atanh(2√n/(1+n)*sin(ϕ))  ) 
    const t: f64 = Math.sinh(Math.atanh(
              Math.sin(lat)) - coef0 * Math.atanh(coef0 * Math.sin(lat) ));
    const t_overline: f64 = Math.sqrt(1.0 + t*t); //t上線付 
    const xiDash: f64 = Math.atan(t / cosDiffLon); // ξ'
    const etaDash: f64 = Math.atanh(sinDiffLon / t_overline); // η'
  : 途中省略
 export function xyzonejapan(sysno:i32):f64[] {
    const origins: f64[][] = [
        [0.0,  0.0],    // 添字0は使用しない
        [33.0, 129.5],  // 座標系1: 長崎県 鹿児島県の後間、岩礁
        [33.0, 131.0],  // 座標系2: 福岡県 佐賀県 熊本県 大分県 宮崎県 鹿児島県(1系区域以外)
  : 以降省略

4.3. 計算精度

科学計算、建築土木計算などの様々な分野で、計算精度は非常に重要なテーマです。
AsseblyScript公式ページの以下のPortabilityの章の「Portable standard library」を読むと、少し気になる記述があります。JavaScriptと互換にするには、compile時のjsonファイルを書き変えて使用するライブラリーを変更するように書いてあります。

https://www.assemblyscript.org/compiler.html#portability

JavaScriptはIEEE754(浮動小数点数算術標準)に準拠して計算していますので、符号もあるので64bit全部を数値として使えるわけではありません。公式ページでは「AssemblyScriptで64bitをフルに使ってしまうi64, f64の計算は若干違う。だから互換ライブラリーを使え」と言っているように読めます。私の英文解釈が間違っているとよいのですが、そうでないと(u32, u64などの符号が無いデータ型の話ではないので)符号はどこにいってしまうのか等謎の文章です。

WebAssemblyまたはAssemblyScriptが使用する三角関数などの算術計算ライブラリーの計算結果が、JavaScriptと比較してどの程度計算誤差があるのかは、この分野の計算をする場合は重要な点です。浮動小数点の演算方法やMathライブラリの三角関数の計算方法が全く違うとは考えにくいのですが、誤差が大きいと困るので「Portable standard library」を使わずに標準的なWebAssemblyのMathライブラリーを使って計算した場合の、JavaScriptとの計算誤差を検証してみました。

4.3.1 テスト方法

  • 下記の国土地理院・関東地方地図の赤枠内の約200Km四方範囲にある、任意の場所の緯度経度について、原点からの東西方向の距離、南北方向の距離を計算します。
    • 赤枠内の範囲は、東経139°~141°、北緯35°~37°の範囲です。
    • 原点は、千葉県野田市北部の北緯36°、東経139°50分の地点です。この原点は、国土交通省告示が測量法に基づいて定めた関東地方(系9)の原点にあたる地点です。
  • 上記の範囲内の100万か所の緯度経度を乱数で発生させます。
    • 100万個の経度と100万個の緯度を正しく生成するために、乱数が循環する周期がそれより長い周期約20億回の乱数を使用しています。
  • このアプリはMathライブラリーの三角関数(sin, cos, tan)、逆三角関数(arcsin, arccos, arctan)、双曲線関数(sinh, cosh, tanh)、逆双曲線関数(asinh, acosh, atanh)を多用しているので、Mathライブラリーの互換性を検証するには適しています。
  • JavaScriptとWebAssemblyの両方で計算をして、計算結果が一致するかテストします。
    • 一致、不一致はJavaScriptの演算子「==」で比較判定します。

地図上のテスト対象範囲
出典:国土地理院地図・小縮尺地図。テスト範囲の赤枠を追加編集

上記地図の公式版の地図は、以下のリンク先の電子国土Web(国土地理院地図)で参照できます。
https://maps.gsi.go.jp/#9/36.001341/139.996033/&base=pale&ls=pale&disp=1&vs=c1g1j0h0k0l1u0t0z0r0s0m0f1

4.3.2. テスト結果

100万地点の比較結果としては、全点完全一致ではありませんでしたが、実用上全く問題がない範囲の僅かな差でした。

項目 結果
完全一致 967,122点 (96.72%)
不一致 32,878回 (3.2%)
不一致距離の最大値 約3nm (ナノメーター) = 約0.003 ミクロン

このアプリは三角関数と四則演算を多用していますが、東西約200km南北約200kmの範囲の距離での、誤差が0.003ミクロンのなので実用上問題はありません。少なくともMathライブラリーの三角関数、双曲線関数計算では、普通は問題にはならない範囲だと思います。
ただし、JavaScriptを基準とした精度をシビアに求めるならば「互換ではないので使用するライブラリーを互換ライブラリーに切り替えろ」という公式ドキュメントに従った方が良いでしょう。

5. AssemblyScriptの実行速度

WebAssemblyおよびAssemblyScriptの実行速度と、JavaScriptとの速度の差について、まとめてみました。

5.1. WebAssemblyの本領である「超高速」を発揮する分野と原理

AIの機械学習や画像処理などの膨大なデータ処理では、データ処理を超高速に処理する必要があり、WebAssemblyの技術は、こうした分野で本領を発揮します。既に多くの商用製品でも実用化されている必須の技術です

WebAssemblyの「超高速」動作の原理

WebAssemblyが高速ではなく「超高速」に動作するための原理は、ただコンパイルしているから早いというだけの仕組みとは全く異なります。(出典資料:フロントエンド向けWebAssembly入門)

  • SIMD(Single Instruction Mulitiple Data)

    • 最近のCPUはSIMD命令をサポートしていますが、1個のSIMD命令で同じ型の複数のデータを一挙に同じ処理をできます。ですので、AIや画像処理など同じデータ型のデータが膨大な量ある場合に、1個づつ処理するのでなく複数同時に処理することができます。WebAssemblyでは、8ビット整数ならば16個同時、16ビット整数ならば8個同時、・・64ビット整数ならば2個同時といった具合に128ビットづつ処理できます。
  • マルチスレッド

    • 複数CPUコアにまたがるマルチスレッドによって、複数CPU上で並行処理させます。
    • 複数CPUでのShared Memory機能でメモリ共有するので、高速なデータ交換ができます。
  • コンパイル

    • 上記のCPUレベルでのメカニズムに加えて、環境に依存しないポータブルな事前コンパイルしたWebAssemblyモジュールを使用します。これは次の項目で説明します。

WebAssemblyのプログラムのコンパイルの仕組み

  • WebAssemblyは、開発時にC,C++,Rust,AssemblyScriptなど様々な言語からコンパイルして、機械語に近い WebAssemblyバイトコード(WASMモジュール)を生成します。
  • 実行時にはWebAssemblyのエンジンが、WebAssemblyバイトコード(WASMモジュール)から実際の機械語にコンパイルます。そのために高速に動作します。

これはJavaが開発時に機械語に近いバイトコードにコンパイルし、実行時にJITコンパイラーが機械語にコンパイルするのと似ています。

WebAssemblyバイトコードは、データ型がしっかり決まっているので、機械語への変換は明確です。

以下は、WebAssemblyがJIT(Just-in-time)コンパイルする説明をした英語の記事です。
https://wingolog.org/archives/2022/08/18/just-in-time-code-generation-within-webassembly

5.2. JavaScriptが高速な理由

一方、Chrome, Edgeなどに使用されているJavaScriptのV8エンジンは、内部的には実行時に動的に機械語にコンパイルするので高速です。ただし普通のコンパイラーやWebAssemly、JavaのJITコンパイラーの仕組みとはだいぶ違います。V8エンジンの仕組みは複雑なので、簡単化して説明します。V8エンジンの仕組みを解説し記事がネット上に色々掲載されていますが、興味があれば是非一度ご覧ください。

https://ponyfoo.com/articles/an-introduction-to-speculative-optimization-in-v8

JavaScriptの変数は型定義がないので、例えば一つの変数に数値を代入したり、文字列の配列を代入し直したり、配列の要素全てに違うデータ型を代入するなどが可能です。そのためIF文の分岐などによって、単純な数値計算の機械語を生成するべきなのか、文字列配列処理の機械語を生成するべきなのか、決まらないという事が起きます。

そこでJavaScripのエンジンは一挙に全部コンパイルするのではなく、実行単位ごとに少しづつコンパイルして、データ型の解釈の統計情報をとってデータ型を確率的に予想しながら実行を進めます。実行よりも先にに予想して生成した機械語を、実行予定箇所に事前に貼り付けます。データ型が予想通りならばその機械語を使用し、データ型予想と違っていたら機械語ではなくインタープリター的にその箇所を実行します。そして、また予測しながらの機械語生成と実行を進めます。更に実行をしながら、頻繁に実行される箇所を優先的に最適化し、最適化したコードを実行予定箇所に貼り付け直すという最適化を行います。

JavaScriptエンジン側から見ると、JavaScriptは上記のように動的にコンパイル内容を変える必要があるので、最初から最適な機械語にコンパイルはできません。そのため、コードの書き方によって実行速度が変わります。

5.4. 普通のアプリでのWebAssemblyとJavaScriptの速度比較

WebAssemblyもJavaScriptと同様に、実行時に機械語にコンパイルするので、どちらも高速です。
JavaScriptは前述の原理のようにアプリによって実行速度が変わるので、現実のアプリではWebAssembly化した方が早い場合と、JavaScriptの方が早い場合と両方あります。

Net上の速度比較をした投稿や記事を見ると、やはりWebAssembly化した方が早い場合と、JavaScriptの方が早い場合と、両方あるようです。
動的コンパイルの原理から考えると、動的コンパイルについては平均的にはWebAssemblyの方が早いはずです。ですが、実行速度は動的コンパイルだけで決まるわけではなく、最適化レベルや実装技術、実際のアプリの特性にもよります。「JavaScriptは遅いから、コンパイルをするWebAssemblyの方が圧倒的に早いはず」とは考えない方がよいでしょう。

5.5. AssemblyScriptのコンパイラーの最適化オプション

AssemblyScriptのコンパイルオプションの最適化レベルの指定は、「--optimizeLevel 0~3の数値」の形式で指定します。数値3が一番高度に最適化しますので、以下のように指定してコンパイルします。

asc assembly\xxx.ts -o build\xxx.wasm  -b esm --optimizeLevel 3

最適化の指定方法は「--optimizeLevel」以外に、速度優先の最適化オプション「-Ospeed」と、サイズを小さくすることを優先させる「-Osize」があります。「-Ospeed」で高速化する場合のコンパイル・コマンドは以下の通りです。

asc assembly\xxx.ts -o build\xxx.wasm  -b esm -Ospeed

コンパイル時の実行速度に影響を与えそうなオプションとして、他に「--converge」と「--noAssert」があります。「--converge」は「これ以上最適化できないとところまで再度最適化する」という凄いオプションです。「--noAssert」はデバッグでなくリリースする時は指定するオプションと書かれています。試した範囲では、残念ながらつけてもつけなくても実行速度の差は出ませんでした。
AssemblyScript導入時に自動生成されたasconfig.jsonでは、release用のoptionとしては、どちらもfalse (disable)にしています。

AssemblyScriptの公式ほーホームページの解説は以下の通りです。
--converge Re-optimizes until no further improvements can be made.
--noAssert Replaces assertions with just their value without trapping.

コンパイルオプションの詳細は以下をご覧ください。

https://www.assemblyscript.org/compiler.html#compiler-options

--optimizeLevel 3と-Ospeedはどちらが早いか

「--optimizeLevel 3」と「-Ospeed」はどちらが早くなるのか試してみましたが、筆者が試したアプリだけでは意味がある速度差はありませんでした。「--optimizeLevel 3」と「-Ospeed」の両方を指定することもできますが、それもあまり速度差はありませんでした。

「--optimizeLevel 3」でコンパイルする場合と、最適化のオプションを何も指定しない省略値の場合でどの程度の実行速度の差があるかは、アプリがどの程度の最適化の余地があるかによって異なります。極端に簡単なアプリなら全く変わりませんし、最適化しやすければ非常に高速になります。
4章でご紹介した「緯度経度から地図のXY座標に変換するアプリ」は、「Gauss-Krüger 投影における経緯度座標及び平面直角座標相互間の座標換ついてのより簡明な計算方法」という計算方法にもとづく、あまり簡明でない三角関数を多用した難解な数式ばかりで、人間にはとても複雑に見えます。ですがコンパイラーになったつもりで、プログラムの意味を無視して読み直してみると、コンパイルしやすい、簡単な最適化しやすいアプリでした。これで速度を比較すると、「--optimizeLevel 3」を指定すると約2倍高速(=実行時間が半分)になりました。「-Ospeed」を指定しても、両方のオプションを指定してもほぼ同じです。(100万回ループさせた測定を、10回繰り返した平均です)

「WATファイル」とAssemblyScriptの更なる最適化の余地

AssemblyScriptの最適化で高速化はされていますが、まだまだ最適化の余地はあると考えます。最適化の計画は公式ページで明示されていませんが、「これ以上最適化できないところまで再度最適化する」という「converge」オプションが今後実装されることを期待したい思います。

AssemblyScriptが生成するWebAssemblyバイナリコードは、「WATファイル」と呼ばれるテキスト形式で出力することができます。最適化した時のWATファイルが、コンパイラーが行う最適化パターンをどの程度適用しているかを見てみました。

テキスト形式にしたWASMコードは、コンパイルオプション「-t ファイルパス」で、以下のように指定して生成できます。
以下はxyz.tsから watファイルxyz.watを生成するコマンドの例です。

asc  assembly\xyz.as -o  build\xyz.wasm -b esm -t build\xyz.wat

生成されるwat形式のファイルは、以下のような感じです。量が多いので一部だけ掲載していますが、if文を含むtriangleという名前の関数のWATファイルの例です。

(module
 (type $0 (func (param f64) (result f64)))
 (type $1 (func (param f64 i64) (result i32)))
 : 中略
 (global $~lib/memory/__stack_pointer (mut i32) (i32.const 32968))
 (global $~lib/memory/__heap_base i32 (i32.const 32968))
 (memory $0 1)
 (data $0 (i32.const 8) "n\83\f9\a2\00\00\00\00\d1W\\03V\08]\8d\1f
 : 中略
\bc\cf\f0\abk{\fca\91\e3\a9\1d6\f4\9a_\85\99e\08\1b\e6^\80\d8\ff\8d@h\a0\14W\15\06\061\'sM")
 (table $0 1 1 funcref)
 (elem $0 (i32.const 1))
 (export "triangle" (func $assembly/triangle/triangle))
 (export "memory" (memory $0))
 (func $~lib/math/pio2_large_quot (param $0 f64) (param $1 i64) (result i32)
  (local $2 i64)
  (local $3 i64)
  (local $4 i64)
 : 中略
  i64.const 0
  i64.ne
  if
   i32.const 64
   i64.extend_i32_s
   local.get $4
   i64.sub

WATファイルは、S式と呼ばれる形式で記述されています。LISP言語でも使っているプログラムの表記形式です。(S式はLISPでお世話になりました。ずいぶん懐かしいものを見ました。)
「WebAssemblyのテキスト形式」は以下をご覧ください。

https://developer.mozilla.org/ja/docs/WebAssembly/Understanding_the_text_format

コンパイラーの最適化技法は長く研究されていますが、一つの例として以下のようにループを展開する「Loop Unrolling」という方法があります。WATファイルを見る限りAssemblyScriptはこの最適化はしていませんでした。「徹底的に最適化する」というconvergeオプションをつけても変わっていませんでした。やっても効果が無いコードだと判断から適用しなかったのか、元々この最適化は行わないのかはわかりません。

Loop Unrollingは、わかりやすさのために単純化したJavaScriptで書くと、最適化前のコードは以下のようになります。

 const constValue = 111;
 for (let j = 1; j<= 5; j++) {
   a[j] = b[j] * j * constValue; 
 }

コードサイズが大きくなっても構わず、とにかく高速化したい場合、以下のようにループの内容を展開してforループを無くします。
for文の条件判定とループから抜けるためのジャンプが無くなるので、少なくともその分早くなります。CPU内部からすると条件分岐が無いことはより重要です。簡単化して言うと、CPUは常に機械語の命令を先読みして実行準備をするので、2つの分岐があると両方の準備をして、分岐に到達すると選択されなかった片方の分岐の準備を捨てます。条件判定が無ければ無駄な先読みが無くなり、その分もっと先読みの準備ができてCPU内部の処理を最適化できる可能性が高くなります。

const constValue = 111;
 a[1] = b[1] * 1 * constValue;
 a[2] = b[2] * 2 * constValue;
 a[3] = b[3] * 3 * constValue;
 a[4] = b[4] * 4 * constValue;
 a[5] = b[5] * 5 * constValue;

更に最適化する場合は、定数同士の簡単な演算はコンパイラーが事前に計算してしまうこともできます。

 a[1] = b[1] * 111;
 a[2] = b[2] * 222;
 a[3] = b[3] * 333;
 a[4] = b[4] * 444;
 a[5] = b[5] * 555;

JavaScriptの方が早い場合もあります

AssemlyScriptはコンパイル時に最適化はしますが、現時点の実装では、高度な最適化をするというわけではなさそうです。4章の「緯度経度からXY座標に変換するアプリ」では、AssemblyScriptよりJavaScriptの方が約5倍速いという結果でした。AssemblyScriptを紹介するつもりの記事でJavaScriptの方が圧倒的に早い事例を見つけてしまいました。最適化しやすいアプリに対して、AssemblyScriptのそこそこのレベルの最適化よりも、JavaScriptエンジンの最適化が非常に高度であったという結果でしょう。この結果からもAssemblyScriptの最適化が、非常に高度というわけではないと言えるでしょう。成長する伸びしろは大きそうです。

このアプリは、全ての配列の全ての要素のデータ型はprimitiveな浮動小数点型で、それ以下の変数もfor文のloop変数以外全て不動小数点で、かつ定数が多くif文が1個もないという特殊なアプリです。このアプリ特性では、JavaScriptエンジン内部の動的コンパイルのためのデータ型推論が容易な上、元々コンパイルの最適化パターンを沢山適用しやすいアプリなので、最適化レベルによる差は出やすかったと思います。

Binding用コードのオーバーヘッド

JavaScriptとWebAssemblyの間の、Binding用JavaScriptグルーコードのオーバーヘッドも大きい場合があります。2.3章でご紹介したように、グルーコードは連携するデータ型によって、すぐに複雑になります。
ただし、この点は今後WebAssemblyでグルーコードを変換するようになるそうで、AssemblyScriptに限らずWebAssembly全体で今後改良されていく予定だそうです。

JavaScriptは表面上はデータ型を緩く簡単そうに見せてていますが、実際はどのようなデータ型にも対応するために内部データ構造は複雑です。データ型の強い言語からJavaScript変数にデータ連携しようとすると、内部的に複雑なJavaScriptのデータ構造への変換が発生し、それでようやく表面的にはデータ型が緩い便利な変数として扱えるようになります。その変換処理がBindingのオーバーヘッドです。
JavaScriptの関数が、他のJavaScript関数を呼びだす時には、そのようなオーバーヘッドは生じません。

コード量がとても少ない簡単なプログラムで、引数や戻り値のデータ型が複雑な場合は、AssemblyScriptにするよりJavaScriptの方が一般的には早いと言えるでしょう。

6. まとめ

AssemblyScriptは現在(2024年3月)、まだVersion 0.27.25であり進化途中です。今後変わっていく予定で、公式ページの今後の予定は以下の通りです。

https://www.assemblyscript.org/status.html

筆者がWebAssembly開発言語としてAssemblyScriptを試用してみた範囲で、筆者が考えるAssemblyScriptの長所と課題を、この記事を執筆している現時点での一つの私見としてまとめました。

AssemblyScriptの長所(私見):

  • 安定動作は一番重要です。少なくとも200~300行程度のJavaSriptアプリを幾つかAssemblyScriptに書き直した範囲では、安定して動作しました。公式ページには実運用されている例も載っています。ただし、筆者が試したアプリは、配列やStringでheapは使いますが元がJavaScriptなので、大量に動的なメモリーAllocationしたり、メモリー開放を必要にするような書き方をわざわさはしていないので、GCを繰り返す検証にはなっていません。

  • JavaScriptとの連携は非常に簡単です。WASMモジュールと共に生成されるBinding用グルーコードのJavaScriptをimportするだけです。

  • AssemblyScriptの文法はTypeScriptやJavaScriptに近くて馴染みやすいです。開発環境もNode.jsで開発するスタイルを踏襲できるので、気軽にちょっとやってみようかという気持ちなれる所は良い所です。

  • AssemblyScriptコンパイラーのエラーメッセージは、エラー箇所も内容も明確に表示されます。

  • コンパイルする言語全般の長所になってしまいますが、型制約が強くてコンパイル時に単純な代入や参照のエラーを静的に検出し、時間のかかる動的テスト工数を削減できるのは良い点です。JavaScriptでなくTypeScriptで書く方が潜在バグが減る理由と同じです。ただし、型制約が緩い言語を、実行させながらデバックするのが好みという方にとっては、短所かもしれません。

AssemblyScriptが現在成長中な課題点(私見)

  • AssemblyScriptの公式ページでは、GC(Garbage Collection)はimplementation phase(実装中)です。一方、公式ページのコンパイル・オプションの説明を見ているとGCができるようにも見えてしまいます。現時点でもオブジェクトや配列、String型データ等を動的に生成させ、ヒープ上のメモリを動的に確保させることは可能ですし動作します。ですが、メモリのリリースがどう動くのかは気になる所です。規模が大きいGCが多く発生するアプリを作るのであれば、GCの安定動作が確認できるまで待つ必要があるでしょう。

  • 公式ドキュメントの文法記載は、TypeScript文法を参照することを前提に(?) 簡略化されています。AssemlyScriptは今後も文法的に改良される予定になっていますが、「TypeScriptとAssemblyScriptは目的が違うので、将来的にもTypeScriptと同じものにはならない」と公式ページで宣言されています。目的が違う以上TypeScriptとの違いがあっても全く良いのですが、何が違うのか明確に書かれているわけではありません。配列代入時のlet文に制約がありましたが公式文書には書かれていません。直面した文書化不足はどれも推測でカバーできる範囲でしたので、困るほどではありませんでした。直面しなかった潜在的問題については、わかりません。

  • AssemblyScriptはコンパイル時にきちんと最適化は行います。ですが前述の検証の範囲では非常に優秀かつ高度な最適化というわけではないようです。筆者の検証結果とネット記事を見る限りJavaScriptとよりも高速化されるとは限りません。AssemblyScript使用の目的が「JavaScriptよりも高速化すること」だけですと期待を裏切られる場合があります。
    公式ドキュメントに書かれている「これ以上最適化できないところまで再度最適化する」という「--converge」コンパイル・オプションが、今後実装されることを期待したいと思います。

  • コンパイル・オプションの中には効果がよくわからないものがあります。指定しても機能しないのか、機能しているけれどアプリによってたまたま効果が薄いだけなのか、または仕様変更されたけれどドキュメントが追い付いていないのか悩むオプションがあります。「--converge」もその一つです。

開発環境構築(Editor)の注意点

以下は言語の課題ではなく、開発環境する上でEditrorの注意点です。

  • VSCodeと JetBrainsのIDE(RubyMine)を使用しましたが、AssemblyScriptに対応したEditor、またはEditor のPluginを使わないと面倒です。TypeScriptもAssemblyScriptも拡張子が「.ts」なので、TypeScriptと思ってデータ型のProposalを出したり、AssemblyScriptのi32, f64などの型定義が全部エラーとして表示されます。
    例えば「f64」と書くと「Float64Array」と毎回自動的にProposalに上書きされるので、空白を余計に入れてからカーソル位置を戻して書き直してみたり、無駄な手間がかかりました。
    JetBrainsのIDEはどの言語用のIDEでもTypeScriptに対応しているようですが、RubyMineを持っていたのでRubyMineとVSCodeでEditしていました。どちらもWebAssembly用Plug-inがあるようですが、深く追求しませんでしたので、残念ながらお勧めPlug-inは不明です。

使い分け

WebAssembly化する目的が明確で、まあまあ早くて最初にとっつきやすい言語と開発環境で手軽に始めたい、という使い方を求める場合は、AssemblyScriptは良い選択肢になると思います。

一方、規模がある程度大きくGCを含めた安定性や、言語としての動作としっかりしたドキュメントが求められるシビアな開発の場合には、AssemblyScriptは中途半端な感じがします。

AI機械学習ライブラリなど、膨大な配列的に構造化されたデータを、SIMD対応プロセッサーを使ってマルチタスク化したい、というWebAssembly本来の超高速動作をさせるためには、今時点ではAssemblyScriptには難しいでしょう。SIMDはある程度対応しているようですが、ライブラリ側の対応、シビアに使われるのでGC実装や更なる最適化などが必要でしょう。

最後に

筆者はサーバーサイドに負荷をかけるCGI, Per, Rubyでサーバーアプリを書くのではなく、Webクライアント上と、ブラウザーの外側両方で普通のアプリを動作させる必要がありました。データ使用権の観点から少なくとも簡単にはバイナリ化させたアプリを開発する必要があり、WebAssebmly技術を選択しました。それを何の言語で記述すべきか検討する過程で、TypeScriptに近いAssemblyScriptの技術検討をしました。 それをまとめたのが本資料です。

AssemblyScriptが具体的にどんなものだろうか、使ってみよう、試してみようと思われる方が最初の出だしでつまづかないように、もしもこの資料がお役にたてれば幸いです。

Discussion