kickflow Tech Blog

株式会社kickflowのプロダクト開発本部によるブログ

Wasmを触ってみた

A pop and cute eye-catch image for a WebAssembly (wasm) related blog
(Bing の Image Creatorで生成)

kickflowプロダクト開発本部の小本です。

kickflowでは週1でkickflowで使っている技術や今後kickflowで使いたい技術を紹介する勉強会を開いています。今回は、その勉強会のWasm回の資料を公開します。

なお、kickflowでは直近でWasmを導入する予定は無く、まずWasmの概要を把握し今後に備えるという段階なので、この資料も大掴みに説明するものとなっています。

Wasm=WEB向けのアセンブリ言語

WebAssembly(公式の略称はWasm)はWebブラウザで実行できる第2の言語です(第1はJavaScript)。高機能なJavaScriptに対して、Wasmはアセンブリ言語的なシンプルな言語です。

主な特徴は以下の通りです(以下は公式ページをGoogle翻訳で翻訳したものです。正確性は保証しません。):

WebAssembly の設計目標は次のとおりです。

  • 高速、安全、移植可能なセマンティクス:

    • 高速: 現代のすべてのハードウェアに共通する機能を活用して、ネイティブ コードに近いパフォーマンスで実行します。

    • 安全:コードは検証され、メモリセーフ[ 2 ]のサンドボックス環境で実行されるため、データの破損やセキュリティ侵害を防ぐことができます。

    • 明確に定義されている: 有効なプログラムとその動作を、非公式にも公式にも簡単に推論できる方法で完全かつ正確に定義します。

    • ハードウェアに依存しない: デスクトップやモバイル デバイス、組み込みシステムなど、すべての最新アーキテクチャでコンパイルできます。

    • 言語に依存しない: 特定の言語、プログラミング モデル、またはオブジェクト モデルを優先しません。

    • プラットフォームに依存しない: ブラウザに埋め込んだり、スタンドアロン VM として実行したり、他の環境に統合したりできます。

    • オープン: プログラムは、シンプルかつ普遍的な方法で環境と相互運用できます。

  • 効率的で移植性の高い表現:

    • コンパクト: 通常のテキスト形式やネイティブ コード形式よりもサイズが小さいため、転送が高速なバイナリ形式です。
    • モジュール式: プログラムを小さな部分に分割して、個別に送信、キャッシュ、および消費することができます。
    • 効率的: ジャストインタイム (JIT) コンパイルまたは事前コンパイル (AOT) コンパイルのいずれの場合も、高速な単一パスでデコード、検証、コンパイルできます。
    • ストリーム可能: すべてのデータが確認される前に、デコード、検証、およびコンパイルをできるだけ早く開始できます。
    • 並列化可能: デコード、検証、コンパイルを多数の独立した並列タスクに分割できます。
    • ポータブル: 最新のハードウェアで広くサポートされていないアーキテクチャ上の仮定は行いません。

Wasmを導入する上で辛そうなところ

かのように、野心的な目標を掲げるWasmですが、長所は逆に欠点にもなります。

ハードウェア、言語、プラットフォームに依存しない

つまり…

  • 依存する部分は誰かが実装しなければならない。
  • まだ誰も実装していないなら、自分で実装しなければならない。
  • 例えば、こういった処理は実装が必要
    • ブラウザ側:DOM操作やWEB APIの呼び出し
    • サーバー側:ファイルの読み書きなど

数値型のみを提供する

つまり…

  • 文字列やオブジェクトなどの型関数の引数や戻り値として使う事はできない
    • 実は「線型メモリ」というバッファを介して値のやり取りをできる。文字列をバッファに書き込んで、位置をポインタ渡しすることでやり取りできる。
  • 「線型メモリ」の管理をどうするか?GCは?
  • 誰かがメモリ管理を実装していなければ、自分で実装する必要がある。

文字列表現(.wat)

Wasmプログラムには、バイナリ表現(.wasm)と、テキスト表現(.wat)があります。 テキスト表現は人間でもある程度理解できます。

