Mackerelにおけるフロントエンドのパフォーマンス改善の取り組み

この記事は、はてなエンジニアアドベントカレンダー2016の14日目の記事です。13日は id:astj による『Perl 6 のモジュールエコシステムの話とモジュールを公開する話 (2016年12月版) - 平常運転』でした。

こんにちは。Mackerelチームでアプリケーションエンジニアをやっている id:itchyny です。

Mackerelは、同じ役割を持つホストを束ねた「ロール」、そしてロールを束ねた「サービス」というまとまりでホストを管理し、一覧性の良いグラフ画面を提供しています。 ロールあたりのホスト数、そしてサービスあたりのロール数が増えると、グラフの画面のパフォーマンスに大きく影響します。 Mackerelチームでは大規模なサービスでも快適にグラフを閲覧できるように、継続的に画面のパフォーマンスを改善してきました。

本記事では、Mackerelのフロントエンドのパフォーマンスを改善する過程で得られた知見をご紹介します。

パフォーマンスを改善するためにやったこと

計測する

計測することはパフォーマンス改善の最初の一歩として、とても大切です。 計測しなければ、なぜその箇所を改善するのかを他の人に伝えることができません。

JavaScriptのパフォーマンスを計測するには、ブラウザーのプロファイラーを使うのが手軽だと思います。 実行している全てのコードに対して、実行時間やコールスタックを表示してくれます。

ところが、重厚なWebフレームワークの上に乗っていると、本当に重いところになかなかたどり着くことができません。

f:id:itchyny:20161215135409p:plain

複雑なコールスタックを展開していくと、実行時間が支配的ではなくなって本当にチューニングする必要があるかよくわからなくなります。 表示されるのはフレームワークの関数なのだけど、その関数が呼ばれるきっかけを作っているのは自分たちのコードである、そういう場合には本当に悪い箇所にたどり着くのが困難になります。 フレームワークのとある関数が重いと分かっても、自分が渡している引数のせいで重くなっていると表示されているケースでは引数毎に実行時間を集計したくなるのですが、そういうことはできません。

フレームワークを使っていると、JavaScriptの処理系からはまずフレームワークの関数が見える状態になり、私たちが書いたコードはフレームワークの複雑な処理に隠れてしまいます。 運良くコールスタックを辿ることができて、自分たちのコードの重いところや、必要以上にフレームワークの関数を呼びすぎている箇所を特定できれば、うまく改善できるでしょう。 しかし、私たちがやりたいことは、そんなに苦労してブラウザーのプロファイル結果を掘り起こして原因を頑張って見つけるということではありません。

改善したいコードが自分たちのコードならば、自分たちのコードに限ってプロファイルをとればよいのです。

パフォーマンスのプロファイラーを自分で書く

JavaScriptのパフォーマンス改善に本気で取り組んでくださいと言われたその日のうちに、ブラウザーのプロファイラーでは知りたいことが何もわからないということに気が付き、その夜にはHaskellでプロファイラーを書き始めていました。

人に伝えやすいのでプロファイラーと言っていますが、正確には「プロファイルをとるコードを注入するトランスパイラー」です。 JavaScriptのコードをパースして構文木にし、全ての関数の最初と最後に、実行にかかった時間を計測するコードを挿し込んでくれます。 変換したコードを読み込めば、console.logで重い関数を表示してくれます。 やってることは単純なので半日くらいで書くことができました。 実際に、次の日からsjspでプロファイルを取り、いくつも重い処理を改善してきました。

ブラウザーのプロファイラーで重いと分かるのがフレームワークの関数だったとしても、実際に重い処理が自分が書いたコードというのはよくあることです。 私たちが書いたコードは複雑なコールスタックの波に飲み込まれ、ブラウザーのプロファイラーの中では霞んでしまうかもしれません。 フレームワークの作者ならいざ知らず、自分のコードのチューニングしたいなら自分のコードだけのプロファイルを取るべきです。 実行時間で支配的なコードが自分が書いたコードだと気がつかずに、なぜかフレームワークの関数ばかり表示するブラウザーのプロファイル結果ばかり眺めてフレームワークの批判をするのは的はずれです。

  • どのコードのプロファイルを取りたいのかきちんと考えよう
  • 計測は大事。よい計測ができないならば、よい計測ができるツールを作ればよい

LIMITする・グラフの簡略化

Mackerelでは1ロールに何ホストでも登録できるようになっており、特に制限は設けていません。 ロールに属する全てのホストの情報を返すようになっていると、数百ホストを超えるロールが複数存在するような大規模な使い方に対応できません。 ロールのグラフのホストの一覧では、現在では一定数以上を超えると全ては返さずに、ページングのあるホスト一覧画面へのリンクが表示されます。

