幸せの形はどれも似ているが

不幸なプロジェクトはそれぞれの形がある

@mizchi at JSConfJP 2024

English Text Version

About

話すこと/話さないこと

  • 話すこと
    • パフォーマンスという予算の考え方
    • Lighthouseによる計測
    • ソースコードを二分探索して問題を特定
    • 計測した問題の扱い方
  • 話さないこと
    • 具体的な問題の解決方法(都度調べて)

発表のコンテキスト

パフォーマンスバジェット

一度高速なウェブサイトを作ったとしても、パフォーマンスを維持するのは想像以上に困難です。
新しい機能の追加や、サードパーティの計測タグの設定、意図せず巨大なファイルサイズの写真を配信してしまった等、長期的に見ると開発をする中で起きる大小さまざまな出来事によりパフォーマンスは劣化してしまいます。

Google Developers Japan: パフォーマンスバジェットのご紹介 - ウェブパフォーマンスのための予算管理

ここ最近遭遇したもの(一部)

パフォーマンス課題の発生傾向

  • 計測していない
    • 対象を計測/解決できるという認識がない
    • コード量とパフォーマンスが反比例すると思い込んでいる(実際関係ない)
  • 安易なアンチパターンの採用からの、爆発
    • ドキュメント上の非推奨を無視するのが常態化
    • 目的や手段が間違ってるときは、間違った解に辿り着く
  • 開発体験/DX/UXの悪化を過度に許容する文化
    • 非機能要件に不平を言うのが社会人らしくない行為と思われている?
    • (プログラマの三大美徳の短気/怠惰/傲慢は現代だと主張しづらいよね…)

自分の結論: 幸せなプロジェクト=まだなにもないプロジェクト

高速で空虚なプロジェクト

  • 採用したボイラープレートが理論上最速
    • Lighthouse満点を出せるかを最初に確認
    • これ以上は前段のCDNでキャッシュしないと無理
  • 開発者は速度/複雑性のバジェット(予算)を消費することで機能追加する
    • 機能提供とは、バジェットを運用している 状態
    • バジェットを食い潰すまで機能追加は止まらない
  • 健全なサービス提供には、失ったバジェットを認識し予防的に取り返す必要がある

不幸なプロジェクト=何らかの形でバジェットを食いつぶした状態

幸福な家庭はどれも似たものだが、不幸な家庭はいずれもそれぞれに不幸なものである
アンナ・カレーニナ (トルストイ)

失敗する可能性があるものは、失敗する | マーフィーの法則

現代のソフトウェアの複雑性の前では、事前に問題を想定することはできない。

想定できないなら、計測するしかない

「推測するな、計測せよ」 by Rob Pike

あなたはプログラムがどこで時間を消費しているかを理解していない。
ボトルネックは予想外のところにある。推測するな。どこにボトルネックがあるか証明されるまで高速化するな。
計測せよ。計測するまで高速化するな。計測して、コードの一部分が他の部分より圧倒的に時間を消費しているのでなければ、高速化をするな。
...

フロントエンドの計測とは

Synthetic monitoring (合成モニタリング) - MDN Web Docs

合成モニタリングは、可能な限り一貫性のある環境で、通常は自動化ツールを使用して、「実験室」環境でページの性能をモニタリングすることです。
一貫性のあるベースラインがあれば、合成モニタリングはコード変更が性能に及ぼす影響を測定するのに適しています。しかし、必ずしもユーザーが体験していることを反映しているとは限りません。

Lighthouseのフロントエンド計測≒アプリケーション全体(E2E)の推測

指標の定義と計測ツール

  • Web Vitals
    • FCP: 最初に意味のある要素が表示されるまでの時間
    • LCP: 初期表示において一番大きな要素が確定したタイミング
    • TBT: ユーザーのCPUをブロックした時間
    • CLS: 累積レイアウト。CPU適用やJSによる要素書き換えの回数
  • Lighthouse: WebVitalsを計測するツール
  • Chrome DevTools を Lighthouseの推奨設定に合わせて計測

alt text

