メルペイフロントエンドパフォーマンス改善報告

はじめに

こんにちは。メルペイフロントエンドチームのtoshickです。
この記事は、このチームで取り組んでいるパフォーマンス改善について紹介するシリーズ記事の第4弾の記事になります。
ref: https://engineering.mercari.com/blog/entry/20220118-e040ab3dd3/

昨年末から改善調査を行ってきたパフォーマンス改善の活動報告をしていきます。
メルペイチームで開発・運営している社内ツール(以下社内ツール)に対して、WebVitalsの指標の一つであるTBTにフォーカスした改善を行ってきました。
スコア計測のエンジンとしてはlighthouse-ci(以下lhci)を利用しています。
実際のところ「このようにしたらこれだけ改善しました!」という報告とは多少違いますが新
たな学びがありました。

TBTとは
トータルブロッキングタイム。WebVitalsチームはページロード時に50ms以上かかっているメインスレッドに負担をかけているタスクをLongTaskと定義しました。トータルブロッキングタイムはすべてのLongTaskのこの50msを超えたミリ秒を合計した秒数から算出した値です。

つまり、タスクがたくさんあっても個々のタスクが50msを超えさえしなければLongTaskとして計上されないのです。
初期画面表示時にどれだけメインスレッドのブロッキングが発生しているかを表しています。

改善のためにしたこと

社内ツールのあるページのTBTが20点程度なのでこれを改善していくための活動をはじめました。
以前にもWebFontの遅延ロードを行ったりしましたがTBTはそのようなスコアでした。
tokuda109の記事にあるように、nuxtの初期化ハイドレーション処理は多くのマイクロタスクを発生させます。
以下の仮設をたてて改善を実行することにしました。

ブロッキング処理が減少し、ロングタスクが減る

そのためにハイドレーション処理のマイクロタスクを減らす

そのためにDOMノード数を減らす

そのために初期レンダーの見えている要素以外はノードからはずす

そのためにコンポーネント、コンテンツの遅延ロードをやってみる

TBT改善のため、とにかく初期ロードの負担を減らすためにDOMノードの削減をはじめました。
DOMノードが小さければ小さいほどこのタスクを減らすことができるはずです。

ターゲットの画面では、「添付ファイルおよびコメントの一覧」のブロックがあります。この一覧は初期画面表示時には画面上見えないため、ここのレンダーを省略することにしました。

その結果ですが、TBTは20点ほど上昇して30点から40点台になりました。
まあまあ期待どおりになりましたが他の問題が発生しました。

なぜかFCPとLCPのスコアが下がる

「添付ファイルおよびコメントの一覧」ブロックでは多少サイズの大きいファイルリストレンダー専用のコンポーネントを利用しています。(さらにファイルリストコンポーネントはPDFを解析する大きめのライブラリも読み込むためこれもロードサイズに影響を与えています。)
せっかく遅延レンダーするのだから、中身の一覧情報だけではなくファイルリストコンポーネントのロード自体も遅延させるべきです。

コンポーネントのダイナミックインポート

components: {
    AAA,
    BBB,
    FileList: () => import(‘.....FileList.vue’),
    …
}

FileListのコンポーネントはダイナミックにインポートをさせるように修正し、v-ifによりロードが開始されるようになりました。

v-ifが真となるタイミングは、ユーザがスクロールしてファイルリストが画面に表示される直前です。
このためにintersection observerによるノード位置の監視を導入しました。

ユーザがスクロールをさせると、親コンポーネントはファイルリストの中身のデータを子に流し込みます。
するとファイルリストを保持しているコンポーネントは配列の長さ変更を検知しv-ifを真にします。
それがトリガになりFileListコンポーネントがダイナミックにロードされ画面が更新されます。
これはつまり、スクロールをしない初期画面ロードではFileListがレンダーされないためそれらのロード、レンダーのコストをまるごと除外できるということになります。

コンポーネントのauto loadの停止

ディレクトリ指定により全コンポーネントはnuxtのauto load機能でまるごとロードするような処理を行っていました。
しかし、一部遅延読み込みをさせたいにもかかわらずこの指定はそれを台無しにしてしまいます。
面倒でしたが全コンポーネントを明示的に指定しロードするプラグインを追加してFileListコンポーネントを除外することにしました。

この設定で再度スコアを確認してみたところあまり改善がみられません。多少は変化があるような気がする程度でした。

Nuxtのprefetch

本当に遅延ロードが成功しているのか、ログを出力してみると全く遅延がされていないことがわかりました。
FileListコンポーネントに仕込んだログが見事初期レンダー時に出力されています。
実は対象の画面の初期画面のアイテムに内部リンクをレンダーしている箇所があります。
ここではnuxt-linkがレンダーされるのですが、このリンクを削除すると遅延が成功します。
 調べてみるとnuxt-linkは対象のページに対してprefetchを行っているようです。
