RenderingNG の詳細: LayoutNG

Ian Kilpatrick 氏
Ian Kilpatrick
石光司
Koji Ishi

Blink レイアウトチームの エンジニアリングリードの Ian Kilpatrick と 石井幸二ですBlink チームで働く前は フロントエンド エンジニア(Google が「フロントエンド エンジニア」になる前)で Google ドキュメント、ドライブ、Gmail の機能を開発していました。 その職務を 5 年間務めた後、大規模なギャンブルを Blink チームに移し、その仕事で C++ を効果的に学び、非常に複雑な Blink コードベースを強化しようとしました。 現在でも、私はそのごく一部しか理解できていません。貴重なお時間をいただき、誠にありがとうございました。 多くの「フロントエンド エンジニアを復活させる」ために「ブラウザ エンジニア」になる前は、かなり前から動いていました。

それまでの Blink チームは、私個人としての経験を活かしてくれました。私はフロントエンド エンジニアとして、ブラウザの不整合、パフォーマンスの問題、レンダリングのバグ、機能の欠落に常に直面していました。LayoutNG は、Blink のレイアウト システム内でこれらの問題を体系的に修正する良い機会となり、長年にわたる多くのエンジニアの努力の積み重ねを象徴するものです。

この投稿では、このような大規模なアーキテクチャ変更により、さまざまな種類のバグやパフォーマンスの問題がどのように減少し、軽減されるかを説明します。

レイアウト エンジン アーキテクチャの概要

以前は、Blink のレイアウト ツリーが「可変ツリー」と呼ばれていました。

次のテキストで説明するようにツリーを表示します。

レイアウト ツリーの各オブジェクトには、入力情報(親によって課される使用可能なサイズ、浮動小数点数の位置など)、出力の情報(オブジェクトの最終的な幅と高さ、またはその x と y の位置など)が含まれていました。

これらのオブジェクトはレンダリングの合間に保持されていました。 スタイルが変更されると、そのオブジェクトとツリー内のすべての親をダーティとしてマークしました。レンダリング パイプラインのレイアウト フェーズが実行されたら、ツリーをクリーンアップし、ダーティ オブジェクトをすべて調べ、レイアウトを実行してクリーンな状態にします。

このアーキテクチャでは、以下に説明するさまざまなクラスの問題が発生しています。まずは一歩引いて、レイアウトの入力と出力について考えてみましょう。

このツリー内のノードでレイアウトを実行すると、概念的には「スタイル + DOM」と、親レイアウト システム(グリッド、ブロック、または Flex)の親制約を取得し、レイアウト制約アルゴリズムを実行して結果を生成します。

前述の概念モデル。

Google の新しいアーキテクチャは、この概念モデルを形式化したものです。レイアウト ツリーは引き続き使用できますが、主にレイアウトの入力と出力を保持するために使用します。 出力には、フラグメント ツリーというまったく新しい不変のオブジェクトを生成します。

フラグメント ツリー。

前回の不変フラグメント ツリーについて、増分レイアウトで前のツリーの大部分を再利用する設計について説明しました。

さらに、そのフラグメントを生成した親制約オブジェクトを保存します。 これをキャッシュキーとして使用します。詳細については後述します。

インライン(テキスト)レイアウト アルゴリズムも、新しい不変アーキテクチャに合わせて書き換えられています。インライン レイアウト用に不変のフラットリスト表現を生成するだけでなく、再レイアウトを高速化する段落レベルのキャッシュ、要素と単語にフォント機能を適用する段落ごとのシェイプ、ICU を使用した新しい Unicode 双方向アルゴリズム、多数の正確性修正などの機能も備えています。

レイアウトのバグの種類

レイアウト バグは大きく分けて 4 つのカテゴリに分類され、それぞれ根本原因が異なります。

正確性

レンダリング システムのバグについて考えるときは、通常は正確性について考えます。たとえば、「ブラウザ A の動作は X であるのに、ブラウザ B の動作は Y である」、「ブラウザ A と B は両方とも壊れている」などです。以前はこれに多くの時間を費やし その過程で常にシステムと戦っていました よくある失敗モードは、1 つのバグに対して対象を絞った修正を適用し、その数週間後にシステムの別の(一見無関係な)部分で回帰が発生したことに気づくというものです。

以前の投稿で説明したように、これはシステムが非常に脆弱であることを示しています。特にレイアウトについては、クラス間のコントラクトが明確でないため、ブラウザ エンジニアが本来使うべきではない状態に依存したり、システムの他の部分の値が誤って解釈されたりする原因となっていました。

