↓ メインコンテンツへスキップ

遅いのは Windows ユーザーの自分だけだった — Langfuse のページ表示を数秒遅くしていた原因と、その修正がリリースされるまで

著者
健介 橘

要約
#

セルフホストしている Langfuse で、ページを開くたびに数秒フリーズする現象に悩まされていました。原因はコメント機能のリアクションピッカーが使っているライブラリで、import しただけで canvas による絵文字の描画判定が3,740回走っていました。まだコメント欄すら開いていなかったとしても 、です。

リアクションピッカーとは、Trace や Prompt の詳細画面で開けるコメント欄で、各コメントに絵文字で反応するための「😊+」ボタンから開く絵文字選択パネルのことです(画像参照)。

Langfuse のコメント欄で「😊+」ボタンを押すと開くパネル。赤枠の絵文字一覧部分が @ferrucc-io/emoji-picker の描画で、ボタン自体は Langfuse 側の UI
「😊+」ボタンは Langfuse 側の UI。押すと開く絵文字一覧(赤枠)が @ferrucc-io/emoji-picker の描画

最終的に、上流の emoji-picker ライブラリを修正して取り込んでもらい、Langfuse 側の依存更新までこぎつけました。以下、その過程を書きます。

発端: 遅いのは Windows ユーザーの自分だけだった
#

Langfuse のページ表示が遅いことは以前から気になっていましたが、ずっとそういうものだと思って使っていました。しかし、社内メンバーと雑談していたときに、同じ環境(=Langfuseサーバ)を使っている他のメンバーが誰もその遅さを認知していないことが分かりました。

同じセルフホストインスタンスにアクセスしているにも関わらず、メンバーごとに体感速度が違う! ここで環境差を疑いました。メンバーの中で目立つ差は1つで、自分だけが Windows をメインで使っていることでした。他は全員 macOS メインです。

「自分の環境だけ遅い」は、ともすると自分のマシンの問題として片付けられがちですが、ここではむしろ手がかりになりました。「自分の環境だけ(遅いときがある、ではなく)いつも遅い」ということは、何らかの必然的理由がある(そしてそれは OS などである可能性が高い)と思えたからです。

犯人の特定
#

Chrome の DevTools でページ読み込み時のプロファイルを取り、時間を食っている箇所を絞り込んでいきました。

行き着いたのは、コメント機能のリアクションピッカー ReactionPicker.tsx でした。このファイルは @ferrucc-io/emoji-picker を静的 import していました。

何が起きていたか
#

このライブラリは、モジュールスコープで filterSupportedEmojis() という関数を実行していました。プラットフォームが各絵文字をカラー絵文字として描画できるかを判定する処理です。

判定のやり方はこうです。2×2 ピクセルの canvas に絵文字のグリフを2回描画し(青と赤で1回ずつ)、それぞれピクセルを読み戻して色を比較する。色が変われば単色フォントでの描画、変わらなければカラー絵文字として描画されている、という判定になります。

問題は実行される回数です。unicode-emoji-json には絵文字が1,906個あり、そのうち1,870個が幅の事前チェックを通過します。つまり fillText と getImageData がそれぞれ約3,700回ずつ、同期的に実行されます。しかもこれはモジュールスコープなので、import 文を書いた時点で走ります。

計測すると、私の環境(Windows 11 / Chrome)では、DevTools 上で getImageData の呼び出しに帰属する累積時間が約5,200ミリ秒でした。これは直前の fillText によって遅延実行されたラスタライズ処理も含む時間であり、その間メインスレッドが5秒以上ブロックされていました。

さらに問題なのは、リアクションピッカーが Popover の中にあり、ユーザーがトリガーをクリックするまで表示されない点です。まだコメント欄すら開いていないのに、そのコメント欄にあるリアクションピッカーを準備するために、すべてのページ読み込みでこのコストが払われていました。

空振りした仮説: GPU アクセラレーション
#

原因の箇所は分かりましたが、「なぜ Windows でだけこんなに遅いのか」の説明にはなっていません。

最初に疑ったのは canvas の GPU アクセラレーションでした。getImageData は一般に GPU 上のバッキングストアから CPU 側へピクセルを読み戻す操作なので、この往復が遅いのだろう、という筋読みです。もっともらしく聞こえます。

検証は、変数を1つだけ動かす形で設計しました。Chrome を --user-data-dir で別プロファイルとして起動し、--disable-accelerated-2d-canvas の有無だけを変えた2条件を用意します。--disable-gpu を使わなかったのは、あれを付けると合成も WebGL もまとめて CPU に落ちてしまい、「canvas のアクセラレーションが原因」という主張の証拠にならないからです。実際にフラグが効いているかは chrome://gpu の Canvas 行が Software only に変わることで確認しました。

結果です。

条件getImageData 累積時間
Chrome の既定設定約 5,191 ms
Canvas アクセラレーション無効化フラグあり約 5,158 ms

差は 0.6% で、GPU からの転送が主因という仮説は支持されませんでした。あとから分かったことですが、このライブラリの判定用 canvas は willReadFrequently: true を指定して生成されていました。これは頻繁なピクセル読み出しを前提にブラウザがソフトウェア描画を選ぶ指定なので、そもそも GPU からの転送は起きておらず、フラグの有無で対象 canvas の描画経路が変わっていたとも限りません。GPU 仮説は、検証で棄却されたというより、前提から外れていたことになります。

さらに、getImageData 単体のマイクロベンチマークを取ると1回あたり約0.004ミリ秒でした。3,700回実行しても15ミリ秒程度にしかなりません。読み戻しはそもそもコストの本体ではなかったわけです。