自分のチューニングするときの考え方

  • 最初は計測に徹する
    • ソースコードの意味論から推測しない。事前知識は計測においてはバイアス
    • (フリーランスの立場だからできる)
  • DevToolsとソースコード解析を繰り返して問題を絞り込む
    • DevTools で傾向を掴み、ソースコードを書き換えて確認
    • プロダクションに近い環境で、必ず複数回(3回以上)計測する
  • 本来はアプリケーションに応じた指標がある
    • WebVitalsは参考値すぎないがよく練られている(し、SEOに響く)

Lighthouse + Chrome DevTools

Lighthouse の読み方

DevTools>Lighthouse>Analyze page load

  • FCP → 初期レスポンスが悪い
  • LCP → RTTに問題
  • TBT → CPU/JSバンドル処理の問題
  • CLS → クリティカルパスのCSS
  • SI → 結果的な総計(※見る必要なし)

Lighthouse: LCP を追え

  • 目標2.5s (Lighthouse:100)
  • 何の表示がLCPを確定させたか?
  • LCPまでの経路がチューニング対象

DevTools > Performance

縦に読む

  • 同じ処理が起点(のはず)
  • 縦のグループが何列あるかがRTT

密な場所を読む

  • MainThread: CPU負荷 →TBT
  • Network: リクエストキュー待機数

LCP 直前に何が起きたか?

full

DevTools > Network

Size,Time でソートして読む

  • Size: 単純に転送量が多いアセット
  • Time: 遅いリクエスト
  • Waterfall: リクエスト発生順を視覚的に確認

DevTools > Network Blocking

  • リクエストを右クリックで Block request URL
  • リロードして副作用を確認
  • この状態でLighthouseを計測

「例えばGTM起点で何点悪化するんだっけ?」
「WebFontsやめたら何点?」

DevTools > Sources > Overrides

  • レスポンスを書き換えて検証
    • 配列を空にしたり…
  • (有効化がやや面倒)
  • Overrides > Select folders to Override
    ☑ Enable Local Overrides

Chrome DevTools の学び方

使って体で覚えろ!

chrome for developpers が一番まとまってるが、基本的にない。

More tools 全部試すのがオススメ

ソースコード解析と計測

ソースコード分析の前提

  • 問題が絞り込んであるのが前提
    • CPU / Network / RTT
  • ソースコードを書き換えて問題が再現する最小状態を探る
    • どんなに汚いコードを書いてもいい!
    • ブランチは切っておく(説明用に使う)
  • ボトルネックが特定できたら伸びしろを確認
    • モック or 単に処理をスキップ
    • この問題が解決できたら N点伸びる、というのがわかる
    • ボトルネックが移動したらLighthouseで再計測

計測手段: JS

  • プリントデバッグ
    • console.log(): いつものやつ
    • console.time();console.timeEnd(): 同じラベルの区間を計測(ミリ秒)
    • PerformanceObserver
  • タイムスタンプ
    • Date.now(): UnixTime ミリ秒精度。CPU処理を測るには不向き
    • performance.now(): ナビゲーション開始からマイクロ秒精度
  • デバッガ
    • debugger;: DevTools Debugger で停止させてデバッグ

自分の計測手法

  • 基本はプリントデバッグ一本
    • 調査対象に依存しない
    • 高度なツールは環境依存しがち
  • ※人による
const started = performance.now();
console.log("js:started", started);

function MyApp(props) {
  useEffect(() => {
    console.log('useEffect with', props);
    console.time('req:xxx');
    fetch('/xxx').then((res) => {
      console.timeEnd('req:xxx')
      debugger;
    });
  }, []);
  return <div>...</div>
}

二分探索で絞り込む (1)

仮にこういうコードがあるとする

import a from './a';
import b from './b';
import c from './c';
import d from './d';

function async run() {
  await a();
  await b();
  await c();
  await d();
}

二分探索(2) - 前半を計測

  • 後半をコメントアウト
  • 計測用コードを挿入
async function run() {
  console.time('a');
  await await a(); // 200-300
  console.timeEnd('a');
  console.time('b');
  await await b(); // 30
  console.timeEnd('b');
  // await c();
  // await d();
}

二分探索(3) - 後半を計測&依存確認

async function run() {
  // await a(); // 200-300
  // await b(); // 30

  console.time('c');
  await c(); // 0
  console.timeEnd('c');
  console.time('d');
  await d(); // 1000-1800
  consloe.timeEnd('d');
}
  • 後半で同じことをやる
  • ロジックに依存がある場合...
    • 簡単ならモックを試みる
    • モックが難しい場合そのまま残す
  • 計測結果をメモ