一例として、ある時点で、Flex レイアウトに関連するバグが 1 年以上にわたって約 10 件発生していました。 修正が行われるたびに、システムの一部で正確性またはパフォーマンス上の問題が発生し、さらに別のバグにつながった。

LayoutNG ではレイアウト システム内のすべてのコンポーネント間のコントラクトが明確に定義されているため、より自信を持って変更を適用できることがわかりました。また、複数の関係者が共通のウェブテスト スイートに貢献できる優れたウェブ プラットフォーム テスト(WPT)プロジェクトの恩恵も受けています。

Stable チャンネルで実際にリグレッションをリリースした場合、通常は WPT リポジトリに関連テストがなく、コンポーネント契約の誤解が原因ではないことがわかりました。 さらに、Google のバグ修正ポリシーの一環として、ブラウザが同じ誤りを二度と起こさないよう、常に新しい WPT テストを追加しています。

無効化の不足

ブラウザ ウィンドウのサイズ変更や CSS プロパティの切り替えによってバグが解消されるという不思議なバグが発生した場合は、無効化不足の問題に遭遇します。 実質的には、可変ツリーの一部はクリーンであるとみなされていましたが、親の制約が変更されたため、正しい出力を表しませんでした。

これは、以下で説明する 2 パス(レイアウト ツリーを 2 回確認して最終的なレイアウト状態を決定する)レイアウト モードでよく発生します。以前のコードは次のようになります。

if (/* some very complicated statement */) {
  child->ForceLayout();
}

通常、このタイプのバグの修正は次のように行います。

if (/* some very complicated statement */ ||
    /* another very complicated statement */) {
  child->ForceLayout();
}

通常、このタイプの問題を修正すると、重大なパフォーマンスの低下を引き起こし(以下の過剰無効化を参照)、修正するのは非常に繊細でした。

現在(前述のとおり)、親レイアウトから子へのすべての入力を記述する不変の親 constraint オブジェクトがあります。これを、結果の不変フラグメントとともに保存します。 そのため、この 2 つの入力の diff を 1 か所で行い、子に対して別のレイアウトパスを実行する必要があるかどうかを判断しています。この比較ロジックは複雑ですが、十分に網羅されています。 この種類の無効化不足の問題をデバッグすると、通常、2 つの入力を手動で検査し、別のレイアウトパスが必要になるように入力内の何が変更されたかを判断します。

通常、この差分コードの修正はシンプルで、独立したオブジェクトを簡単に作成できるため、単体テストが可能です。

固定幅とパーセンテージ幅の画像の比較。
固定の幅/高さ要素は、指定された使用可能なサイズが増えても関係ありませんが、割合ベースの幅/高さは向上します。available-sizeParent Constraints オブジェクトで表し、差分アルゴリズムの一環としてこの最適化を行います。

上記の例の差分コードは次のとおりです。

if (width.IsPercent()) {
  if (old_constraints.WidthPercentageSize() 
    != new_constraints.WidthPercentageSize())
   return kNeedsLayout;
}
if (height.IsPercent()) {
  if (old_constraints.HeightPercentageSize() 
    != new_constraints.HeightPercentageSize())
   return kNeedsLayout;
}

ヒステリシス

この種類のバグは無効化中に似ています。 基本的に、以前のシステムでは、レイアウトがべき等であることを確認することは非常に困難でした。つまり、同じ入力を使用してレイアウトを再実行しても同じ出力が得られたことです。

以下の例では、単に CSS プロパティを 2 つの値の間で切り替えています。 しかし、この場合は「無限に拡大」する長方形になります。

動画とデモでは、Chrome 92 以前におけるヒステリシスのバグを示しています。この問題は Chrome 93 で修正されています。

以前の可変ツリーでは このようなバグが 非常に簡単に組み込まれました誤った時間またはステージでオブジェクトのサイズまたは位置を読み取ったコードが間違っていた場合(たとえば、以前のサイズや位置が「クリア」されなかった場合など)は、すぐに微妙なヒステリシス バグが追加されます。テストの大部分は 1 つのレイアウトとレンダリングに重点を置いているため、これらのバグは通常、テストでは発生しません。さらに問題なのは、一部のレイアウト モードを正しく動作させるには、このヒステリシスが必要であることもわかっていました。 最適化を実行してレイアウトパスを削除するバグもありましたが、正しい出力を取得するためにレイアウト モードで 2 つのパスが必要になるため、「バグ」が導入されました。

前のテキストで説明した問題を示すツリー。
以前のレイアウト結果情報によっては、非べき等レイアウトになる

LayoutNG では、明示的な入力および出力データ構造があり、以前の状態へのアクセスが許可されていないため、レイアウト システムから生じるこの種のバグを幅広く緩和しています。