対象のページでもしっかりとFileListコンポーネントが使われており、そこでは無論遅延処理などは実行していません。
これによりせっかく遅延ロードして本ページの初期レンダーから除外してもprefetchが別ページにて同コンポーネントをロードしていたために無駄になっていたことがわかりました。

他にもこのような問題が発生することを恐れ、nuxt.config上から全体としてリンクのprefetchを停止する設定にしました。(これは今回のパフォーマンス調査のための設定なので、本番をこの設定でいくかは議論が必要でしょう)

router: {
    …
    prefetchLinks: false,
}

遅延ロード成功

これにより、遅延ロードは成功しました。
TBTは20点程度だったものが70点を超える場合もありました。
この「場合もありました」というのが今回のキーなのですがこれについては後述することにします。
かなりバラつきはあるものの、TBTは30点を下回ることはなくなりました。
そのかわり80点を超えていたFCP、90点を超えていたLCPが50点に下がることが出てきました。
場合もあった、とか出てきた、という言い方をしているのは出力結果がかなり不安定なためです。

TBT 最初 20点 → 31点 → 53点 → 33点 → 44点 → 70点 → 53点
FCP 最初 81点 → 14点 → 41点 → 85点 → 9点 → 48点 → 55点
LCP 最初 95点 → 35点 → 63点 → 97点 → 26点 → 62点 → 79点

最初というのは改善なしの状態、そこから改善しつつ確認のためにスコアを計算していましたが、とにかくバラつきが激しい状態です。
これではうまくいったのかどうか判断しかねます。
しかしながら改善なし状態では出なかったスコアも出ているため「上がった」「下がった」の判断はなんとなくできています。

TBTの改善で失われたスコア

不安定とはいえTBTは当初の20点から大幅にあがっています。
ここで問題がでてきました。
改善をしない状態では安定してFCPは 80点、 LCPは90点をだしていたのですが遅延ロード策を追加すると明らかにFCP・LCP2つのスコアは半分以下にもなることがあり点を減らしています。

この原因を考えていたのですがまったくわかりません。
想像すらできていません。
初期画面でレンダーされるアイテムとはまったく無関係の画面外のアイテムをレンダーからはずしただけでFCP(First Contentful Paint)とLCP(Largest Contentful Paint)が下がる理由です。
仮定すら浮かばないので困り果てていました。

FCP
ページの読み込みが開始されてからページ内のコンテンツのいずれかの部分が画面上にレンダリングされるまでの時間のスコア。ページの読み込みが開始されてからページ内のコンテンツのいずれかの部分が画面上にレンダリングされるまでの時間のスコア。

そもそも「いずれかの部分のレンダー」を計測するのだからレンダーするアイテムが減ることによりスコアは上がるのではないか?

LCP
ビューポート内に表示される最も大きい画像またはテキスト ブロックのレンダリング時間に関するスコア。

「ビューポート内に表示される」と言っている以上、スクロールしないと見えないコンテンツを初期レンダーからはずしたところで影響があるわけはないのではないか?

湧き出た疑問と設定の影響

それ以外にもいろいろ疑問はありましたが、ようやくそれに向き合う時がきたようです。

なぜ、レンダーアイテムを減らしたのにFCPとLCPがさがるのか。
なぜ、同じバージョンのサイトのスコアにかなりのばらつきがあるのか。
なぜ、ローカルのlhciとchrome devtoolsのlighthouseでかなりの差があるのか(lhciはlighthouseをラップしているため、スコアの計算という意味ではまったく同一のエンジンのはずです)

行き詰まったためlhciの設定を見直すことにしました。

ligththouse設定の見直し

現在利用しているlhciというツールでは「デスクトップ指定でPWAは使わないから プリセット ‘lighthouse:no-pwa’ をセットしておけばよいだろう」としか考えていませんでした。
しかし重要な視点が抜け落ちていました。スロットリングです。

スロットリング

ユーザが常に最新のブロードバンドで、最新のCPUを持つデバイスで対象サイトを閲覧しているわけではありません。
実際にはより貧弱なネットワーク環境やデバイス個別のCPUを利用してブラウザがHTMLを解析表示しています。
よりリアルなユーザの環境をシミュレートして一定の貧弱な環境下でのスコアを算出するための設定がスロットリングです。
lighthouseはデフォルトではモバイルデバイスのシミュレーション設定になっているため、デスクトップの設定に変更する必要がありました。
ref: https://github.com/GoogleChrome/lighthouse/blob/master/docs/throttling.md