二分探索(4) - 再帰的に絞り込む

async function run() {
  await a(); // 200-300
  // await b();
  // await c();
  await d(); // 1000-1800
}
// 再帰的に計測
export default function d() {
  d1(); // 0
  await d2(); // 1000-1700 <- これ!
  await d3(); // 100
}
  • 実行に必要なパスだけ残す
  • 一番重い箇所に対し、再帰的に計測

二分探索(5) - 具体コードを特定

export default function d() {
  d1(); // 0
  await d2(); // 1000-1700
  // await d3(); // 100
}
async function d2() {
  console.time(`d2:fetch`);
  let cnt = 0;
  while(cnt < 10) {
    const ret = await fetch(`/api/d/${cnt}`);
    if (!ret.ok) break;
    cnt++;
  }
  console.timeEnd(`d2:fetch`);
  return cnt;
}
  • 実際のボトルネックを特定しにいく
  • (経験的には、ライブラリAPI or ネイティブコードであることが多い)

ボトルネックを除去して再計測

async function d2() {
  let cnt = 0;
  // while(cnt < 10) {
  //   const ret = await fetch(`/api/posts/${cnt}`);
  //   if (!ret.ok) break;
  //   cnt++;
  // }
  // console.timeEnd(`d2:fetch`);
  return 0;
}
  • メインブランチ(origin/main)から新しいブランチを切る
  • 最小手数(diff) で問題を取り除く
  • この状態で Lighthouse が何点改善するかを計測
  • この点数の差が、改善の伸びしろ
    • 40 -> 70 (+30)
    • +30 のうち、実現可能な範囲は?

最後に: 「計測した」ので「チューニングしていい」

async function d2() {
  let cnt = 0;
  while(cnt < 10) {
    console.time(`d/${cnt}`); // 200-300
    const ret = await fetch(`/api/d/${cnt}`);
    console.timeEnd(`d/${cnt}`);
    if (!ret.ok) break;
    cnt++;
  }
  console.log("end cnt", cnt); // 6
  return cnt;
}
// 止血的に、こうできる?
async function d2_parallel() {
  return Promise.all([...Array(10).keys()].map(idx => {
    await fetch(`/api/d/${idx}`).catch(console.error)
  }))
}
// サーバー実装ごと修正
async function d2_once() {
  return await fetch(`/api/d`)
}
  • そもそもこのコードは何なんだ
  • API 自体は改善できるか?
  • 本質的に対応可能なもの?

CrUXVis: Google による長期計測ダッシュボード

  • 長期傾向が自分の観測値と一致してるか確認
  • (SEOスコアが高いサイトほど計測されてる傾向がある)

zenn.devの例

パフォーマンス改善の意思決定

判明した問題をトリアージ

    • LCP: /api/xxx が直列で 300ms * 3RTT
      • 難易度: 中 +20~
    • FCP: 初回レスポンスが 1800ms
      • 難易度: 高 +10~
    • TBT: バンドルに800kb のライブラリが入り込んでいた
      • 難易度: 低 +10
    • CLS: サードパーティ読み込み後に画像サイズが変更される
      • 難易度: 低 +5, 仕様変更: 有
  • 難易度が低く、伸びしろが大きく、仕様変更がないものからやる
  • 担当範囲を割り振る(フロント、サーバー、仕様)

問題同士の相互干渉を計測

  • 大きな問題同士を混ぜて計測する
    • 問題 A, B, C を (A & B), (A & C), (B & C) で再計測
  • 同根の問題は干渉する!
    • -10(CPU) -10(CPU) => Total 80 (TBT -20)
    • -15(Network) -10(CPU) => Total 85 (LCP -15)
  • アプリケーション vs サードパーティ
    • サードパーティ(GTM起点)は初期化のリクエストキューに割り込む
    • サードパーティに極端に問題がある場合、運用を確認する

再発防止策

  • TBT: @next/bundle-analyzer / vite-bundle-visualizer
  • CI: lighthouse-ci で計測を自動化
  • 文化: 計測方法の周知(この発表自体が業界へ向けての再発防止策)
  • 組織: パフォーマンスバジェットにどれだけリソースを割くかを確認