過剰な無効化とパフォーマンス

これは、無効化の少ないバグの正反対です。 無効化下のバグを修正すると、パフォーマンスの急激な低下を招くことがよくあります。

パフォーマンスよりも正確性を優先するために、難しい選択を迫られることもよくありました。次のセクションでは、この種のパフォーマンスの問題を軽減する方法を詳しく見ていきます。

2 パス レイアウトとパフォーマンスの急激な増加

フレックス レイアウトとグリッド レイアウトは、ウェブ上のレイアウトの表現力に変化をもたらしました。 ただし、これらのアルゴリズムは、以前のブロック レイアウト アルゴリズムとは根本的に異なります。

ブロック レイアウト(ほとんどの場合)では、エンジンがそのすべての子に対してレイアウトを 1 回だけ実行する必要があります。パフォーマンスは向上しますが、ウェブ デベロッパーが望むほどの表現力は得られません。

たとえば、すべての子のサイズを最大のサイズに拡張する必要があることがよくあります。これをサポートするために、親レイアウト(flex または grid)は、測定パスを実行して各子のサイズを決定し、次にレイアウトパスを実行してすべての子をこのサイズに引き伸ばします。この動作は、フレキシブル レイアウトとグリッド レイアウトの両方でデフォルトです。

2 セットのボックス。1 つ目は測定パスの固有のサイズを示し、2 つ目は、すべて同じ高さのレイアウトです。

これらの 2 パス レイアウトは、一般的には深くネストされていなかったため、当初はパフォーマンスの面で許容範囲内でした。 しかし、より複雑なコンテンツが登場するにつれて、パフォーマンスに関する重大な問題が生じ始めました。 測定フェーズの結果をキャッシュに保存しない場合、レイアウト ツリーは measure 状態と最終的な layout 状態との間でスラッシングします。

キャプションで説明した 1 パス、2 パス、3 パス レイアウト。
上記の画像には、3 つの <div> 要素があります。 単純な 1 パス レイアウト(ブロック レイアウトと同様)では、3 つのレイアウト ノード(複雑さ O(n))にアクセスします。ただし、2 パス レイアウト(Flex や Grid など)では、この例での O(2n) アクセスが複雑になる可能性があります。
レイアウト時間の指数関数的な増加を示すグラフ。
この画像とデモは、指数レイアウトのグリッド レイアウトを示しています。Grid を新しいアーキテクチャに移行した結果、Chrome 93 ではこの問題が修正されています。

これまでは、この種のパフォーマンスの低下に対処するため、Flex レイアウトとグリッド レイアウトに特定のキャッシュを追加することを試みていました。 Flex ではうまくいきましたが 無効化のバグと絶えず闘いました

LayoutNG を使用すると、レイアウトの入力と出力の両方に明示的なデータ構造を作成できます。その上に、測定パスとレイアウトパスのキャッシュも構築されています。これにより複雑さが O(n) に戻り、ウェブ デベロッパーにとっては予測可能な線形パフォーマンスが実現します。レイアウトが 3 パス レイアウトを行っている場合は、そのパスもキャッシュするだけで済みます。 これにより、将来的に、より高度なレイアウト モードを安全に導入できる可能性があります。たとえば、RenderingNG によって全体的に拡張性が根本的に向上する可能性があります。場合によっては、グリッド レイアウトに 3 パス レイアウトが必要になる場合がありますが、現時点では非常にまれです。

デベロッパーに特にレイアウトに関するパフォーマンスの問題が発生した場合、通常はパイプラインのレイアウト ステージの生のスループットではなく、指数関数的なレイアウト時間のバグが原因であることがわかっています。小さな増分変更(1 つの要素で 1 つの CSS プロパティを変更する)でレイアウトが 50 ~ 100 ミリ秒になる場合は、指数レイアウトのバグである可能性があります。

まとめ

レイアウトは非常に複雑な領域です。インライン レイアウトの最適化(つまり、インラインとテキスト サブシステム全体の仕組み)など、興味深い内容についてはまだ説明していませんが、ここで取り上げたコンセプトでさえ、表面を傷つけ、多くの詳細に触れるだけで十分です。 しかし、システムのアーキテクチャを体系的に改善することで、長期的には大幅な改善につながることがわかっていれば幸いです。

とは言え、まだ多くの課題があることは承知しています。Google は、現在解決に取り組んでいるさまざまな問題(パフォーマンスと正確性の両方)を認識しており、CSS に導入される新しいレイアウト機能に期待しています。Google は、LayoutNG のアーキテクチャによって、このような問題を安全に解決できると考えています。

Una Kravets による 1 枚の画像