LoginSignup
8
3

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

Last updated at Posted at 2023-07-01

はじめに

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

そこで、

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

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

Asterius とは

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 です。

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 種類の方針があると思います。

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

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

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

色々あるのですが、

  • Data.ByteStringunsafe がつかないやつ vs. Data.ByteString.Unsafeunsafe がつくやつ
    • 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 から memorymallocfree を持ってきます。構造体は DataView でごにょごにょして、文字列は TextEncoderTextDecoder でどうにかしましょう。

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

実際に移行したもの

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

おわりに

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

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

8
3
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
8
3