楽しく役に立つCSSのプロファイリング

私はここ最近、いわゆるシングルページWebアプリケーションのパフォーマンスの最適化に取り組んでいます。そのアプリケーションは非常に動的かつインタラクティブで、新しいCSS3の利点が詰め込まれたものです。単に角丸やグラデーションの効果にとどまらず、影やグラデーション、要素の変形がふんだんに使われており、加えてtransition効果(時間的変化)や多彩な半透明色、疑似要素をベースにしたCSSの巧妙なトリック、それに実験的なCSSの特徴がちりばめられています。

分析する際には、Javascript/DOM側のボトルネックだけではなく、CSSの領域にも踏み込んでみました。上に挙げたすばらしいUIの要素が、パフォーマンスにどのような影響を及ぼしているかを見たかったからです。このアプリケーションのベースにあるJavascriptのロジックは以前(表面的な装飾のないバージョン)からさほど変わってはいませんが、前のバージョンでは動作がとてもスムーズでした。それに比べると、新しいバージョンでのスクロールやUIアニメーションの動きは、それがあるべき姿からはかけ離れているように思えます。

これはスタイリングのせいなのでしょうか?

幸いにも、少し前にOperaの開発者から実験的な「Style profiler」が公開されましたその後すぐにWebKitのチケット+パッチも出ました)。このプロファイラを使えば、CSSセレクタのマッチング、文書のリフロー(レイアウトの再計算)、リペイント(再描画)、更には文書やCSSのパース(構文解析)時間などが調べられます。

完璧ですね。


少し気掛かりなのは1種類の環境でプロファイリングし、1つのエンジン(特に1種類のブラウザでのみ使われるエンジン)に従って最適化するということですが、まあ、とにかくものは試しです。詰まるところ問題のあるスタイルやルールといったものは、どのエンジン、ブラウザでもある程度は共通しているでしょうし、いずれにしても使えるものと言えば、ほぼこれしかありませんからね。

似たようなもので唯一あった他のツールとしては、WebKitディベロッパツールの「Timeline」タブがあります。ただし使い勝手はさほどよくありません。リフロー/リペイント/セレクタマッチングのトータル時間も示せないですし、情報を抽出するにはJSON形式でデータをエクスポートしてから手動でパースしなくてはなりません(これについては後ほど触れます)。

以下では、これら2つ、WebKitとOpera両方のツールによるプロファイリングを通じて得た私の所見の一部を挙げていきたいと思います。長くて読むのがしんどい場合は、最後のまとめの項をご覧ください。

まずお伝えしておきたいのは、以下の覚書の(全てではないにしろ)ほとんどは、大規模で複雑なアプリケーションを想定しているということです。ドキュメントに数千の要素が含まれていたり、高度にインタラクティブであったりするアプリケーションに対して最もうまく適用されると思います。私の場合は、ページのロード時間を~650ミリ秒(スタイルの再計算だけで~500ミリ秒(!)、リペイントで~100ミリ秒、リフローで~50ミリ秒)短縮することができました。アプリケーションの動作は体感できるくらいに改善しましたが、特にIE7などの古いブラウザでその効果が顕著に感じられました。

シンプルなページやアプリケーションに対しては、もっと適切な方法が他にもたくさんあるはずです。

覚書

  1. 最速のルールなどは存在していません。制作時には、1つのファイルにスタイルシート「モジュール」を組み合わせるという手法がよく使われており、こうすることでルールの大きなコレクションが作られますが、その中の一部(多く)は、サイトやアプリケーションの特定のパートでは使われないことが多々あります。CSSのパフォーマンスを最適化するのに最も効果的なのは、こうした未使用のルールを取り除くことです。そうすればマッチングの数も少なくて済みます。もちろん大きなファイル1つにすれば、リクエストの数などが減らせるため、利点がないわけではありません。それでも、少なくともアプリケーションの重要な部分については、関連スタイルのみに絞ることで最適化をすることができるはずです。

