Route54

技術や好きなものの話をします

go-graphviz が新しくなりました

TL;DR

goccy/go-graphviz が新しくなり、cgo を使って Graphviz の機能をバインディングしていたところを WebAssembly ( 以降 WASM ) ベースに置き換えたため、晴れて Pure Go になりました。

一部 API のインターフェースが少し変わっていますが、以前できたDOT言語の読み書きやSVGPNGとしての出力などは全て可能です。それに加え、図中の画像のレンダリングサポートやフォントまわりの改善、最新のGraphvizライブラリへの対応により以前よりもレンダリング処理が改善されています。
また、任意の出力フォーマットに対するレンダリング処理をプラグイン形式で外から追加できるようになったので、terraform graph の内容を読んで構成図を出力するとか、アスキーアート出力するみたいな特殊な処理が実装しやすくなりました。

Graphviz とわたし

Graphviz はグラフ構造を可視化するための手段として30年以上も使われているすごいライブラリです。Go もお世話になっていて、パフォーマンスチューニングをしたことがある人は一度は使ったことがあると思いますが、pprof で SVG 出力するのに使われていたり、go mod graph の可視化用に modgraphviz というツールがあったりします。

少し昔話をすると、自分が Graphviz を知ったのは 2010年頃で、当時大学の学部生で研究室に配属された後、研究室で開発していた Konoha というプログラミング言語から Graphviz の機能を利用するためのバインディングをした時でした。自分は研究室に配属されてからプログラミングを覚えたため、覚えたて4ヶ月目くらいで苦労しながら実装したのを覚えています。
それから、RubyPerlJavaObjective-Cなど様々な言語向けにC/C++言語のバインディングを書きながら、大体4年くらい前に Go でも cgo を使って何か作りたいと思い、どうせやるならということで、はじめてバインディングした Graphviz を選びました。

Go と WASM によるバインディング

WASI ( WebAssembly System Interface ) ができ、各言語から WASI ベースの WASM が生成できるようになり、それを動かすためのランタイムが各言語のサードパーティライブラリとして実装されている状況が整ってきました。Go でも Pure Go で実装された WASM ランタイムの wazero の登場により、WASM を組み込んだプログラムがより身近になりました。2021年に入った Go の embed 機能により、生成した WASM を Go コードに埋め込んで簡単にシングルバイナリとして配布することもできるのが WASM 環境の背中を押していると思います。

こういった状況から、個人的にはこれから作るC/C++言語のバインディングライブラリはほとんど WASM ベースになっていくんじゃないかと思っています。 さらに今後、WASI Preview2 が一般的になれば、コンポーネント化した WASM を扱えるようになるので、Go から WASM の機能を利用するために必要なグルーコード ( バインディングに必要なコード )をコンポーネントのインターフェースから自動生成することができるようになり、バインディングライブラリを作るために必要な処理のほとんどを自動生成できるような時代になるかもしれません。

go-graphviz がどのように WASM に移行したか

ここからは、自分がどのように go-graphviz を cgo から WASM ベースに移行したかの記録です。

Go で WASM ベースのバインディングを行うには、具体的には次のようなステップが必要です。

  1. ライブラリをビルドするときに出力ターゲットを WASM にする
  2. Go からライブラリのAPIにアクセスするために、アクセスしたいシンボルを Export する。このとき、そのまま Go から呼び出せないAPIの場合は、型変換処理をはさむために一度ラッパー関数を作成する必要がある。また、C++ の場合は Export するシンボル名を demangle する必要もある
  3. 2 で用意したブリッジ処理(グルーコードと言ったりします)を含めて WASM を作れるようにする
  4. Go 側で、wazero を通してC言語の型とGoの型を相互変換する処理を実装する

もう少し詳しく解説すると、以下のような処理が必要になります。

ライブラリをビルドするときに出力ターゲットを WASM にする

WASM ベースのバインディングを作る上でまず WASM を生成できないと始まらないのですが、これが結構難しいです。 C/C++言語を WASM にするには、wasi-sdk や emcc や em++ といった emscriptenコンパイラツールを使う方法があると思うのですが、C/C++言語のビルドはライブラリによって configure や CMake、 Bazel など独自の方法を採用しているため、それぞれのビルド方法を把握した上で、コンパイラやその他のビルドオプションを WASM ように切り替える必要があります。 Graphviz は configure を利用したライブラリになり、wasi-sdk の Docker Image を利用したコンテナ上で configure をたたけば必要な config.h が生成されるので、それとライブラリのビルドに最低限必要なソースコードを集めた Makefile を自作して対応しました。

ZetaSQL を WASM にする際は、ZetaSQL が Bazel ベースのツールになっているので、BUILD.bazel ファイルから Makefile を自動生成するための bazel2makefile というツールを作って対応しました。

もちろん Makefile を作らなくてもビルドする方法はあると思いますが、いずれにせよ何らかの方法で WASM のビルドに必要なオプションを設定できる状況に持ち込む必要があります。

バインドしたいシンボルを Export する

例えば math.h に定義されている double sqrt(double x) をバインドするだけならば、Go の float64 型をそのまま WASM 側に渡して処理できるため、math ライブラリにある sqrt シンボルをそのまま Export すれば良いだけです。

しかし、C言語には関数ポインタや構造体の実体を引数にとる関数があり、これらは直接 Export することができません。 他にも、構造体のメンバ変数にアクセスするためにはメンバ変数ひとつひとつに対して Getter / Setter 用の関数を Export する必要があります。

