Asterius から GHC WebAssembly バックエンドに移行した話

## はじめに

趣味で作っているアプリケーションを Haskell で書いていて、これをブラウザで動かせるようにしたいというのがありました。

そこで、

- Haskell で書いたコードを、WebAssembly にコンパイルすることでブラウザ上で呼び出せるようにする
- そのための諸々の手順を GitHub Actions で自動化しつつ GitHub Pages にデプロイする

というのをやるために、かつて Asterius という Haskell → WebAssembly コンパイラを使っていたのですが、最近これが GHC 本体にマージされたという話があり、移行することにしました。

https://www.tweag.io/blog/2022-11-22-wasm-backend-merged-in-ghc/

## Asterius とは

Haskell から WebAssembly へのコンパイラです。

https://github.com/tweag/asterius

Asterius を動かしてみた人の情報としては、以下が詳しいと思います。

- [AsteriusでHaskellの関数をJSから呼べるようにしてみた（けど失敗）（拡大版） - Haskell-jp](https://haskell.jp/blog/posts/2019/asterius.html)
- [Haskell 製の自作のトランスパイラを Asterius で WebAssembly にコンパイルしてみた - うさぎ小屋](https://kmyk.github.io/blog/blog/2021/08/31/haskell-webassembly-asterius/)

## Asterius 使用時の構成

`MyProject` というパッケージの `myFunc :: Text -> Either Text Text` という関数を呼び出したいとします。

Asterius 使用時はこんな構成でした。

↓ Haskell のエントリーポイント

```pkg/Lib.hs
module Lib where

import Asterius.Text
import Asterius.Types
import qualified Data.Text as T
import qualified MyProject.Main as MyProject

foreign export javascript myFunc :: JSString -> JSString

myFunc :: JSString -> JSString
myFunc input =
  either (error . T.unpack) textToJSString $
    MyProject.myFunc (textFromJSString input)
```

↓ Asterius 生成物の呼び出し部分

```pkg/index.ts
// @ts-ignore
import { newAsteriusInstance } from '../out/rts.mjs';
// @ts-ignore
import WASM_URL from '../out/myproject.wasm?url';
// @ts-ignore
import req from '../out/myproject.req.mjs';

export interface MyProject {
  myFunc(input: string): Promise<string>;
}

export async function loadMyProject(): Promise<MyProject> {
  const res = await fetch(WASM_URL);
  const bytes = await res.arrayBuffer();
  const module = await WebAssembly.compile(bytes);
  Object.assign(req, { module });
  return {
    async myFunc(input: string): Promise<string> {
      const instance = await newAsteriusInstance(req);
      return instance.exports.myFunc(input);
    },
  };
}
```

↓ ビルドスクリプト

```entrypoint.sh
#!/bin/bash
mkdir -p out
ahc-link \
  --input-hs pkg/Lib.hs \
  --output-directory out \
  --output-prefix myproject \
  --no-main \
  --browser \
  --ghc-option "-isrc" \
  --export-function=myFunc
```

↓ GitHub Actions ワークフロー (抜粋)

```gh-pages.yml
      - name: Build WebAssembly
        uses: docker://terrorjack/asterius
        with:
          entrypoint: ./entrypoint.sh
```

## Asterius のつらみ

### GHC 8.10 で止まっている

Asterius の Docker イメージは Docker Hub に上がっていますが、更新は 2021 年 1 月で止まっており、ここに含まれている GHC のバージョンは 8.10 です。

https://hub.docker.com/r/terrorjack/asterius

### Docker イメージがデカすぎる

上のイメージを落としてくると 20 GB 以上の大きさになります。

GitHub Actions でイメージをダウンロードするのに長い時間がかかる上に、GitHub Actions で使えるディスク容量が大体 14 GB くらいらしいため、バージョンによっては `no space left on device` エラーでイメージを使えませんでした。

## ghc-wasm-meta とは

Asterius を作っていた人が GHC 本体にその機能をマージすることになったのですが、現状 GHC のオプションなどで WebAssembly をターゲットにコンパイルすることはできません。WebAssembly を吐き出せるように GHC を設定したものがこのリポジトリです。

https://gitlab.haskell.org/ghc/ghc-wasm-meta

ghc-wasm-meta を動かしてみた人の情報としては、以下が詳しいと思います。

- [Glasgow Haskell Compiler の WebAssembly バックエンドを一足早く動かしてみる - Qiita](https://qiita.com/tnagao7/items/09a4e18d23d5608573e9)
- [GHC 9.6の新機能](https://zenn.dev/mod_poppo/articles/whats-new-in-ghc-9-6)

## ブラウザ上で WASI

WebAssembly System Interface (WASI) をブラウザ上で動かすには、ghc-wasm-meta で紹介されている

- [`wasi-js`](https://github.com/sagemathinc/cowasm/tree/main/core/wasi-js)
- [`browser_wasi_shim`](https://github.com/bjorn3/browser_wasi_shim)

や [Ruby on Browser](https://github.com/ongaeshi/rubyonbrowser) で使われている

- [`@wasmer/wasi`](https://github.com/wasmerio/wasmer-js)

が使えます。ここでは `@wasmer/wasi` を使いました。

## WASI reactor モード

WASI には command モードと reactor モードがあって、CLI のように `main` 関数を呼び出して終了する (内部的には `_start` 関数) のが command モード、起動後 (内部的には `_initialize` 関数) に自由に関数を呼び出せるのが reactor モードです。

当初 command モードを使い、標準入出力を使って値の受け渡しをしようとしていたのですが、Haskell 内で標準出力や標準エラー出力に出力しようとすると WASI の `poll_oneoff` が無限に呼ばれ続ける不具合が出たので諦めました。

reactor モードでは値を直接受け渡しすることができますが、文字列や構造体など数値以外のものを渡そうとすると、Wasm のメモリを介した ABI を適当に自分で定める必要があります (Asterius や [wasm-bindgen](https://github.com/rustwasm/wasm-bindgen) が勝手にやってくれていた部分)。

## ABI を定める

文字列や構造体を JavaScript と Haskell 間で送り合うために ABI を定めます。大体以下の 2 種類の方針があると思います。

1. ひとつの連続した領域にシリアライズする
2. C 言語的なレイアウトで、固定長の構造体や可変長のバッファを `malloc` する

ここでは 2. を採用しましたが 1. でもいいと思います。

Haskell 側で文字列を受け取るのに使えるのは `Data.ByteString.Unsafe` の `unsafePackMallocCStringLen` や `unsafeUseAsCStringLen` といった関数です。

https://hackage.haskell.org/package/bytestring/docs/Data-ByteString-Unsafe.html

色々あるのですが、

- [`Data.ByteString`](https://hackage.haskell.org/package/bytestring/docs/Data-ByteString.html) の `unsafe` がつかないやつ vs. `Data.ByteString.Unsafe` の `unsafe` がつくやつ
  - → `unsafe` がつかないやつはバイト列をコピーして `ByteString` を作るので O(n) かかる。`unsafe` がつくやつは直接 `ByteString` にぶっこむので O(1)
- `CString` 系 vs. `CStringLen` 系
  - → `CString` 系はヌル文字終端の列なので長さを見るのに O(n) かかる。`CStringLen` 系は長さとのタプルなので O(1)
- `unsafePackCStringLen` vs. `unsafePackMallocCStringLen`
  - → 受け取った `CStringLen` に対してファイナライザをつけない / つける

といった感じで選んでいきます。

構造体は、`mallocBytes` で適切な長さ分を確保し、`poke` でフィールドを埋めていきます。

JavaScript 側では、Wasm インスタンスの `exports` から `memory`、`malloc`、`free` を持ってきます。構造体は [`DataView`](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/DataView) でごにょごにょして、文字列は [`TextEncoder`](https://developer.mozilla.org/ja/docs/Web/API/TextEncoder) と [`TextDecoder`](https://developer.mozilla.org/ja/docs/Web/API/TextDecoder) でどうにかしましょう。

## ghc-wasm-meta 使用時の構成

ghc-wasm-meta 使用時はこんな構成になります。

↓ Haskell のエントリーポイント

```wasm/Main.hs
module Main where

import Data.Text (Text)
import Foreign.C.Types (CChar)
import Foreign.Ptr (Ptr)
import qualified MyProject.Main as MyProject

main :: IO ()
main = mempty

type MyFuncResult = Either Text Text

-- JavaScript から値を受け取るやつ
receiveText :: Ptr CChar -> Int -> IO Text
receiveText ptr len = ...

-- JavaScript に値を送るやつ
sendMyFuncResult :: MyFuncResult -> IO (Ptr MyFuncResult)
sendMyFuncResult result = ...

foreign export ccall myFunc :: Ptr CChar -> Int -> IO (Ptr MyFuncResult)

myFunc :: Ptr CChar -> Int -> IO (Ptr MyFuncResult)
myFunc inputPtr inputLen = do
  input <- receiveText inputPtr inputLen
  let result = MyProject.myFunc input
  sendMyFuncResult result
```

↓ Cabal のプロジェクト設定 (抜粋)

```myproject.cabal
executable myproject
  main-is: Main.hs
  hs-source-dirs:
      wasm
  ghc-options: -no-hs-main -optl-mexec-model=reactor -optl-Wl,--export=hs_init,--export=malloc,--export=free,--export=myFunc
```

↓ 型定義

```pkg/src/types.ts
export interface MyProjectInstance extends WebAssembly.Instance {
  readonly exports: MyProjectExports;
}

export interface MyProjectExports extends WebAssembly.Exports {
  readonly _initialize: () => void;
  readonly myFunc: (inputPtr: number, inputLen: number) => number;
  readonly malloc: (size: number) => number;
  readonly free: (ptr: number) => void;
  readonly hs_init: (argc: number, argv: number) => void;
  readonly memory: WebAssembly.Memory;
}

export interface Request<Method extends string, Params> {
  method: Method;
  params: Params;
  id: number;
}

export interface Response<Result> {
  result: Result;
  id: number;
}

export type MyProjectRequest = Request<"myFunc", MyFuncParams>;
export type MyProjectResponse = Responce<MyFuncResult>;

export type MyFuncParams = { input: string };
export type MyFuncResult =
  | { status: "success"; output: string }
  | { status: "error"; message: string };
```

↓ Wasm を呼び出すワーカー

```pkg/src/worker.ts
import { WASI, init } from "@wasmer/wasi";
import {
  MyFuncParams,
  MyFuncResult,
  MyProjectInstance,
  MyProjectRequest,
} from "./types";
import { Sender } from "./sender"; // Haskell に値を送るやつ
import { Receiver } from "./receiver"; // Haskell から値を受け取るやつ

const WASM_URL = new URL("../../out/myproject.wasm", import.meta.url);

const modulePromise = WebAssembly.compileStreaming(fetch(WASM_URL));

self.addEventListener("message", async (event: MessageEvent<MyProjectRequest>) => {
  if (event.data.method === "myFunc") {
    const result = await myFunc(event.data.params);
    self.postMessage({ result, id: event.data.id });
  }
});

const myFunc = async ({ input }: MyFuncParams): Promise<MyFuncResult> => {
  await init();
  const wasi = new WASI({});
  const instance = wasi.instantiate(await modulePromise) as MyProjectInstance;
  instance.exports._initialize();
  instance.exports.hs_init(0, 0);
  const sender = new Sender(instance.exports.memory, instance.exports.malloc);
  const receiver = new Receiver(instance.exports.memory, instance.exports.free);
  const [inputPtr, inputLen] = sender.sendString(input);
  const resultPtr = instance.exports.myFunc(inputPtr, inputLen);
  const result = receiver.receiveMyFuncResult(resultPtr);
  return result;
};
```

↓ ワーカーを呼び出す部分

```pkg/index.ts
import { MyFuncParams, MyFuncResult, MyProjectResponse } from "./types";

const worker = new Worker(new URL("./worker.ts", import.meta.url), {
  type: "module",
});

let globalId = 0;

const resolvers = new Map<number, { resolve: (value: MyFuncResult) => void }>();

worker.addEventListener(
  "message",
  (event: MessageEvent<MyProjectResponse>) => {
    const { resolve } = resolvers.get(event.data.id)!;
    resolvers.delete(event.data.id);
    resolve(event.data.result);
  }
);

export const myFunc = (params: MyFuncParams) =>
  new Promise<MyFuncResult>((resolve) => {
    const id = globalId++;
    resolvers.set(id, { resolve });
    worker.postMessage({ method: "myFunc", params, id });
  });
```

↓ ビルドスクリプト

```build.sh
#!/bin/bash
source ~/.ghc-wasm/env
wasm32-wasi-cabal build myproject-wasm
mkdir -p out
cp $(wasm32-wasi-cabal list-bin myproject-wasm) out/myproject.wasm
```

↓ GitHub Actions ワークフロー (抜粋)

```gh-pages.yml
      - name: Setup ghc-wasm-meta
        run: |
          git clone https://gitlab.haskell.org/ghc/ghc-wasm-meta.git
          cd ghc-wasm-meta
          FLAVOUR=9.6 ./setup.sh

      - name: Build WebAssembly
        run: |
          ./build.sh
```

## 実際に移行したもの

値の受け渡し含め、細かい実装はたくさんあるのですが、実際に移行したときのプルリクエストはこんな感じでした。

https://github.com/n4o847/nazuki-hs/pull/45

## おわりに

将来的には、GHC、Cabal、Stack のオプションや設定だけで WebAssembly が吐けるようになったり、Asterius や wasm-bindgen みたいに JavaScript との ABI が自動で実装されたりしてくれるといいなと思います。

ところで GHC Wasm バックエンド、一人の人がめちゃくちゃ頑張って実装してるのすごいですね……。