たとえば、こちらはAssemblyScript(TypeScript風言語)で書いたフィボナッチ数列のコードですが、

// AssemblyScriptで書いたフィボナッチ数列のコード
export function fib(n: i32): i32 {
  var a = 0, b = 1
  if (n > 0) {
    while (--n) {
      let t = a + b
      a = b
      b = t
    }
    return b
  }
  return a
}
/* https://www.assemblyscript.org/ より引用 */

上記をコンパイルした .wat ファイルは以下のようなものになります。

(module
 (type $0 (func (param i32) (result i32)))
 (memory $0 0)
 (export "fib" (func $module/fib))
 (export "memory" (memory $0))
 (func $module/fib (param $0 i32) (result i32)
  (local $1 i32)
  (local $2 i32)
  (local $3 i32)
  i32.const 1
  local.set $1
  local.get $0
  i32.const 0
  i32.gt_s
  if
   loop $while-continue|0
    local.get $0
    i32.const 1
    i32.sub
    local.tee $0
    if
     local.get $1
     local.get $2
     i32.add
     local.set $3
     local.get $1
     local.set $2
     local.get $3
     local.set $1
     br $while-continue|0
    end
   end
   local.get $1
   return
  end
  i32.const 0
 )
)

さまざまなプログラミング言語のコードをWasmにコンパイルできる

Wasmコードは直接手書きすることもできなくはないが、他言語のコードをコンパイルして生成するものと思ってください。

LLVMからWasmを生成できるemscriptenのおかげで、大手の言語はWasmに対応しています。

大きく3種類ほどに分けられます。

  • コンパイル型言語
    • 例: Rust、C、C++、Go
    • コードをemscriptenでWasmにコンパイルします。
    • 生成するWasmのコードには標準ライブラリなどのコードも含むので、そこそこ大きくなります。
  • インタープリタ型言語
    • 例:Python、Ruby、Java
    • 処理系をWasmにコンパイルすることで、コードをWEBブラウザ上で実行できます。
    • 生成するWasmのコードには処理系全体を含むので、巨大になります。
  • Wasm専用言語
    • 例:AssemblyScript(TypeScript風言語)
    • コードをWasmにコンパイルします。
    • 最小限のコードのみを生成します。

学習目的ではAssemblyScriptを使うのがオススメです。

ブラウザ以外でも実行できる

実はWasmにはWEB固有の機能は含まれていないため、ブラウザ以外の環境でも実行できます。

CLIで実行できるWasmランタイムとしてはwasmtimewasimerがあります。wasmtime・wasmerともに他言語向けのSDKがあり、たとえば他言語のプログラムからWasmを実行できます。

wasmtimeの他言語サポート image.png (190.0 kB)

wasmerの他言語サポート(一部)

image.png (376.2 kB)

Wasmをコンテナ技術として使う

Wasmはサンドボックス化どこでも実行できるなどのDockerと共通の特徴を持っており、Docker DesktopがWasmをサポートしたり、WasmをDockerイメージのように使えるようにするビジョンがあるようです。

また、WebAssemblyを使ってWebブラウザ上でDockerコンテナを実行するツールも提供されています。

学習目的ではAssemblyScriptがオススメ

Wasmの基礎を学習する上では、AssemblyScriptがおすすめです。

  • 生成する.watが最小限の内容なので、ソースコードとwatの関係を理解しやすい
  • JSのラッパーコードを出力してくれるので、すぐ実行できる

実際に書いてみた

WSGIやRack風に、

  • 引数:HTTPリクエスト情報
  • 戻り値:ステータスコード・ヘッダー・ボディの配列

という関数をAssemblyScript→Wasmで書いてみました。

// handler.ts
// AssemblyScriptで書いたコード

class Header {
  key: string
  value: string
}

class Response {
  status: number
  headers: Header[]
  body: string
}

class RequestEnv {
  key: string
  value: string
}