また、1つのロールのホストが1000を超えるとメトリックの線が重なりグラフが見にくくなり、ブラウザーの描画も負荷がかかるようになります。 かつては全て描画していたのですが、グラフの描画に時間がかかるという問題があったため、今はグラフを簡略化しています。 ホストの数が多い場合、折れ線グラフでは最大と最小と平均の三本が、積み重ねグラフでは合計の一本が表示されるようになっています。 ユーザーは必要に応じて簡略グラフと全てのメトリックを表示するグラフを切り替えることができます。

  • 仕様で上限がないものがあれば、一定数以上はページングのある画面で見てもらおう
  • 情報が落ちることは覚悟して、最小でどういう情報があれば役に立つかを考えよう

JSONの構造を変更する

Mackerelのフロントエンドは、できるだけサーバーサイドよりもクライアントサイドでレンダリングするという方針で設計しています。 ホストのステータスや表示するグラフや期間の変更、監視ルールの作成や更新・削除、グラフのチャットツールへの共有など、多くの操作が画面遷移せずにXHRで通信し、画面を動的に更新するからです。 最初からクライアントサイドでレンダリングするようにしておくと、定期的にアラートの状態を取得して画面を更新して欲しいといった要求にも簡単に対応できます。

Mackerelではページを開いた時に多くの情報をJSONで取得しています。 特に、サービスのグラフ一覧画面では画面の描画に必要な情報がたくさんあり、そこで叩いているJSONが肥大化するという問題がありました。

JSONが肥大化した時に考えることは3つあります。 まずは不要なデータがないか調べること。 開発が何年も続く中で、かつて画面に表示していたものをいつの間にか出さなくなったのに、まだクエリを叩いてJSONに入れていた、そういうものが残っている場合があります。 昔から叩いているJSONは特に注意して調べて、不要な情報があれば削りましょう。

もう1つは分割すること。 XHRの中である一つのJSONが飛び抜けて大きく、そのサイズの半分を超えるまとまった情報があるならば、それを単体のJSONとしてAPIを分けると良いでしょう。 データを並行に取りに行くとXHRにかかる時間が半減します。 実際、グラフの一覧画面ではホストのデータのJSONを別のAPIに切り出しました。 ただし、分割しすぎると共通の処理で無駄にクエリを叩くことになり、またXHRが増えてかえって遅くなることもありますので、全てを細かく分割しろと言っているわけではありません。

そして、重複しているデータがないか考えるということです。 例えば次のような構造のデータがあったとします。

{
  "monitorsByRoleId": {
    "roleId0": [ { monitor0 }, { monitor1 }, { monitor3 } ],
    "roleId1": [ { monitor1 }, { monitor2 }, { monitor3 } ],
    "roleId2": [ { monitor1 }, { monitor2 }, { monitor3 }, { monitor4 } ],
    // etc.
  },
  // etc.
}

monitorN のデータが大きくて、かつ重複も多かったので、次のようにJSONの構造を変更しました。

{
  "monitorsById": {
    "monitor0Id": { monitor0 },
    "monitor1Id": { monitor1 },
    "monitor2Id": { monitor2 },
    "monitor3Id": { monitor3 },
    "monitor4Id": { monitor4 },
    // etc.
  },
  "monitorIdsByRoleId": {
    "roleId0": [ "monitor0Id", "monitor1Id", "monitor3Id" ],
    "roleId1": [ "monitor1Id", "monitor2Id", "monitor3Id" ],
    "roleId2": [ "monitor1Id", "monitor2Id", "monitor3Id", "monitor4Id" ],
    // etc.
  },
  // etc.
}

この変更によって、監視ルールを多く設定している場合にJSONのサイズを大きく減らすことができました。 しかも元と全く同じ情報を簡単に再構築することができます。

  • 昔からあるAPIには注意し、現在のテンプレートに不要なデータを引いてないか調べよう。
  • JSONのサイズを注視して必要であれば分割しよう。サーバー側のアプリケーションのシリアライズ、サーバーの転送量、ネットワークの帯域、ブラウザーでの展開処理速度など、様々な場所に影響します。
  • JSONのデータが冗長ではないかを確認すること。重複があればまとめる。一覧で全ての中身を返している時は、idの一覧にして中身は別に持つことを検討する。それでデータサイズが減るならば、JavaScriptの処理が複雑になりすぎない範囲で改善する。

フレームワークに対する理解を深める