CPUスロットリング

Fastest (Desktop i5 / Macbook Pro / iPhone X) → 速度 1x
Premium Mobile (Pixel 2 / iPhone 6s) → 速度 2x
Average Mobile (Nexus 5X / Acer CB3 / iPhone 5s) → 速度 4x
Low-End Mobile (Galaxy J3 / iPhone 4s) → 速度 8x

CPUの速度のためのスロットリングは上記の4つに分類されます。
かけた数値の分だけ速度が低下するという意味になります。
今まではデフォルトのmobileSlow4G設定だったため、cpuSlowdownMultiplierの値は4になっていました。
これはAverage Mobileの設定ということになり、現実の値から4倍遅くする設定になります。
今回は社内管理ツールなのでデスクトップを想定するためthrottlingをdesktopDense4G設定にしました。
この設定によりcpuSlowdownMultiplierは1になり通常速度での計算が行われるようになりました。
これによりロングタスクの処理の効率も上がるために50msを超えるタスクが減少し、TBTのスコアもGreenライン(Webvitalsチームが定義している健康なスコアのライン)を超えて100点になりました。

default設定(lighthouse:no-pwa) デスクトップ用設定に変更

ネットワークスロットリング

人口統計グループに基づくネットワーク分類により、想定されるユーザは以下のグループに分けられています。

Dense → Broadband - a fast WiFi
Sparse → 4G - an average 4G
Sparse → 3G - an average 3G
NBU → 2.5G
NBU → 2G

「人口が多く大部分が都会でインフラが速く安定している」のがDenseグループですが、日本はこのグループのサンプル国として設定されています。
「人口が少ないか農村地帯が存在し、都市部でのみインフラが安定している」のがSparseグループであり、アメリカがこのサンプル国です。
NBU(next billion users)は途上国など「インフラが整っていない低速な地域」です。

日本ではサンプル国なのでDense、つまりdesktopDense4Gでよいと考えます。

throttling method設定

devtoolsとsimulate、providedの3つがあります

(simulate)
「ligththouseが」最初のスロットルされていないロードで観察されたデータに基づいて、ページロードのシミュレーションを使用します。rttMsとthroughputKbpsのパラメータを利用します

(devtools)
「chromeブラウザが」CPU作業の実行を定期的に中断して、低速のプロセッサをエミュレートします。requestLatencyMsとdownloadThroughputKbpsのパラメータを利用します。

(provided)
すべてのスロットリングをオフにします。

ここはsimulateを選択します。
devtools設定はTCPやSSLのハンドシェイクのラウンドトリップを考慮しないためそのへんがsimulate設定より早くなり意図していない良いスコアになることがあるようです。
一方simulate設定はすべてのリクエスト速度の中央値をTTFBのvalueとして採用するらしく、これも意図していない良いスコアにつながるようです。

ref: https://www.debugbear.com/blog/network-throttling-methods

我々は今までpresetをperfにして計測していましたのでthrottling methodはdevtoolsになっていました。
provided設定はスロットリングなしなので除外するとしてsimulateとdevtools、この2つのどちらを採用すべきでしょうか。
難しいところですがいままでの不安定な出力結果が安定したのはsimulate設定を明示してからでした。
なので深い理由は不明ですがsimulate設定にて安定した結果を得ることにしようと考えました。

その他設定

form-factor: [mobile, desktop]
    モバイルのテストをスキップするかどうかの設定

preset: [perf, experimental, desktop]

throttling: [mobileSlow4G, mobileRegular3G, desktopDense4G]
    プリセットの設定が上記の三種類ありますが、さらにその内容が以下の設定を持っています。

    throttling.rttMs:
        TCP層のラウンドトリップタイムをシミュレーションしたもの

    throttling.throughputKbps:
        ダウンロードのスループットをシミュレーションしたもの

    throttling.requestLatencyMs:
        HTTP(アプリケーション)層のラウンドトリップタイムをエミュレーションしたもの

    throttling.downloadThroughputKbps:
        ダウンロードのスループットをエミュレーションしたもの

    throttling.uploadThroughputKbps:
        アップロードのスループットをエミュレーションしたもの

    throttling.cpuSlowdownMultiplier:
        CPU速度を調整するための値。何倍遅くするかを指定する。

throttlingMethod: [devtools, provided, simulate]

screenEmulation: [mobile, desktop]

今回対象となるサイトの設定は以下にしました。

formFactor: 'desktop',
preset: 'perf',
throttling: {
    ...constants.throttling.desktopDense4G,
    rttMs: 80,
},
throttlingMethod: 'simulate',
screenEmulation: constants.screenEmulationMetrics.desktop,

