nanovgoはあのままのアーキテクチャだとダメそうで、スクラップ・アンド・ビルドしたいなぁと思ったりするので、とりあえず手を付けられそうなところからボトムアップで部品を作っています。
で、本題の行列演算。
nanovgoでは3x2の行列を使っています。2D変換の座標値のアフィン変換であれば3x2でOK。アニメーションとか興味ある人なら当然Adobe Flashはみんな使ったことあるはずで、今更書く必要もないんですが、Flashも同じ行列を使ってます。行と列は転置されていますが、まあ一緒。
Goで作るからにはふつうのネイティブ環境と、Gopher.jsの両方に対応したいのが人情というもの。で、ネイティブ環境だと精度は32ビットもあれば十分。もしかしたら16ビット固定小数点数でもいいかもしれないけどGoが非対応。で、Gopher.jsの場合はパフォーマンスTipsとしてintとfloat64以外は使ってくれるな、とREADMEに確か書かれていました。なので、ネイティブ環境とブラウザ環境で最速を狙うにはビルドタグで切り替える必要があります。じゃあ、どういうコードを書けばいいのかな、というを実験しました。
ちなみに、nanovgoはブラウザ対応はバーテックスバッファーのコピー以外には特別に手当はしてなくて、どっちもfloat32を使っていました。
コードはここにあります。
単純実装
まずはつぎのようなコードを作ってみました。メモリのヒープからのアロケートは遅いけど、スタックは別に遅くないし、要素数も少ないのでカジュアルにコピーでいいだろう、ということで全コピーのimmutableっぽい感じの実装にしました。次のコードは32ビットだけど、float64のも作ってベンチしました。
type Mat32 [6]float32
func (t Mat32) Multiply(s Mat32) Mat32 {
t0 := t[0]*s[0] + t[1]*s[2]
t2 := t[2]*s[0] + t[3]*s[2]
t4 := t[4]*s[0] + t[5]*s[2] + s[4]
t[1] = t[0]*s[1] + t[1]*s[3]
t[3] = t[2]*s[1] + t[3]*s[3]
t[5] = t[4]*s[1] + t[5]*s[3] + s[5]
t[0] = t0
t[2] = t2
t[4] = t4
return t
}
ベンチを取った結果です。
行列の実装 | darwin/amd64 | gopher.js |
---|---|---|
[6]float32 | 14 ns/op | 2060 ns/op |
[6]float64 | 18 ns/op | 1900 ns/op |
うーむ。確かにfloat64の方がgopher.jsの方が速いが・・・そもそもすごく遅い。ネイティブコードと150倍ぐらい遅い。
インラインで演算する実装
じゃあ、コピーじゃなくてインラインでやってみた方がいいのかな・・・と思って試してみた結果が以下の通り。
func (t *Mat32) Multiply(s *Mat32) {
t0 := t[0]*s[0] + t[1]*s[2]
t2 := t[2]*s[0] + t[3]*s[2]
t4 := t[4]*s[0] + t[5]*s[2] + s[4]
t[1] = t[0]*s[1] + t[1]*s[3]
t[3] = t[2]*s[1] + t[3]*s[3]
t[5] = t[4]*s[1] + t[5]*s[3] + s[5]
t[0] = t0
t[2] = t2
t[4] = t4
}
行列の実装 | darwin/amd64 | gopher.js |
---|---|---|
[6]float32 | 110 ns/op | 1700 ns/op |
[6]float64 | 99 ns/op | 1560 ns/op |
差は縮まりましたが・・・
結果を踏まえた実装方針
当初の予定どおり、ネイティブは32ビット、JSは64ビットの方が良さそうですが、それにプラスして、ネイティブの方はコピーを積極的に、JSの方はインラインで・・・という実装の仕分けをした方が良さそうです。
それにしてもこれだけJS実装の速度に差があると、計算結果のキャッシュは不可欠ですね。nanovgoは毎フレームキャッシュせずに毎回計算していたので、そこの構造にも手を入れる必要があります。
WebAssemblyとJavaScriptの違いとしてはfloat32の有無があるので、そのうちGoがWebAssemblyに対応してくれるようなことがあったら、また測定しなおしでしょうけど、とりあえずはこの方針でいきたいと思います。