はじめに
趣味で作っているアプリケーションを Haskell で書いていて、これをブラウザで動かせるようにしたいというのがありました。
そこで、
- Haskell で書いたコードを、WebAssembly にコンパイルすることでブラウザ上で呼び出せるようにする
- そのための諸々の手順を GitHub Actions で自動化しつつ GitHub Pages にデプロイする
というのをやるために、かつて Asterius という Haskell → WebAssembly コンパイラを使っていたのですが、最近これが GHC 本体にマージされたという話があり、移行することにしました。
Asterius とは
Haskell から WebAssembly へのコンパイラです。
Asterius を動かしてみた人の情報としては、以下が詳しいと思います。
- AsteriusでHaskellの関数をJSから呼べるようにしてみた(けど失敗)(拡大版) - Haskell-jp
- Haskell 製の自作のトランスパイラを Asterius で WebAssembly にコンパイルしてみた - うさぎ小屋
Asterius 使用時の構成
MyProject
というパッケージの myFunc :: Text -> Either Text Text
という関数を呼び出したいとします。
Asterius 使用時はこんな構成でした。
↓ Haskell のエントリーポイント
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 生成物の呼び出し部分
// @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);
},
};
}
↓ ビルドスクリプト
#!/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 ワークフロー (抜粋)
- name: Build WebAssembly
uses: docker://terrorjack/asterius
with:
entrypoint: ./entrypoint.sh
Asterius のつらみ
GHC 8.10 で止まっている
Asterius の Docker イメージは Docker Hub に上がっていますが、更新は 2021 年 1 月で止まっており、ここに含まれている GHC のバージョンは 8.10 です。
Docker イメージがデカすぎる
上のイメージを落としてくると 20 GB 以上の大きさになります。
GitHub Actions でイメージをダウンロードするのに長い時間がかかる上に、GitHub Actions で使えるディスク容量が大体 14 GB くらいらしいため、バージョンによっては no space left on device
エラーでイメージを使えませんでした。
ghc-wasm-meta とは
Asterius を作っていた人が GHC 本体にその機能をマージすることになったのですが、現状 GHC のオプションなどで WebAssembly をターゲットにコンパイルすることはできません。WebAssembly を吐き出せるように GHC を設定したものがこのリポジトリです。
ghc-wasm-meta を動かしてみた人の情報としては、以下が詳しいと思います。
ブラウザ上で WASI
WebAssembly System Interface (WASI) をブラウザ上で動かすには、ghc-wasm-meta で紹介されている
や Ruby on Browser で使われている
が使えます。ここでは @wasmer/wasi
を使いました。
WASI reactor モード
WASI には command モードと reactor モードがあって、CLI のように main
関数を呼び出して終了する (内部的には _start
関数) のが command モード、起動後 (内部的には _initialize
関数) に自由に関数を呼び出せるのが reactor モードです。
当初 command モードを使い、標準入出力を使って値の受け渡しをしようとしていたのですが、Haskell 内で標準出力や標準エラー出力に出力しようとすると WASI の poll_oneoff
が無限に呼ばれ続ける不具合が出たので諦めました。
reactor モードでは値を直接受け渡しすることができますが、文字列や構造体など数値以外のものを渡そうとすると、Wasm のメモリを介した ABI を適当に自分で定める必要があります (Asterius や wasm-bindgen が勝手にやってくれていた部分)。
ABI を定める
文字列や構造体を JavaScript と Haskell 間で送り合うために ABI を定めます。大体以下の 2 種類の方針があると思います。
- ひとつの連続した領域にシリアライズする
- C 言語的なレイアウトで、固定長の構造体や可変長のバッファを
malloc
する
ここでは 2. を採用しましたが 1. でもいいと思います。
Haskell 側で文字列を受け取るのに使えるのは Data.ByteString.Unsafe
の unsafePackMallocCStringLen
や unsafeUseAsCStringLen
といった関数です。
色々あるのですが、
-
Data.ByteString
の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
でごにょごにょして、文字列は TextEncoder
と TextDecoder
でどうにかしましょう。
ghc-wasm-meta 使用時の構成
ghc-wasm-meta 使用時はこんな構成になります。
↓ Haskell のエントリーポイント
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 のプロジェクト設定 (抜粋)
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
↓ 型定義
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 を呼び出すワーカー
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;
};
↓ ワーカーを呼び出す部分
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 });
});
↓ ビルドスクリプト
#!/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 ワークフロー (抜粋)
- 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
実際に移行したもの
値の受け渡し含め、細かい実装はたくさんあるのですが、実際に移行したときのプルリクエストはこんな感じでした。
おわりに
将来的には、GHC、Cabal、Stack のオプションや設定だけで WebAssembly が吐けるようになったり、Asterius や wasm-bindgen みたいに JavaScript との ABI が自動で実装されたりしてくれるといいなと思います。
ところで GHC Wasm バックエンド、一人の人がめちゃくちゃ頑張って実装してるのすごいですね……。