// HTTPリクエストの内容を表すRequestEnv[] を受け取って、HTTPレスポンスを表すResponseを返す
export function run(request: RequestEnv[]): Response {
  let requestMethod = ''

  for(let i = 0; i < request.length; i++) {
    const r = request[i]
    if (r.key === 'REQUEST_METHOD') {
      requestMethod = r.value
    }
  }

  return {
    status: 200,
    headers: [
      { key: 'Content-Type', value: 'text/plain' }
    ],
    body: "Hello " + requestMethod + "!"
  }
}

ご覧の通り、ほぼTypeScriptそのままです。文字列やオブジェクトも使えています。

ここで、いくつか注意点があります。

  • AssemblyScript では(生Wasmと違い)引数にオブジェクトを使えるが、コンストラクタなどを持たないプレーンなオブジェクトでなければならない
  • TSの一部機能(大部分の機能?)はAssemblyScriptでは未実装
    • for-of
    • type = {
    • 配列をタプルとして使う

Wasmを呼び出すためのコード

JavaScript(node.js)の場合は以下のようなコードを書くだけです。

本質的には、AssemblyScriptが生成した run関数を呼んでいるだけです。

// node.js側のコード
import http from "http";
import {run} from "./build/release.js"

http.createServer((request, response) => {
  // WASI関数に渡す配列を準備
  const requestEnv = []
  for (const key in request.headers) {
    requestEnv.push({key, value: request.headers[key]});
  }
  requestEnv.push({key: 'REQUEST_METHOD', value: request.method});

  // AssemblyScriptが生成したrun関数を呼び出すだけ。
  const result = run(requestEnv);

  // 結果をHTTPレスポンスとして返す。
  const headers = {}
  result.headers.forEach((h) => {
    headers[h.key] = h.value;
  })
  response.writeHead(result.status, headers);
  response.end(result.body, "utf-8");
}).listen(8080);

AssemblyScriptが生成するグルーコード

AssemblyScriptでは、生成するWasmはシンプル呼び出す側のコードもシンプルなのですが、その分Wasmと呼び出し側の中間のグルーコードがそれなりに複雑になっています。

Wasmでは関数の引数として数値型しか使えませんが、AssemblyScriptで文字列やオブジェクトも使えることになっています。グルーコード内では、文字列やオブジェクトをシリアライズしてメモリに書き込み、ポインタをWasm関数に引数として与える処理をしています。

なお、AssemblyScriptはJavaScript向けのグルーコードを生成しています。JS以外の言語(Ruby)から利用するには?それは、グルーコードを自前で実装する必要があります

// AssemblyScriptが生成したグルーコード
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({
    run(request) {
      // handler/run(~lib/array/Array<handler/RequestEnv>) => handler/Response
      request = __lowerArray((pointer, value) => { __setU32(pointer, __lowerRecord4(value) || __notnull()); }, 5, 2, request) || __notnull();
      return __liftRecord6(exports.run(request) >>> 0);
    },
  }, exports);
  function __lowerRecord4(value) {
    // handler/RequestEnv
    // Hint: Opt-out from lowering as a record by providing an empty constructor
    if (value == null) return 0;
    const pointer = exports.__pin(exports.__new(8, 4));
    __setU32(pointer + 0, __lowerString(value.key) || __notnull());
    __setU32(pointer + 4, __lowerString(value.value) || __notnull());
    exports.__unpin(pointer);
    return pointer;
  }
  function __liftRecord7(pointer) {
    // handler/Header
    // Hint: Opt-out from lifting as a record by providing an empty constructor
    if (!pointer) return null;
    return {
      key: __liftString(__getU32(pointer + 0)),
      value: __liftString(__getU32(pointer + 4)),
    };
  }
  function __liftRecord6(pointer) {
    // handler/Response
    // Hint: Opt-out from lifting as a record by providing an empty constructor
    if (!pointer) return null;
    return {
      status: __getF64(pointer + 0),
      headers: __liftArray(pointer => __liftRecord7(__getU32(pointer)), 2, __getU32(pointer + 8)),
      body: __liftString(__getU32(pointer + 12)),
    };
  }
  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;
  }
  function __liftArray(liftElement, align, pointer) {
    if (!pointer) return null;
    const
      dataStart = __getU32(pointer + 4),
      length = __dataview.getUint32(pointer + 12, true),
      values = new Array(length);
    for (let i = 0; i < length; ++i) values[i] = liftElement(dataStart + (i << align >>> 0));
    return values;
  }
  function __lowerArray(lowerElement, id, align, values) {
    if (values == null) return 0;
    const
      length = values.length,
      buffer = exports.__pin(exports.__new(length << align, 1)) >>> 0,
      header = exports.__pin(exports.__new(16, id)) >>> 0;
    __setU32(header + 0, buffer);
    __dataview.setUint32(header + 4, buffer, true);
    __dataview.setUint32(header + 8, length << align, true);
    __dataview.setUint32(header + 12, length, true);
    for (let i = 0; i < length; ++i) lowerElement(buffer + (i << align >>> 0), values[i]);
    exports.__unpin(buffer);
    exports.__unpin(header);
    return header;
  }
  function __notnull() {
    throw TypeError("value must not be null");
  }
  let __dataview = new DataView(memory.buffer);
  function __setU32(pointer, value) {
    try {
      __dataview.setUint32(pointer, value, true);
    } catch {
      __dataview = new DataView(memory.buffer);
      __dataview.setUint32(pointer, value, true);
    }
  }
  function __getU32(pointer) {
    try {
      return __dataview.getUint32(pointer, true);
    } catch {
      __dataview = new DataView(memory.buffer);
      return __dataview.getUint32(pointer, true);
    }
  }
  function __getF64(pointer) {
    try {
      return __dataview.getFloat64(pointer, true);
    } catch {
      __dataview = new DataView(memory.buffer);
      return __dataview.getFloat64(pointer, true);
    }
  }
  return adaptedExports;
}
export const {
  memory,
  run,
} = await (async url => instantiate(
  await (async () => {
    const isNodeOrBun = typeof process != "undefined" && process.versions != null && (process.versions.node != null || process.versions.bun != null);
    if (isNodeOrBun) { return globalThis.WebAssembly.compile(await (await import("node:fs/promises")).readFile(url)); }
    else { return await globalThis.WebAssembly.compileStreaming(globalThis.fetch(url)); }
  })(), {
  }
))(new URL("release.wasm", import.meta.url));
  • __liftString, __lowerString: JS側とWasm側の文字列を変換する関数
  • __liftArray, __lowerArray, __liftRecord, __lowerRecord
  • __new: Wasm側で定義した関数で、malloc的なことをする(と思う)
  • export const { run }: Wasmの関数runをラップしている