ここでは、一番難しい関数ポインタの例について詳細に解説してみます。 C言語は関数を第一級オブジェクトとして扱うことができないので、(JITを使わない限りは)すべての関数がコンパイル時点で確定している必要があります。コンパイル時点で確定する必要があるということは、WASMを生成する時点で存在する関数しか呼び出せないことになります。一方、Go は関数を第一級オブジェクトとして扱えるため、渡す関数は実行時まで定りません。言い換えれば、Go と C の関数が N : 1 の関係になってしまいます。このギャップを埋めるために、グルーコードを生成する必要があります。

ここで、例としてC言語で引数に void(*)(void *) 型の引数をとる関数 void hello(void *, void(*fn)(void *)) をバインドすることを考えてみます。第一引数はレシーバ変数で、コールバックされた際はコールバック関数の第一引数にレシーバ変数が渡されるような挙動をする関数だとします。 直接 Go 側で fn に相当する関数を作ることは難しいため、あらかじめ グルーコードとして以下のようなコードを用意しておいて WASM にします。

void wasm_bridge_callback(void *);

static void callback(void *arg) {
  return wasm_bridge_callback(arg);
}

void wasm_bridge_hello(void *recv) {
  hello(recv, callback);
}

上記のコードが何をしているかというと、hello を直接呼び出す代わりに、そのラッパーである wasm_bridge_hello を呼び出します。すると中では C言語であらかじめ定義された callback 関数を引数に hello 関数が呼び出されます。 hello の処理の過程で callback が呼び出された際、その関数の中で今度は wasm_bridge_callback 関数を呼び出しています。 この関数の実体は C言語側で定義しておらず、WASMのランタイムを初期化する際に Host である Go 言語側から渡されます。 WASM の Host Functionsという仕組みです。これで Go 側に処理が戻ってくることになるのですが、まだ問題となる N : 1 問題は解決していません。Go 側でどんな関数を hello の引数に指定しても、同じ関数がコールバックされてしまうからです。

これを解決するために、 Go 側でもグルーコードを用意します。

env.NewFunctionBuilder().WithGoModuleFunction(
  api.GoModuleFunc(func(ctx context.Context, _ api.Module, stack []uint64) {
    arg0 := stack[0] // コールバック関数の第一引数に相当する
    funcID := getCallbackFunctionID(arg0)
    if fn, exists := callbackFunctionMap[funcID]; exists {
      fn(uintptr(arg0))
    }
  }),
  []api.ValueType{api.ValueTypeI32},
  []api.ValueType{},
).Export("wasm_bridge_callback")

上記のコードは wazero を利用して Go 側でコールバック用のホスト関数を登録している処理の疑似コードです。 stack という変数にコールバック関数の引数が格納されています。 この引数の値から、コールバック関数を一意に決定するための ID ( funcID ) を作成します。 ここで、事前に以下のように Go 側で Hello 関数を呼び出す際に ID と関数の組み合わせを登録しておくことで、ID に対応した関数を見つけることができる仕組みです。これで晴れて Go と C のコールバック関数の関係が 1 : 1 になります。

var getCallbackFunctionID func(uint64) uint64

var callbackFunctionMap = make(map[uint64]func(uintptr))

func init() {
  getCallbackFunctionID = func(arg uint64) uint64 {
    return arg
  }
}

func Hello(recv uintptr, fn func(uintptr)) {
  callbackFunctionMap[uint64(recv)] = fn
  call("wasm_bridge_hello", recv)
}

このとき、 getCallbackFunctionID が引数のアドレスをそのまま funcID に使っているので、 Hello を呼び出す際に指定する funcID も同じアドレスである必要があります。( この例ではどちらもレシーバ変数のアドレスなのでうまく動きます )

ただしこのアプローチの背景には、「コールバック関数の引数には基本的に呼び出した際に使用した情報が含まれているもの」という前提があり、このルールの外にある関数へは対応できません。

このように、単純なライブラリを除いてC言語側とGo言語側にグルーコードを作成する必要があり、手作業でやると凄まじい作業量になるので自動化が必要です。

go-graphviz では nori というツールを作って対応しました。 Protocol Buffers でバインド対象のスキーマや型変換のための情報を表現して、それをもとに Go と C のグルーコードを生成するツールです。今後、ZetaSQL のバインディングでも使用する予定で、C++用の処理まで完成したら独立したツールとしてリリースする予定です。

今後

go-graphviz を WASM ベースにしたいという構想自体は何年も前からありましたが、Go での状況が整ってきたことと、go-zetasql を WASM ベースに移行する上でより簡単なもので実験したかったという事情で今回のアップデートに至りました。

まだバインディング時のメモリリークの修正やスレッドセーフにする対応などやらなければいけないことはありますが、今の状態でも普通に使うぶんには困らないので少しずつやっていこうと思います。また、ビルド済みのWASMファイルを直接使うことにセキュリティ的に懸念がある方もいると思うので、GitHub Artifact Attestations を使って WASM ファイルが CI 経由で作られたものだと証明することも試そうかなと考えています。

おわりに

今回アップデートの実装にあたり、本来は9月中に作りきろうと思っていたのですが、9月の頭に子供が生まれ、2ヶ月間の育児休暇を取らせてもらって、育児の隙間をぬって少しずつ進めていたため10月末のリリースとなりました。 これからもOSS開発を頑張っていくつもりですので、拙作のライブラリ群をお使いの方で出産祝いを考えてくださった心優しい方のために GitHub Sponsors へのリンクを置いておこうと思います。ご検討ください...!