コストの正体は、fillText が毎回強制するカラー絵文字グリフのラスタライズでした。ここが分かった時点で、「getImageData の呼び方を工夫する」といったマイクロ最適化には意味がないことが確定します。有効な修正は、この判定サイクルを import の評価パスから外すこと以外にありません(※根本的にこの描画検証処理自体を外すという選択肢もあるのかもしれませんが、それはだいぶemoji-picker側のポリシーに抵触する話なので、ここでは考えません)。

決め手: Firefox だけ速い
#

もう1つ、切り分けの決め手になった観測があります。社内で環境別に確認してもらった結果です。

環境結果
Windows + Chrome数秒の停止。明確に体感できる
Windows + Edge同様
Windows + Firefox体感できる遅延なし
macOS体感できる遅延なし

同じ Windows、同じフォント(Segoe UI Emoji)でも、ブラウザを変えるだけで結果が変わりました。Firefox も Windows 上では DirectWrite を使うため、OS やフォントだけではこの差を説明できません。Chromium 系で再現し Gecko では再現しないことから、ブラウザ側のカラーグリフ描画経路が関与している可能性が高いと判断しました。

ちなみに Linux 上のソフトウェアレンダリング環境でも計測しましたが、こちらは約400ミリ秒でした。約13倍の開きです。ただし重要なのは、私が作れた最も条件の良い環境でも import 時に400ミリ秒の同期処理が残るという点です。これは環境固有の問題ではなく、ライブラリの設計の問題です。

なお影響範囲としても、これは「特定環境の稀な問題」ではありません。Edge は Windows の既定ブラウザで、Chrome は最も一般的な代替です。Windows デスクトップのユーザーはほぼ全員が該当します。macOS では見えないので、今まで報告されてこなかったのだと思われます。

どこを直すべきか
#

修正できる層が3つありました。

  1. 自分のセルフホスト環境にパッチを当てる — ReactionPicker を遅延読み込みにすれば、初回ページ読み込みからコストを外せます
  2. upstream のライブラリを直す — モジュールスコープの判定をやめる
  3. Langfuse の依存バージョンを上げる — 2 が前提

1 はすぐ効きますが、自分の環境しか救われませんし、バージョンアップのたびに当て直しになります。計測結果が「判定を import 評価パスから外すしかない」を指している以上、本筋は 2 (と 3 )です。

そこで、1 を暫定対処として当てて遅延が解消することを確認したうえで、2 に着手しました。

upstream への PR
#

修正方針はシンプルです。判定ロジックと公開 API には一切手を触れず、モジュールスコープでの呼び出し(コンポーネント2箇所と Jotai の atom 1箇所)を、ピッカーが実際に描画されるタイミングまで遅延させるだけです。

実装は TDD で進めました。先に「パッケージを import しただけでは getImageData が1回も呼ばれない」ことを検証するテストを書き、修正前に確実に red になることを確認してから実装に入っています。NG事象 を再現できないテストは回帰防止として意味がないので、ここはちゃんと実施しました。

PR(ferrucc-io/emoji-picker#114 )はメンテナのレビューを1巡したうえでマージされ、0.1.2 としてリリースされました。修正後は、パッケージを import しただけでは getImageData が呼び出されず、ピッカーが実際に描画された時点で初めて判定処理が実行されます。

Langfuse への還元
#

Langfuse は @ferrucc-io/emoji-picker を ^0.0.47 で pin していました。semver の範囲外なので、0.1.2 は自動では入ってきません。明示的なバージョン更新が必要です。

CONTRIBUTING.md に従い、まず Issue として起票しました(langfuse/langfuse#16472 )。

Issue を書くときに意識したのは、自分にとって不利な事実を先に出すことでした。具体的には「macOS では全く再現しない」を概要の直後に明示しています。相手が macOS ユーザーだった場合、「手元で再現しない」と返されて話が止まるのが目に見えていたからです。先に自分で言っておけば、相手はその前提を織り込んで判断できます。

あわせて、両バージョンの公開 API を diff して破壊的変更がないことを確認し、それも本文に書きました。レビュアーが自分で確認しなければならない項目を減らしておくほど、判断は早くなると思われます。

結果として依存更新の PR(#16512 )がマージされ、修正が Langfuse 本体に入りました。

結末と、v3 を使っている方への注意
#

修正は Langfuse v4.19.0 以降に含まれています。該当バージョン以降にアップグレードすれば、この問題は解消します。

v3 系にはバックポートされていません。 v3 をセルフホストしたまま運用している場合、この問題は残ったままです。すぐに対処したい場合は、ReactionPicker を遅延読み込みにするパッチを自環境で当てるか、emoji-pickerへの依存を変更してLangfuse自体を rebuild & deploy すれば治るはずです(が、だいぶ面倒です)。

おわりに
#

「自分の環境だけ遅い」は、自分のマシンの問題として片付けてしまいがちです。しかし今回のように、環境差そのものが原因を絞り込む手がかりになることがあります。全員が同じように遅い場合より、かえって切り分けやすい面もあります。

そして、原因がライブラリ側にあると分かったとき、自環境のワークアラウンドで止めずに upstream まで遡ったことで、同じ問題を踏んでいたはずの他の利用者にも修正が届きました。OSS を使う側として、今後ともこうした還元の機会は拾っていきたいと思います。

余談
#

なお、今回の修正では emoji-picker の読み込みそのものは短縮していないので、 Windows 環境にてコメント欄の絵文字ボタンを初回押下したときに数秒待たされます…。そう頻繁に絵文字ボタンを押す人はいないと思いますが…。