今後の新機能(WasmGC、WASI)

WasmGC

  • WasmにGCを追加する仕様です。
  • GoやJavaなどのGCがある言語をWasmで実装するのが容易になります。
  • GCが標準になれば、上記のグルーコードも簡潔になるかもしれません。
  • WasmGCについて予習する

WASI

WASIX

Wasmの利用例

Wasmを本格的に実用した例は少ないと思います。

ブラウザ上でPostgreSQLが動くなど、夢はあります。

感想

  • 画像処理など計算処理をしたい→Wasmを使ってもいいかも
  • なんとなく文字列処理などを速くしたい→Wasmを使うメリットは少ないかも
  • 誰かがメモリ周り等を扱うフレームワークを用意すれば、それに合わせてWasmバイナリを用意するのは簡単そう
  • 今後、例えばAmazon LambdaのようなサービスがWasmをサポートすることはあるかもしれない

We are hiring!

kickflow(キックフロー)は、運用・メンテナンスの課題を解決する「圧倒的に使いやすい」クラウドワークフローです。

kickflow.com

サービスを開発・運用する仲間を募集しています。株式会社kickflowはソフトウェアエンジニアリングの力で社会の課題をどんどん解決していく会社です。こうした仕事に楽しさとやりがいを感じるという方は、カジュアル面談、ご応募お待ちしています!

careers.kickflow.co.jp