このホームページのバックエンドをudzura/picoruby-cloudflare-worker-wasmに変更した

このホームページのバックエンドをmrubyedge/uzumibi + mrubyedge/mrubyedge から udzura/picoruby-cloudflare-worker-wasm へと切り替えました。引き続きudzuraさんプロダクトのお世話になっております。

ブラウザ側では以前からPicoRuby(のWebAssembly)が動いていたため、エッジ側もPicoRubyに揃えることで、一貫性があがるということと、Cloudflareの各種機能対応が現状でさらに揃っているポイントからでした。

移行ポイント

小さいホームページアプリケーションでほとんどツッからなかったのですが、記事データのパース速度だけ問題になりました。

以前のuzumibi(mrubyedge)版では、JSONのパース処理をRubyの外側(Rust層)で高速に行っていたため、約16msと1フレームで処理できていました。

しかし、PicoRubyに切り替えると、20KB程度の日本語記事のパースに1.1秒かかりました。

この大きな要因は、UTF-8マルチバイトによる O(n^2) の発生で、PicoRubyのUTF-8モードでは、マルチバイト文字を含む文字列の「i 文字目」を取得するたびに先頭からバイトを走査し直すため、O(n) のコストがかかります。これを n 文字分繰り返すことで、全体の計算量が O(n^2)になるということです。

施したこと

そこで、PicoRubyにそもそもJSONを直接パースさせないための細工を施しました。

それは、独自トークン形式(.tok)への事前変換で、ビルド時(CRuby)に、JSONの代わりに区切り文字でつないだ平坦なトークン列(.tok)を出力しておき、PicoRuby側では String#split で一気に配列化し、再帰的にデコードするということです。

この細工により、4msに落とし込むことができました。

追記

その後、picoruby-json自体に仮パッチをあて、JSON.parseの根本原因を直しました。@indexを文字位置でなくbyte位置として扱い、String#getbyte/String#bytesliceだけで読み進めるようにしたことで、毎回先頭から走査し直す必要がなくなり、O(n^2)がO(n)近くまで改善しました。これにより.tokへの変換は不要になり、エッジ・ブラウザとも素のJSON.parseへ戻しました。

ローカルでの実測(同一の日本語混じりJSON、同一build):

サイズ パッチ前 パッチ後 倍率
84,671 byte 33.361秒 0.153秒 218倍
204,271 byte 196.231秒 0.458秒 429倍

追記2

あわせてローカルで動かす管理画面のバックエンドもPicoRuby化しました。こちらでは自前のWebサーバーで動かしています。

この際、picoruby-socketのTCPServerで、accept()を非blockingで行うため一時的にlistening socketへO_NONBLOCKを立てるのですが、BSD系カーネル(macOS含む)では、その間にaccept()された側のsocketもO_NONBLOCKを引き継いで、本来blockingのはずのrecv/sendが、相手からの続きのデータがまだ届いていないだけでEAGAIN即失敗し、RuntimeError: read failedになる現象を見つけました。

これによって、読み込みが途中で切れて画面が真っ黒になることが起きていたのですが、accept()直後にclient_fdO_NONBLOCKを明示的に外すように修正することで回避できるようになりました。