MackerelではAngularJS 1を採用しています。 パフォーマンスについて語っているのにAngularJS 1を使っているのかと首をかしげるかもしれませんが、Mackerelの開発が始まった頃は今を時めくReactもそこまで有名ではなく、当時の状況を考えれば良い選択をしたと思います。 おかげさまでこれまで簡単にテンプレートを作ったり、インタラクティブなフォームを効率よく作ることができました。 フレームワークというのはロックインが怖いもので、何万行もコードが積み上がったらやめるのが大変になります。 そういう状況になると、何か月もかかるであろう移行を考えるよりも、まずは使っているフレームワークと真摯に向き合う姿勢が大事なのです。

フレームワークに重い処理をさせている大元は、私たちが書いたコードです。 AngularJS 1を使っているとついつい重い処理のコードを書きがちという主張には同意しますが、行儀よくコードを書いてパフォーマンスを引き出す手法はいくつもあります。 ハマりどころが多くて重くなりがちだという批判は理解できますが、フレームワークの特性も知らずに何のチューニングもしていないのに、フレームワーク自体のコードが重いと思ってしまうのはよくありません。

AngularJS 1を使ったアプリケーションのパフォーマンス改善は、$rootScope.$digestが呼ばれる回数をどれだけ減らすかにかかっています。 AngularJS 1のコードを読んだり、sjspを使って何度もプロファイルを取る過程で (angular.jsに限ってプロファイルを取ることもできる、その柔軟さが好きです)、ようやくこのことに気が付きました。 一般に$watchの数は2000におさえるべしと言われますが、仮にそれを守ったとしても、ページの初期化時に何十回も$rootScope.$digestが呼ばれるようでは結局遅くなってしまいます (もちろん$watchの数をおさえるのも大事です)。

では、$rootScope.$digestはどういう場面で呼ばれるのでしょうか。そしてどうすればいいのでしょうか (AngularJS 1に興味がなければ読み飛ばして頂いて構いません)。

  • $scope.$apply: $scope.$digestで済むならばそれを使いましょう (もちろんこれらの違いをきちんと理解する必要はあります)。どうしても$rootScopeで更新したい、しかもそれが数十個あるという場合は、$applyAsyncを使えば1回になるかもしれません。
  • $timeout: タイムアウト解決時に$rootScope.$digestが呼ばれます。setTimeout + $scope.$digst で代用できないか考えましょう。特にscopeの変数に関係なければ、setTimeoutで十分な場合もあります。
  • $http: レスポンスを受け取った後に呼ばれます。これによって、scopeの変数にデータを代入してDOMが更新されるので、必要な場面もあります。しかし、複数のJSONをそれぞれ$httpで取得してscopeに代入するよりも、window.fetch$qでまとめたほうがパフォーマンスは良いでしょう。$httpを沢山使っているけど全て変更するのは大変という場合は、$httpProvider.useApplyAsync(true);を呼んでおけば少し良くなるかもしれません。
  • $q: 解決する時に$rootScope.$digestが呼ばれます。これが呼ばれることによってDOMが更新されるので、必要だという場面もあります。必要でない場合はPromiseを使えばいいかもしれません。

$rootScope.$digestが呼ばれるきっかけはたくさんあります。 DBを叩く時にN+1を避ける慎重さがあるならば、「N+1 $rootScope.$digest」を避けるくらい慎重にコードを書くこともできると思います。 上記のように$rootScope.$digestがどこから呼ばれるかをリストアップできるのは、丁寧にconsole.traceなどでコードを追ったからです。 内部で$scope.$apply$timeoutを多用しているプラグインは避けるほうが良いでしょう。 特にキー入力のたびに$scope.$applyを呼ぶようなものは要注意です。

他にも細かい技はいろいろあります。 AngularJS 1を使う上ではどれも基本的なことですが、思いつく限り書いておきます。

  • deep watchが必要なければshallow watchにする (データ変更がオブジェクトの参照が変わるときのみである場合)
  • $watchする必要があるのが一時的ならば、丁寧にunwatchする
  • one time bindingを検討する
  • 複雑なテンプレートで、ユーザーがボタンを押して表示をトグルするものがあれば、最初はng-ifで隠しておく (scopeには気をつけつつ)
  • 属性 (attrs) を$watchするときは、linkで$watchするのではなくてcompileで$parseした結果を$watchする

以下の動画はAngularのパフォーマンスについて理解を深める上で役に立ちます。