これは別段、新しい発見というわけでもなく、PageSpeed Insightsはこのことに注意するよう常に呼びかけを行ってきました。ただ、私自身これほどまでにレンダリング時間に影響するとは思っていなかったので、ある意味驚きを隠せません。私の場合は、未使用のCSSルールを取り除いただけで、セレクタマッチングの時間を(Operaのプロファイラによると)~200-300ミリ秒カットできましたし、レイアウトや描画の時間も同様に短縮されました。

  1. リフローを減らすこと(これもよく知られている最適化の手法です)も同じく効果があります。処理の負荷が高いスタイルでも、ブラウザがリフローやリペイントを実行する回数が少なければ、大した負荷にはならないですからね。逆にシンプルなスタイルでも、たびたび適用されれば動作を重くすることもあります。リフローを減らすこととCSSが複雑にならないようにすることは、密接に関係しているというわけです。

  2. 最も負荷が高いセレクタと言えば、通常はユニバーサルセレクタ"*"や複数のクラスを持つもの(".foo.bar""foo .bar.baz qux"など)ですね。これについては皆さんもご存じかと思いますが、いずれにしてもプロファイラでこれが確認できるのはいいことだと思います。

  3. 理由もなしに使われているユニバーサルセレクタ"*"には気を付けてください。私の場合について言えば、サイトやアプリケーション内のボタンにはしか付いていませんでしたが、"button > *"のようなセレクタを見つけました。そこで、この"button > *"<span>に置き換えてやると、セレクタのパフォーマンスは大幅に改善しました。ブラウザが(右に書かれたセレクタから順々に確認するルールによりすべての要素をマッチングする代わりに、を探し(数はさほど多くはありません)、その親要素がbuttonかどうかをチェックするだけで済むようになったからです。ただ、該当のセレクタがどのような場所で使われているかを見つけるのは難しいことが多いので、特定のタグで"*"を置き換える際には注意しましょう。

この最適化の大きな欠点は柔軟性が失われるということでしょうか。マークアップを変える場合、CSSの変更も必要になるため、将来的にボタンの実装を別のものに単純に置き換えることはできなくなります。そのため、私としては上記の置き換えに多少の疑問も持っています。パフォーマンスのために有益な抽象化を排除する形になりますからね。これについては、私たちが心配しなくてもエンジンがそうしたセレクタを最適化できるようになるまでは、ご自身の状況に応じてちょうどいい妥協点を見つけるしかないでしょう。

  1. "*"に代わる要素を見つける方法として、以下のスニペットを使いました。
$$(selector).pluck('tagName').uniq(); // ["SPAN"]

これはprototype.jsのArray#pluckArray#uniq拡張機能に依存しています。(ES5とセレクタAPIに依存した)プレーンなバージョンの場合、次のようにすればいいでしょう。

Object.keys([].slice.call(
  document.querySelectorAll('button > *'))
    .reduce(function(memo, el){ memo[el.tagName] = 1; return memo; }, {}));
  1. OperaでもWebKitでも、input[type="..."]より[type="..."]セレクタの方が、負荷が高いようです。恐らく、ブラウザが属性チェックを指定タグの要素に制限しているためかと思われます(結局、[type="..."]はユニバーサルセレクタですからね)。

  2. プロファイラによると、Operaでは疑似要素の"::selection"":active"も同じく負荷の高いセレクタです。個人的に、":active"の負荷が高いのは理解できますが、"::selection"については理由が分かりませんね。Operaのプロファイラ/マッチャの「バグ」かもしれませんし、単にエンジンの動作の仕組みなのかもしれません。

  3. OperaとWebKitの双方において、"border-radius"はレンダリング時間に影響を与える最も負荷が高いCSSプロパティに分類されます。影やグラデーションと比べても更に負荷が高くなります。レイアウトではなく、主に再描画の時間に影響を与えていることに留意してください。

こちらのテストページをご覧ください。そこに400個のボタンを含む文書を作成しました。

Buttons
設定するスタイルの種類によって、レンダリングのパフォーマンスがどのような影響を受けるか(プロファイラでの再描画時間がどのように変化するか)を検証しました。基本となるボタンには以下のスタイルのみを適用しています:

background: #F6F6F6;
border: 1px solid rgba(0, 0, 0, 0.3);
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 14px;
height: 32px;
vertical-align: middle;
padding: 7px 10px;
float: left;
margin: 5px;

Btn Before
この基本のスタイルにボタン、400個を再描画するのに掛かった時間は、合計でわずか6ミリ秒でした(Operaを使用した場合)。ここから少しずつスタイルを加えていき、それぞれの場合でリペイントにかかる時間の変化を記録しました。最終的に加えられたスタイルは以下のとおりで、リペイントに177ミリ秒掛かりました。標準スタイルと比べると、何と30倍近い時間がかかっています。

text-shadow: rgba(255, 255, 255, 0.796875) 0px 1px 0px;
box-shadow: rgb(255, 255, 255) 0px 1px 1px 0px inset, rgba(0, 0, 0, 0.0976563) 0px 2px 3px 0px;
border-radius: 13px;
background: -o-linear-gradient(bottom, #E0E0E0 50%, #FAFAFA 100%);
opacity: 0.9;
color: rgba(0,0,0,0.5); 

Btn After
それぞれのプロパティごとの分析結果は以下のとおりです:

text-shadowとlinear-gradientは最も負荷の低いプロパティに分類されます。この2つと比べると、透明度を指定するopacityとcolor: rgba()が与える負荷は少し高くなりました。また、「box-shadow」では、内側に影を付けるinset0 1px 1px 0の方が、外側に影を付ける場合0 2px 3px 0よりも処理時間が短くなります。そして、予想外に負荷が高かったのはborder-radiusです。

更に「transform: rotate(1deg)」を加えたところ、非常に高負荷であるという結果が得られました。わずかに傾いた400個のボタンがあるページをスクロールしようとすると、動作がとぎれとぎれで不安定になります。ページ内のある要素を任意に変形させるのは容易ではありませんね。または、この角度が最適ではないケースだったのかもしれません。好奇心から、傾きの角度を変化させて検証すると、以下のような結果が得られました。

まず、わずか0.01度傾けるだけでも、負荷は非常に高いことが分かりました。そして傾きが大きくなるにつれてパフォーマンスは低下していきますが、直線的ではなく山型に変化しました(45度で最高値を示し、90度ではまた低めの値を示します)。
transformプロパティが処理速度に与える影響については、他にも検証できる内容がたくさんあります。transformプロパティで使用する様々な関数(translate, scale, skewなど)を使って、様々なブラウザ上でどのようなパフォーマンスを示すか見てみたいものです。

  1. Operaでは、ページズームのレベルが、レイアウトの処理時間に影響を与えます。ズームレベルが下がるとレンダリング時間が長くなります。同じ領域内でレンダリングが必要な要素が増えるわけですから、この結果には納得でしょう。些末なことと思われがちですが、検証の整合性を保持するためにも、ズームレベルが結果に影響を与えてないことを必ず確認してください。私は、ズームレベルによる影響が明らかになったたえ、全ての検証をやり直さなければなりませんでした。ズームレベルが影響を与えると、数値が変わってしまい、似て非なるものを比べることになってしまうからです。

ズームと言えば、フォントサイズを下げて、アプリ全体のパフォーマンスに与える影響を見るテストがありましたが、それはまだ有効なのでしょうか?

  1. Operaでは、ブラウザのウィンドウをリサイズしても、レンダリング速度への影響がありません。ウィンドウサイズによるレイアウト、ペイント、スタイルの計算への影響はないように思われます。

  2. Chromeでは、ブラウザのウィンドウをリサイズすると、パフォーマンスに影響が出ます。この結果は、ChromeがOperaと違い、可視領域のみをレンダリングするからだと思われます。

  3. Operaでは、ページのリロードによりパフォーマンスが低下します。パフォーマンスの低下は、ページのロード数が増えるにつれ直線的に変化します。下のグラフでは40ページ以上をリロードしていくと、レンダリング時間がゆっくり増加していく様子をご覧いただけます(グラフの一番下の赤い長方形が、若干の待機時間を挟み、ほぼ連続で続けられたページロードの時間に対応しています)。ペイント時間が最終的には3倍ほどに増加しているのがわかります。今にもページがあふれ出そうです。私は慎重を期して、常に最初の5つの結果の平均値を使って、更新の数値を得るようにしました。

Profiler Page Reload
検証に使用したスクリプト(ページのリロード):

window.onload = function() {
  setTimeout(function() {
    var match = location.href.match(/\?(\d+)$/);
    var index = match ? parseInt(match[1]) : 0;
    var numReloads = 10;
    index++;
    if (index < numReloads) {
      location.href = location.href.replace(/\?\d+$/, '') + '?' + index;
    }
  }, 5000);
};

Webkit(Chrome)上でページのリロードがパフォーマンスに影響を与えるかは検証しませんでした。

13.パフォーマンスを低下させる要因として興味深いものに、下記のようなSASSのコードのチャンクがありました。

a.remove > * {
  /* some styles */
  .ie7 & {
    margin-right: 0.25em;
  }
}

これは以下のようなCSSに変換できます:

a.remove > * { /* some styles */ }
.ie7 a.remove > * { margin-right: 0.25em }

追加されたie7というセレクタに着目し、どのような一般法則があるかを考えてください。ブラウザが右に書かれたセレクタから順々に確認するという法則のために、IE7(おそらく上の要素に含まれる.ie7が示している)を除く全てのブラウザで不必要な動作を行わなければならず、マッチングに時間がかかってしまいます。

下記のコードは更に意図的ではなく悪影響を与えます。

.steps {
li {
/* some styles */
.ie7 & {
zoom: 1;
  }
 }
}

これをCSSに変換すると以下のようになります。

.steps li { /* some styles */ }
.ie7 .steps li { zoom: 1 }

この場合でも、これ以上コードを検索しても「ie7」クラスを割り振られた要素は存在しないと判明するまで、ブラウザのエンジンは「steps」クラス内の各<li>要素を検索し続けなければなりません。

私の場合は、最終的なスタイルシートで.ie7や.ie8などをベースとするセレクタが100個近くありました。その中のいくつかはユニバーサルセレクタでした。解決方法は簡単です。IE関連のスタイルは条件分岐のコメントを介したものも含んで、全て別のスタイルシートに分ければいいのです。こうすると、パース、マッチング、適用させるセレクタの数を大幅に減らすことができます。

残念ながらこのような最適化の実行には、代償を伴います。IE関連のスタイルをオリジナルのスタイルの隣に併記した方が、保守が容易になることに気付きました。将来的にどこかを変更、追加、または削除することになったときに、編集する箇所はただ1カ所のみなので、IE関連の修正をうっかり忘れる確率が少なくなるからです。将来はおそらく、SASSのようなツールで、メイン処理のファイルから条件分岐を含むファイルに至るまで、このような宣言を最適化できるようになるでしょう。

  1. Chrome(とWebKit)では、ディベロッパツールの「Timeline」タブを使うと、再描画、リフロー、スタイルの再計算について、Operaと似たような情報を取得できます。また、このタブを使って、データをJSON形式でエクスポートすることもできます。その実例を私が初めて見たのは、Marcel Duran が2011年分の「実績カレンダー」を作ってきた時です。Marcelはnode.jsとスクリプトを使って、データの解析と抽出を実現しました。

ただ惜しいことに、彼のスクリプトでは「レイアウト」時間の中に「スタイルの再計算」も含めていました。私としては、これを含めたくありません。また、私はページのリロード(とその平均所要時間の取得)も避けたいと思いました。そこで私は手直しをして、ずっとシンプルなバージョンにしました。このスクリプトはデータ全体をひととおり見て、再描画、レイアウト、スタイルの計算に関するエントリを抽出し、エントリごとの実行時間を集計します。

 var LOGS = './logs/',
    fs = require('fs'),
    files =  fs.readdirSync(LOGS);

files.forEach(function (file, index) {
  var content = fs.readFileSync(LOGS + file),
      log,
      times = {
        Layout: 0,
        RecalculateStyles: 0,
        Paint: 0
      };

  try {
    log = JSON.parse(content);
  }
  catch(err) {
    console.log('Error parsing', file, ' ', err.message);
  }
  if (!log || !log.length) return;

  log.forEach(function (item) {
    if (item.type in times) {
      times[item.type] += item.endTime - item.startTime;
    }
  });

  console.log('\nStats for', file);
  console.log('\n  Layout\t\t', times.Layout.toFixed(2), 'ms');
  console.log('  Recalculate Styles\t', times.RecalculateStyles.toFixed(2), 'ms');
  console.log('  Paint\t\t\t', times.Paint.toFixed(2), 'ms\n');
  console.log('  Total\t\t\t', (times.Layout + times.RecalculateStyles + times.Paint).toFixed(2), 'ms\n');
});

Timelineデータを保存してスクリプトを実行すると、次に示すような情報が得られるでしょう。

Layout                      6.64 ms
Recalculate Styles          0.00 ms
Paint                       114.69 ms

Total                       121.33 ms

Chromeの「Timeline」とこのスクリプトを使って、私は先述のとおりOpera上で実行した、オリジナルのボタンのテストを改めて実行しました。結果は次のとおりです。

Operaと同様に、border-radiusの処理効率が良くありません。ただし、linear-gradientはOperaの場合よりも処理効率が悪く、box-shadowもtext-shadowに比べてはるかに高い値になっています。
ここでTimelineについて指摘しておきたいのは、こちらでは「レイアウト」情報だけが得られるという点です。一方Operaのプロファイラには、「レイアウト」に加えて「リフロー」情報もあります。WebKitの「Layout」には、Operaのリフローデータに似たものが既に含まれているのか、それともリフローデータは破棄されるのかは、分かっていません。正当なテスト結果を得るためにも、状況を把握した方がいいでしょう。

  1. 私が検証を終えようとしていた頃に、WebKitは、Operaのものに似たセレクタのプロファイラを公開しました。


さほど綿密なテストはできなかったものの、興味深い事実を1つ見つけました。WebKitのセレクタマッチングは、Operaよりも少しばかり速いのです。同じドキュメント、つまり私がテストで使っていた、シングルページアプリ(最適化する前の状態)を処理した場合で比べると、Operaではセレクタマッチングに1,144ミリ秒を要したのに対して、WebKitではわずか18ミリ秒でした。約65倍もの違いです。どちらかのエンジンで計算時に何かを無視しているか、WebKitではセレクタマッチングがずば抜けて速いかの、いずれかの理由が考えられます。ChromeのTimelineでは、スタイルの再計算は全体で37ミリ秒弱(WebKitにかなり近い値)、リペイントは52ミリ秒です(Operaはペイントの総計で225ミリ秒でした。Operaの処理は厳密にはChromeとは異なりますが、極めて似ています)。WebKitではTimelineのデータを保存できなかったので、リフローやリペイントの値はチェックしていません。

まとめ

  • セレクタ(IE関連のスタイル、.ie7 .foo .barなども含む)の総数を減らす
  • ユニバーサルセレクタの使用を避ける(修飾されていない属性セレクタも含む、例えば[type="url"]
  • ページのズームは、一部のブラウザ(例えばOpera)でCSSのパフォーマンスに影響を及ぼす
  • ウィンドウのサイズは、一部のブラウザ(例えばChrome)でCSSのパフォーマンスに影響を及ぼす
  • ページのリロードは一部のブラウザ(例えばOpera)でCSSのパフォーマンスに影響を及ぼす
  • 「border-radius」と「transform」は最も負荷の高いプロパティである(少なくともWebKitとOperaには当てはまる)
  • WebKitベースのブラウザに含まれている「Timeline」タブを使うと、recalc/reflow/repaint(再計算、ページの再構成、再描画)の所要時間を確認できる
  • WebKitはセレクタのマッチングをかなりの高速で処理できる

疑問

覚書の最後に、CSSのパフォーマンスに関して私が抱いている数多くの疑問の中で、ここで取り上げられなかったものを挙げておきます。
* 属性値を指定する場合、引用符の有無は処理効率に影響するのか? 例えば[type=search][type="search"]は違うのか? 引用符の有無は、セレクタのマッチング処理の効率にどう影響するのか?
* box-shadow、text-shadow、backgroundなどの指定を複数にすると、パフォーマンスにはどのような特徴が現れるのか? text-shadowを1回指定するのは、3 回指定する場合と比べてどうか? さらに5回の場合と比べてどうか?
* 疑似セレクタ(:before、:after)のパフォーマンスはどうなのか?
* border-radiusの値を変えると、パフォーマンスにどう影響するのか? radiusの値を高くすると処理に時間がかかるようになるのか? 値と処理時間は直線的な関係にあるのか?
* 「!important」宣言は、パフォーマンスに影響するのか? 影響があるとすればどの程度なのか?
* ハードウェアを高速化すると、パフォーマンスに影響があるか? あるとすればどの程度なのか?
* スタイルを指定すると、組み合わせに関係なく一様に負荷が上がるのか? 例えばtext-shadowとlinear-gradientを指定する場合と、単色の背景色にtext-shadowを指定する場合とで、処理速度に差はないのか?

将来

Webページやアプリは、インタラクティブな動作を増やす方向に進化しているため、CSSはより複雑になっています。またブラウザは“高度な”CSSの機能をサポートするようになっています。こうなると、CSSのパフォーマンスは恐らく、ますます重要になるでしょう。既存のツールでは、上っ面をなでることしかできません。モバイル版ブラウザ上でテストを実行するためのCSSが必要ですし、より多くのブラウザ(IE、Firefoxなど)でCSSを動作させるためのツールも必要でしょう。私はMozilla用のチケットを作成した ので、きっと近いうちに、何かを公開できるでしょう。CSSのパフォーマンスのデータを公開するスクリプトが登場するのを強く望んでいます。そうすれば、jsperf.comのようなツール(cssperf.com? )で、そのスクリプトが利用できるからです。それはそうと、現時点のプロファイラで実行したいテストは、ほかにも山ほどあります。さあ、何をぐずぐずしているのですか?