perfのプリセットを設定し、スロットリングはブロードバンド用のdesktopDense4Gです。
その上でラウンドトリップの時間をデフォルトの40msから少し遅めの80msに変更しています。
やってみてわかったのですが、desktopDense4Gのデフォルト設定だとスコアが良すぎて改善ができそうになかったためです。

設定を見直して再度実行してみました。

その結果スコアがかなり改善され、結果のバラつきもほぼなくなりました。
改善というより今までのシミュレートの設定がデフォルトのモバイル用(mobileSlow4G)だったため、ネットワークを遅くしすぎていたことを理解しました。

さらに衝撃なことに、この新たな設定に変更したことでTBTは常に100点となっています。(cpuSlowdownMultiplierの低速調整がなくなったためだと思われます)
今まで一番スコアの低いTBTを改善しようとしてきましたがそこは問題ないことがわかりました。

この設定から先程までやっていた非同期コンポーネント等の改善を適用してみたところ期待どおりスコアに改善がみられました。
さらに「レンダーのアイテムを削除するとFCPとLCPスコアが下がる」といった意味不明な挙動はなくなりました。

TBT  60ms(100点)→ 30ms(100点)
TTI  4115ms(57点)→ 2900ms(79点)
FCP  1880ms(35点)→ 1829ms(37点)
LCP  1900ms(67点)→ 1829ms(69点)
CLS  0.12ms(84点)→ 0.08ms(92点)

おそらくthrottling method設定をsimulateにした影響で安定したのだと考えていますが、具体的な考察はできていません。
ローカルのlhciとchrome devtoolsのlighthouseでの差も、ローカルのlhciはスロットリングをシミュレートした結果を出力していたこと、chromeのlighthouseは自分でスロットリングを選ばない限り生の実行結果を出力していたために結果に開きがあったということが理解できました。

ターゲット設定は計測のための第一歩

今回の活動では「ここをこうしたらこのようにスコアが改善した」という結果を伝えるべく調査をしてきましたが、そもそもの設定を見直すという事態になり具体的な事例はあまり共有できませんでした。

しかしスロットリングの発想が今まで欠けていたこと、どのような環境でサイトが利用されるのかという観点が足りていなかったという気づきを得ることができました。

常にブロードバンドで高スペックマシンでロードされるページを想定するべきではないという当たり前のことを前提とし、どのようなデバイスで、どんなネットワーク環境のユーザへサービスを提供するかをまず考えることが、パフォーマンス計測の第一歩であると痛感しています。

改善ツールを少しupdate

以前に紹介した改善ツールPerf Local Check Toolですが、出力されたスコアごとに説明コメントをjsonファイルから読み込んでレンダーする仕組みがかなり使いづらいことがわかりました。
なのでローカルのexpressサーバをたててページ上からフォームをつかって説明コメントを保存できるように改良を加えてあります。
これによりかなり調査がやりやすくなりました。

さらにlhciの設定ファイルから設定情報を取得してスコアの下に出力するようにしました。そのスコアがどのようなシミュレーション設定下で記録されたものかを保存しています。
1スコアブロックの情報が多くなったので、この設定表示はトグルで表示を切り替えるようにしています。

コメント入力も可能なちょっとしたwebアプリのように進化しましたが、JavaScriptはCDNからimportしたAlpineJSというものを利用しています。軽量なJavaScriptフレームワークであり、Vueライクに利用でき、ビルドも不要なためこのような簡易webアプリに向いていると考えます。

今後

ようやくスタートラインに立った気がしています。
試行錯誤するための安定した計測結果を出力する環境を手に入れることができたので引き続き他のページやサービスの改善にも取り組んでいきます。

今後はさらにRUM(Real User Monitoring)にも挑戦していきたいと考えています。
今までのものは文字通りシミュレーションでした。
RUMはプロダクト側にWebVitalsのスコアを計測するコードを仕込み、実際に使っているユーザのブラウザが計測したWebVitalsのスコアを蓄積していく方法です。
これによりユーザがどのような体験をしているのかがわかってくるはずです。
シミュレーションでの設定値が遅すぎなのか、早すぎるのか、などを反映していくことができるかもしれません。

以上でパフォーマンスの改善報告を終わりたいと思います。
タイトルの期待感とは違う結果になってしまいましたが、皆さんのパフォーマンス改善のヒントになればうれしいです。
読んでいただきありがとうございました。
メルペイのフロントエンドチームではパフォーマンスやクオリティの改善に興味のある仲間を募集しています。

Engineering Manager, Frontend – Merpay
Software Engineer, Frontend – Merpay

ありがとうございました。
toshick

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加