RenderingNG の詳細: LayoutNG

Ian Kilpatrick
Ian Kilpatrick
Koji Ishi
Koji Ishi

私、Ian Kilpatrick は、Blink レイアウト チームのエンジニアリング リードです。Ishii Koji と共に、Blink チームに加わる前は、Google ドキュメント、ドライブ、Gmail の機能の開発を担当するフロントエンド エンジニアでした(Google に「フロントエンド エンジニア」という役職が存在する前です)。その役割を 5 年ほど務めた後、私は大きな賭けをして Blink チームに異動し、職場で C++ を効果的に学び、非常に複雑な Blink コードベースを習得しようとしました。現在でも、理解しているのは比較的一部にすぎません。この期間、お時間をいただきありがとうございました。 私より前に、多くの「回復中のフロントエンド エンジニア」が「ブラウザ エンジニア」に転向していたという事実に安心しました。

これまでの経験が、Blink チームでの私自身の活動に役立っています。フロントエンド エンジニアとして、ブラウザの不整合、パフォーマンスの問題、レンダリング バグ、機能の欠落に常に直面していました。LayoutNG は、Blink のレイアウト システム内でこれらの問題を体系的に修正する機会でした。これは、長年にわたる多くのエンジニアの努力の集大成です。

この記事では、このような大規模なアーキテクチャ変更によって、さまざまな種類のバグやパフォーマンスの問題を減らし、軽減する方法について説明します。

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

これまで、Blink のレイアウト ツリーは「変更可能なツリー」でした。

次のテキストで説明するように、ツリーが表示されます。

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

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

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

このツリーのノードでレイアウトを実行すると、コンセプト的には「スタイルと DOM」と、親レイアウト システム(グリッド、ブロック、フレックス)の親の制約が取得され、レイアウト制約アルゴリズムが実行されて結果が生成されます。

前述のコンセプト モデル。

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

フラグメント ツリー。

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

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

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

レイアウト バグの種類

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

正確性

レンダリング システムのバグを考える場合、通常は正確性について考えます。たとえば、「ブラウザ A は X の動作をしますが、ブラウザ B は Y の動作をします」や「ブラウザ A と B の両方が機能しません」などです。以前は、この作業に多くの時間を費やし、その過程でシステムと常に戦っていました。一般的な障害モードは、1 つのバグに対して非常にターゲットを絞った修正を適用し、数週間後にシステムの別の(関連性がないと思われる)部分で回帰が発生したことに気付くというものでした。

前回の記事で説明したように、これはシステムが非常に脆弱であることを示しています。特にレイアウトについては、クラス間に明確な契約がないため、ブラウザ エンジニアは、依存すべきでない状態に依存したり、システムの別の部分の値を誤って解釈したりしていました。

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

LayoutNG でレイアウト システム内のすべてのコンポーネント間のコントラクトが明確に定義されたことで、変更をより確実に適用できるようになりました。また、優れた Web Platform Tests(WPT)プロジェクトも大きなメリットです。このプロジェクトでは、複数の参加者が共通のウェブテスト スイートに貢献できます。

現在、安定版チャネルで実際の回帰をリリースした場合、通常は WPT リポジトリに関連するテストがなく、コンポーネント コントラクトの誤解が原因で発生するものではありません。また、バグ修正ポリシーの一環として、常に新しい WPT テストを追加し、ブラウザが同じ間違いを繰り返さないようにしています。

無効化の不足

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

これは、後述する 2 パス(レイアウト ツリーを 2 回走査して最終的なレイアウト状態を決定する)レイアウト モードで非常に一般的です。以前のコードは次のようになります。

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

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

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

通常、この種の問題を修正すると、パフォーマンスが大幅に低下します(下記のオーバー無効化を参照)。また、修正は非常にデリケートです。

現在(上記のように)親レイアウトから子へのすべての入力を記述する不変の親制約オブジェクトがあります。生成された不変フラグメントとともに保存します。そのため、これらの 2 つの入力をdiffして、子に別のレイアウトパスを実行する必要があるかどうかを判断する一元化された場所があります。この差分ロジックは複雑ですが、適切に制御されています。このクラスの無効化不足の問題をデバッグする場合は、通常、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 回だけです。これはパフォーマンスには優れていますが、ウェブ デベロッパーが望むほど表現力豊かではありません。

たとえば、多くの場合、すべての子のサイズを最大のサイズに拡張します。これをサポートするために、親レイアウト(フレックスまたはグリッド)は測定パスを実行して各子のサイズを決定し、レイアウトパスを実行してすべての子をこのサイズに伸ばします。この動作は、フレックス レイアウトとグリッド レイアウトの両方でデフォルトです。

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

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

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

以前は、この種のパフォーマンスの急降下に対処するために、フレックス レイアウトとグリッド レイアウトに非常に具体的なキャッシュを追加していました。これは機能しましたが(Flex でかなりの進歩を遂げました)、無効化の不足と過剰のバグに常に悩まされていました。

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

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

まとめ

レイアウトは非常に複雑な領域であり、インライン レイアウトの最適化(実際には、インラインとテキストのサブシステム全体の仕組み)など、興味深い詳細については説明していません。ここで説明したコンセプトについても、表面的な部分しか触れていません。システムのアーキテクチャを体系的に改善することで、長期的に大きな成果が得られることがおわかりいただけたでしょうか。

とはいえ、まだ多くの課題が残されています。 Google は、解決に向けて取り組んでいる問題のクラス(パフォーマンスと正確性の両方)を認識しており、CSS に新しいレイアウト機能が導入されることを楽しみにしています。LayoutNG のアーキテクチャでは、これらの問題を安全かつ扱いやすく解決できると考えています。

Una Kravets による 1 枚の画像(どの画像かはおわかりでしょう)