つらつらと書いてきましたが、いまさらAngularJS 1を使うという選択肢はまずありえないでしょう。 パフォーマンスが要求される複雑な業務アプリケーションならば尚更です。 パフォーマンスを重視したフレームワークが色々とあるのも知っています。 これまで積み上げてきたAngularJS 1ベースのたくさんのコードやテンプレートを移行するのは苦労することが予想されるうえに、特にこれといった決定打となる移行先もないため、どういうふうに脱出するかは検討している段階です。 これまで開発スピードを維持してこれたフレームワークを選んだ先人に感謝しつつ、冷静に次へのステップを考えましょう。

  • 世間で重いと言われているからとそうなのだと諦めるのではなく、きちんとフレームワークと向き合い、特性を理解した上でチューニングしよう
  • 先人には感謝しながら、より良い道を模索しよう

スタイルはCSSに任せる

スタイルを設定するのはCSSの十八番です。 画面の初期化時に要素の高さをJavaScriptで取得して、別の要素のサイズを設定するというのはあまりいいやり方ではありません。 そういう処理が、ページの初期化時に数十回走るようであれば、まず見直したほうがよいでしょう。 少しデザインを変更してCSSでやれば初期化のパフォーマンスが改善するという場合は、仕様変更を検討してみましょう。 私はcalc()vhが好きです。

  • CSSでできることはCSSでやりましょう

V8の最適化アルゴリズムを調べる

JavaScriptのパフォーマンスについて考える上で、その実行処理系に目を向けるのは自然なことです。 JavaScriptはゆるふわな言語で色々なことができますが、きちんと節度を持ってコードを書けば、実行処理系の最適化の恩恵を受けられる可能性があります。 例えばHidden classを意識して後からプロパティーを付け加えることはしないとか、頻繁に呼ばれる関数の中ではtry catchを使わないといったような話です。 以下の動画はとても刺激的です。

機能がもたらす利点と欠点を考える

パフォーマンスの悪い機能があって、チューニングではどうにもならないとしましょう。 その機能を取るかどうかはどうやって判断すればいいのでしょうか。

そういう時は、どれくらいの人たちがその機能を便利だと思い、どれくらいの人の利便さを損なっているかを考えます。 あるプロダクトにおいて、そこまで重要ではない機能、あるいは他の機能で補えるものを考えます。 5%の人が便利に思っているものでも、95%の人にはなくてよい機能であり、その機能によって全員のページロード時間が倍以上かかっているとします。 パフォーマンスチューニングでなんともならないのであれば、その機能は削るのがよいでしょう。 なおこれはあくまで私見であり、チームの意思決定や開発体制、プロダクトオーナーの意向など、様々な要因によって左右されます。

  • 重くてどうにもならない機能は、どれくらいの人が便利に使っているかを考えた上で削るかどうか考えましょう

データの特徴を使ってよいアルゴリズムを模索する

何らかの重い関数がある時は、その典型的な入力データにどういう性質があるかを考えましょう。 実はデータストアから引いた時点でソートされていたりしませんか。 効率の良い探索方法があるかもしれません。 実はその行列の要素はほとんど0だったりしませんか。 行列演算が速くなるかもしれません。

コードを書く上で、見ている変数にどういう性質があるのかを考えるのは大事なことです。 典型的なデータのサイズやコードが呼ばれる頻度、アルゴリズムを変えることで得られる恩恵の大きさ、コードが必要以上に複雑にならないか、これらのバランスを考えながら改善していくとよいでしょう。

よいアルゴリズムはよいデータ構造から、そしてデータ構造は要素の性質から作られます。 順番の揃った配列から素早く要素を見つけられるのは、要素間で大小を決定できるからです。 ハッシュを作って高速にアクセスできるのは、各要素に対して性質のよいハッシュ関数が存在するからです。 集合に付与される性質と、それによって作られるデータ構造があるから、よいアルゴリズムがあるのです。

まとめ

パフォーマンス改善の基本はどんな言語でも、どんなレイヤーでも同じです。 計測することと、支配的な箇所から改善することです。 その手法は様々ですが、既存のプロファイル手法に囚われずに、本当に計測したいものを計測してくれるツールを作るという勇気を持つことは大事なことです。 既存の挙動を変えずに行える改善もありますが、同じ挙動を維持することが難しい場合もあります。 開発期間が長くなり、機能が増えれば重くなるのはあたりまえのことです。 時には仕様やデザインを変え、また時には表示する情報を落とし、先輩の書いたコードを削ったりしながらサービスを使いやすいものに育てていくという姿勢こそ、そのサービスを持続的に発展させて行くのではないでしょうか。

次のアドベントカレンダーの担当は id:amagitakayosi です。