最後は仕様の「決め」の問題

  • 機械的に修正できる範囲は、どこかで限界が来る
  • 日頃からKPIを測定できていないと仕様の引き算ができない
    • あなたの会社は、一度入れた機能が使われてるか、測定できてますか?
  • 失っているパフォーマンスバジェットに見合った価値があるか?
    • プログラマは実装上の「痛み」がないものを提案するのも仕事

最後に: まとめ

  • 「推測するな、計測せよ」でも、最終的に 「経験と勘」
    • 問題と向き合う姿勢/大量のケースを見る経験が大事
    • DevToolsの使い方は、使って覚える以外にない
  • コスパよく直せる部分は偏っている
    • 問題A/Bが同じ -10 でも、修正するコストは点数に比例しない
    • 累積的な問題を初期に認識できなかった場合、発覚時点では手遅れ
      • 「安易なハック」が起点であることが多い
  • 最後に大事なのは仕様
    • Dev/Biz 相互に提案を行うコミュニケーションが一番大事
    • サーバー:フロントエンド:GTM の速度比率は、組織パワーバランスの発露

おわり

計測/改善の仕事だけでなく、計測手法の勉強会もできます。

お仕事の相談をお待ちしています

\n
\n
\n `.split(/\n\s*/).join(""),this.wrapper=this.shadowRoot.querySelector(`div[${e}]`)??void 0;const t=this.svg;this.svg=this.wrapper?.querySelector(`svg[${i}]`)??void 0,this.svg!==t&&(this.svgComputedStyle=this.svg?window.getComputedStyle(this.svg):void 0),this.container=this.svg?.querySelector(`span[${n}]`)??void 0,this.observe()}disconnectedCallback(){this.svg=void 0,this.svgComputedStyle=void 0,this.wrapper=void 0,this.container=void 0,this.observe()}attributeChangedCallback(){this.observe()}flushSvgDisplay(){const{svg:t}=this;t&&(t.style.display="inline",requestAnimationFrame((()=>{t.style.display=""})))}observe(){this.containerObserver.disconnect(),this.wrapperObserver.disconnect(),this.wrapper&&this.wrapperObserver.observe(this.wrapper),this.container&&this.containerObserver.observe(this.container),this.svgComputedStyle&&this.observeSVGStyle(this.svgComputedStyle)}observeSVGStyle(t){const e=()=>{const i=(()=>{const e=t.getPropertyValue("--preserve-aspect-ratio");if(e)return e.trim();return`x${(({textAlign:t,direction:e})=>{if(t.endsWith("left"))return"Min";if(t.endsWith("right"))return"Max";if("start"===t||"end"===t){let i="rtl"===e;return"end"===t&&(i=!i),i?"Max":"Min"}return"Mid"})(t)}YMid meet`})();i!==this.svgPreserveAspectRatio&&(this.svgPreserveAspectRatio=i,this.updateSVGRect()),t===this.svgComputedStyle&&requestAnimationFrame(e)};e()}updateSVGRect(){let t=Math.ceil(this.containerSize?.width??0);const e=Math.ceil(this.containerSize?.height??0);void 0!==this.dataset.downscaleOnly&&(t=Math.max(t,this.wrapperSize?.width??0));const i=this.svg?.querySelector(":scope > foreignObject");if(i?.setAttribute("width",`${t}`),i?.setAttribute("height",`${e}`),this.svg&&(this.svg.setAttribute("viewBox",`0 0 ${t} ${e}`),this.svg.setAttribute("preserveAspectRatio",this.svgPreserveAspectRatio),this.svg.style.height=t<=0||e<=0?"0":""),this.container){const t=this.svgPreserveAspectRatio.toLowerCase();this.container.style.marginLeft=t.startsWith("xmid")||t.startsWith("xmax")?"auto":"0",this.container.style.marginRight=t.startsWith("xmi")?"auto":"0"}}}const r=(t,{attrs:e={},style:i})=>class extends t{constructor(...t){super(...t);for(const[t,i]of Object.entries(e))this.hasAttribute(t)||this.setAttribute(t,i);this._shadow()}static get observedAttributes(){return["data-auto-scaling"]}connectedCallback(){this._update()}attributeChangedCallback(){this._update()}_shadow(){if(!this.shadowRoot)try{this.attachShadow({mode:"open"})}catch(t){if(!(t instanceof Error&&"NotSupportedError"===t.name))throw t}return this.shadowRoot}_update(){const t=this._shadow();if(t){const e=i?``:"";let n="";const{autoScaling:s}=this.dataset;if(void 0!==s){n=`${n}`}t.innerHTML=e+n}}};let o;const a=Symbol();let l;const c="marpitSVGPolyfill:setZoomFactor,",d=Symbol(),h=Symbol();const g=()=>{const t="Apple Computer, Inc."===navigator.vendor,e=t?[u]:[],i={then:e=>(t?(async()=>{if(void 0===l){const t=document.createElement("canvas");t.width=10,t.height=10;const e=t.getContext("2d"),i=new Image(10,10),n=new Promise((t=>{i.addEventListener("load",(()=>t()))}));i.crossOrigin="anonymous",i.src="data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2210%22%20height%3D%2210%22%20viewBox%3D%220%200%201%201%22%3E%3CforeignObject%20width%3D%221%22%20height%3D%221%22%20requiredExtensions%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxhtml%22%3E%3Cdiv%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxhtml%22%20style%3D%22width%3A%201px%3B%20height%3A%201px%3B%20background%3A%20red%3B%20position%3A%20relative%22%3E%3C%2Fdiv%3E%3C%2FforeignObject%3E%3C%2Fsvg%3E",await n,e.drawImage(i,0,0),l=e.getImageData(5,5,1,1).data[3]<128}return l})().then((t=>{null==e||e(t?[u]:[])})):null==e||e([]),i)};return Object.assign(e,i)};let p,m;function u(t){const e="object"==typeof t&&t.target||document,i="object"==typeof t?t.zoom:t;window[h]||(Object.defineProperty(window,h,{configurable:!0,value:!0}),document.body.style.zoom=1.0001,document.body.offsetHeight,document.body.style.zoom=1,window.addEventListener("message",(({data:t,origin:e})=>{if(e===window.origin)try{if(t&&"string"==typeof t&&t.startsWith(c)){const[,e]=t.split(","),i=Number.parseFloat(e);Number.isNaN(i)||(m=i)}}catch(t){console.error(t)}})));let n=!1;Array.from(e.querySelectorAll("svg[data-marpit-svg]"),(t=>{var e,s,r,o;t.style.transform||(t.style.transform="translateZ(0)");const a=i||m||t.currentScale||1;p!==a&&(p=a,n=a);const l=t.getBoundingClientRect(),{length:c}=t.children;for(let i=0;i{null==t||t.postMessage(`${c}${n}`,"null"===window.origin?"*":window.origin)}))}function v({once:t=!1,target:e=document}={}){const i=function(t=document){if(t[d])return t[d];let e=!0;const i=()=>{e=!1,delete t[d]};Object.defineProperty(t,d,{configurable:!0,value:i});let n=[],s=!1;(async()=>{try{n=await g()}finally{s=!0}})();const r=()=>{for(const e of n)e({target:t});s&&0===n.length||e&&window.requestAnimationFrame(r)};return r(),i}(e);return t?(i(),()=>{}):i}p=1,m=void 0;const w=Symbol(),b=(e=document)=>{if("undefined"==typeof window)throw new Error("Marp Core's browser script is valid only in browser context.");if(((e=document)=>{const i=window[a];i||customElements.define("marp-auto-scaling",s);for(const n of Object.keys(t)){const s=`marp-${n}`,a=t[n].proto();(o??(o=!!document.createElement("div",{is:"marp-auto-scaling"}).outerHTML.startsWith("
{t.outerHTML=t.outerHTML.replace(new RegExp(`^<${n}`,"i"),`<${s}`).replace(new RegExp(`${n}>$`,"i"),`${s}>`)})))}window[a]=!0})(e),e[w])return e[w];const i=v({target:e}),n=()=>{i(),delete e[w]},l=Object.assign(n,{cleanup:n,update:()=>b(e)});return Object.defineProperty(e,w,{configurable:!0,value:l}),l},y=document.currentScript;b(y?y.getRootNode():document)}();