轉譯 NG 深入解析:BlinkNG

Stefan Zager
Stefan Zager
Chris Harrelson
Chris Harrelson

Blink 是指 Chromium 實作的網頁平台,涵蓋前置於合成的所有轉譯階段,最終完成合成器提交。如要進一步瞭解 Blink 轉譯架構,請參閱本系列先前的文章

Blink 最初是 WebKit 的分支,而 WebKit 本身則是 1998 年 KHTML 的分支。它包含 Chromium 中一些最古老 (也是最關鍵) 的程式碼,到了 2014 年,它已明顯顯露出老舊的痕跡。在那一年,我們以 BlinkNG 為名,展開一系列雄心勃勃的專案,目標是解決 Blink 程式碼組織和結構中長期存在的缺陷。本文將探討 BlinkNG 及其構成專案:我們為何開發這些專案、這些專案的成就、塑造設計的指導原則,以及日後可改善的機會。

BlinkNG 前後的轉譯管道。

算繪前 NG

在 Blink 中,算繪管道在概念上一向分為多個階段 (樣式版面配置繪圖等),但抽象障礙會發生漏洞。一般來說,與轉譯相關的資料包含長效可變物件。這些物件隨時都可以修改,而且也確實經常修改,並經常在後續轉譯更新中回收和重複使用。無法可靠地回答簡單的問題,例如:

  • 樣式、版面配置或繪圖的輸出內容是否需要更新?
  • 這些資料何時會取得「最終」值?
  • 何時可以修改這些資料?
  • 這個物件何時會刪除?

這類情況有很多,包括:

樣式會根據樣式表產生 ComputedStyle,但 ComputedStyle 並非不可變動,在某些情況下會在後續管道階段進行修改。

樣式會產生 LayoutObject 的樹狀結構,然後版面配置會在這些物件上加上大小和位置資訊的註解。在某些情況下,版面配置甚至會修改樹狀結構。版面配置的輸入和輸出內容沒有明確區隔。

Style 會產生輔助資料結構,用來決定合成的過程,而這些資料結構會在 style 後的每個階段就地修改。

在較低層級中,算繪資料類型主要由專門的樹狀結構組成 (例如 DOM 樹狀結構、樣式樹狀結構、版面配置樹狀結構、繪圖屬性樹狀結構);而算繪階段則是以遞迴樹狀結構檢查的方式實作。理想情況下,樹狀檢視應包含:處理特定樹狀節點時,我們不應存取該節點根目錄的子樹狀結構以外的任何資訊。在 RenderingNG 推出之前,這並非事實;樹狀檢視經常會存取所處理節點祖系的資訊。這使得系統非常脆弱,且容易出錯。也無法從樹狀結構的根節點以外的任何位置開始樹狀結構檢查。

最後,程式碼中散布著許多進入轉譯管道的匝道,包括由 JavaScript 觸發的強制版面配置、在文件載入期間觸發的部分更新、為事件指定目標而觸發的強制更新、顯示系統要求的排程更新,以及僅向測試程式碼公開的專用 API 等。其中甚至還有幾個遞迴重入的算繪管道路徑 (也就是從另一個階段的中間跳到某個階段的開頭)。每個 on-ramp 都有其獨特的行為,在某些情況下,算繪輸出的結果會取決於觸發算繪更新的方式。

異動內容

BlinkNG 由許多大小不一的子專案組成,這些專案都致力於消除先前所述的架構缺陷。這些專案都遵循幾項指導原則,讓轉譯管道更貼近實際管道:

  • 統一進入點:我們應該一開始就進入管道。
  • 功能性階段:每個階段都應有明確的輸入和輸出,且行為應為功能性,也就是確定性和可重複性,且輸出內容應只取決於定義的輸入內容。
  • 常數輸入:在階段執行期間,任何階段的輸入都應有效地保持不變。
  • 不可變動的輸出內容:階段完成後,其輸出內容應在後續轉譯更新期間保持不變。
  • 檢查點一致性:在每個階段結束時,目前產生的轉譯資料應處於自我一致的狀態。
  • 工作重複排除:只計算每項工作一次。

完整的 BlinkNG 子專案清單閱讀起來會很乏味,但以下幾個子專案特別重要。

文件生命週期

DocumentLifecycle 類別會追蹤轉譯管道的進度。這可讓我們執行基本檢查,強制執行先前列出的不變量,例如:

  • 如果我們要修改 ComputedStyle 屬性,則文件生命週期必須為 kInStyleRecalc
  • 如果 DocumentLifecycle 狀態為 kStyleClean 或更高版本,則 NeedsStyleRecalc() 必須針對任何已附加的節點傳回 false
  • 進入繪製生命週期階段時,生命週期狀態必須為 kPrePaintClean

在實作 BlinkNG 的過程中,我們有系統地排除違反這些不變量的程式碼路徑,並在程式碼中加入更多斷言,以確保不會發生回歸現象。

如果您曾經深入研究低階轉譯程式碼,可能會問自己:「我怎麼會來到這裡?」如先前所述,您可以透過多種方式進入轉譯處理管道。先前這項功能涵蓋遞迴和重入呼叫路徑,以及在中間階段進入管道的地點,而不是從一開始。在 BlinkNG 的過程中,我們分析了這些呼叫路徑,並判斷這些路徑可歸納為兩種基本情境:

  • 所有轉譯資料都需要更新,例如在產生用於顯示的新像素,或執行事件指定目標的命中測試時。
  • 我們需要特定查詢的最新值,這樣才能在不更新所有轉譯資料的情況下回答查詢。這包括大部分 JavaScript 查詢,例如 node.offsetTop

目前只有兩個進入點可進入轉譯管道,對應這兩種情況。已移除或重構重入程式碼路徑,因此無法從中繼階段開始進入管道。這項功能可消除許多與算繪更新的確切時間和方式相關的疑問,讓您更容易推斷系統的行為。

管線化樣式、版面配置和預先繪製

總體而言,paint 之前的轉譯階段負責執行下列操作:

  • 執行樣式層疊演算法,計算 DOM 節點的最終樣式屬性。
  • 產生代表文件方塊階層的版面配置樹狀圖。
  • 判斷所有方塊的大小和位置資訊。
  • 將子像素幾何圖形四捨五入或對齊整個像素邊界,以便繪製。
  • 判斷合成圖層的屬性 (仿射變換、濾鏡、不透明度或任何可由 GPU 加速的屬性)。
  • 判斷自上一個繪製階段以來,哪些內容已變更,且需要繪製或重新繪製 (繪製無效化)。

這份清單並未變更,但在 BlinkNG 推出之前,這項工作大多是透過臨時方式完成,並分散在多個轉譯階段,因此產生許多重複功能和內建的低效率。舉例來說,style 階段一向主要負責計算節點的最終樣式屬性,但在某些特殊情況下,我們會在 style 階段完成後才決定最終樣式屬性值。在轉譯過程中,我們無法透過任何正式或可強制執行的點,確切判斷樣式資訊是否完整且不可變動。

另一個 BlinkNG 推出前的問題是繪圖無效化。先前,繪圖無效化會散布在繪圖前所有轉譯階段。修改樣式或版面配置程式碼時,很難得知需要變更哪些繪圖無效化邏輯,而且很容易出錯,導致無效化不足或過度的問題。如要進一步瞭解舊版繪圖無效化系統的複雜性,請參閱本系列文章中專門討論 LayoutNG 的文章。

以繪製為例,將子像素版面配置幾何圖形對齊至整個像素邊界,就是我們實作相同功能的多個實作項目,並執行許多重複作業的例子。繪圖系統會使用一個像素對齊程式碼路徑,而當我們需要在繪圖程式碼之外,對像素對齊座標進行一次性即時計算時,就會使用完全獨立的程式碼路徑。不必多說,每個實作項目都有各自的錯誤,而且結果不一定一致。由於系統未對這類資訊進行快取,因此有時會重複執行相同的運算,這會對效能造成另一種負擔。

以下是一些重大專案,這些專案消除了繪製前算繪階段的架構缺陷。

Project Squad:管道化樣式階段

這個專案解決了樣式階段的兩個主要缺陷,導致無法以清晰的管道處理:

樣式階段有兩個主要輸出內容:ComputedStyle,其中包含在 DOM 樹狀結構上執行 CSS 階層演算法的結果;以及 LayoutObjects 的樹狀結構,用於建立版面配置階段的運算順序。從概念上來說,執行連鎖演算法時,應嚴格遵守先產生版面配置樹狀結構的規則;但先前這兩項作業是交錯執行的。Project Squad 成功將這兩項工作分成不同的連續階段。

先前,ComputedStyle 在樣式重新計算期間不一定會取得最終值;在某些情況下,ComputedStyle 會在較晚的管道階段更新。專案小組成功重構這些程式碼路徑,因此在樣式階段後,ComputedStyle 就不會再經過修改。

LayoutNG:版面配置階段的管道作業

這個重大專案是 RenderingNG 的基礎之一,完全重寫版面配置算繪階段。我們不會在這裡介紹整個專案,但 BlinkNG 專案整體有一些值得注意的面向:

  • 先前,版面配置階段會收到樣式階段建立的 LayoutObject 樹狀結構,並在樹狀結構中加上大小和位置資訊註解。因此,輸入內容和輸出內容並未完全分開。LayoutNG 引進了片段樹狀結構,這是版面配置的主要唯讀輸出內容,也是後續轉譯階段的主要輸入內容。
  • LayoutNG 將容器屬性帶入版面配置:計算特定 LayoutObject 的大小和位置時,我們不再查看以該物件為根的子樹外部。系統會事先計算所有更新特定物件版面配置所需的資訊,並將這些資訊做為唯讀輸入內容提供給演算法。
  • 先前有極少數情況下,版面配置演算法無法正常運作:演算法的結果取決於先前最新的版面配置更新。LayoutNG 已解決這些問題。

預先繪製階段

先前並沒有正式的預繪轉譯階段,只有一堆後版面配置作業。預繪階段的出現,是因為我們發現有幾個相關的函式,可在版面配置完成後,以系統化方式逐一檢視版面配置樹狀結構,這類函式可有效實作。最重要的是:

  • 發出繪圖無效化:如果資訊不完整,在版面配置期間正確執行繪圖無效化作業非常困難。如果將其拆分為兩個獨立程序,就能更容易正確執行,且效率也能大幅提升:在樣式和版面配置期間,可以使用簡單的布林旗標將內容標示為「可能需要繪圖無效化」。在預先繪製樹狀檢查期間,我們會檢查這些標記,並視需要發出無效項目。
  • 產生繪圖屬性樹狀結構:我們會在後續章節中詳細說明這個程序。
  • 計算及記錄以像素為單位對齊的繪圖位置:繪圖階段和任何需要這些結果的後續程式碼都可以使用記錄的結果,而不會產生任何多餘的運算。

房源樹狀結構:一致的幾何圖形

屬性樹狀結構在 RenderingNG 早期推出,用於處理捲動畫面的複雜性,因為在網路上,捲動畫面的結構與其他所有視覺效果不同。在屬性樹狀結構出現之前,Chromium 的轉譯器使用單一「圖層」階層來表示合成內容的幾何關係,但隨著 position:fixed 等功能的完整複雜性逐漸顯現,這種做法很快就失效了。圖層階層會產生額外的非本機指標,用於指出圖層的「捲動父項」或「剪輯父項」,不久之後,程式碼就變得難以理解。

屬性樹狀結構解決了這個問題,因為它會將內容的溢位捲動和剪輯方面與所有其他視覺效果分開表示。這可讓系統正確模擬網站的真實視覺和捲動結構。接下來,我們「只需」在屬性樹狀結構上實作演算法,例如組合圖層的螢幕空間轉換,或是判斷哪些圖層會捲動,哪些不會。

事實上,我們很快就發現程式碼中還有許多其他地方會產生類似的幾何問題。(關鍵資料結構文章提供更完整的清單)。其中幾個實作方式重複了合成器程式碼執行的相同動作;每個實作方式都有一些不同的錯誤集;而且沒有一個實作方式正確模擬真正的網站結構。解決方案隨即浮現:將所有幾何學演算法集中於單一位置,並重構所有程式碼以便使用。

這些演算法反過來都依賴屬性樹狀結構,因此屬性樹狀結構是 RenderingNG 管道中使用的關鍵資料結構。因此,為了達成這個集中幾何學程式的目標,我們需要在管道更早的階段 (在預先繪製階段) 引入屬性樹狀結構的概念,並且變更所有依賴這些屬性樹狀結構的 API,要求這些 API 在執行前先執行預先繪製作業。

這個故事是 BlinkNG 重構模式的另一個面向:找出主要運算,重構以避免重複運算,並建立明確的管道階段,以便建立可供這些運算使用的資料結構。我們會在所有必要資訊皆可取得的時間點計算屬性樹狀結構,並確保在後續轉譯階段執行時,屬性樹狀結構不會變更。

繪製後合成:繪製和合成的管道

分層是指找出哪些 DOM 內容會進入其專屬的複合層 (這會代表 GPU 紋理) 的程序。在 RenderingNG 之前,分層作業會在繪製前執行,而不是繪製後 (請參閱這裡的現行管道,注意順序變更)。我們會先決定 DOM 的哪些部分會進入哪個合成的圖層,然後再為這些紋理繪製顯示清單。當然,這些決定取決於許多因素,例如哪些 DOM 元素會進行動畫或捲動,或具有 3D 轉換,以及哪些元素會在哪些元素上方繪製。

這會造成重大問題,因為這麼做多少會導致程式碼中出現循環依附元件,而這對轉譯管道來說是個大問題。我們來看看以下範例,瞭解原因。假設我們需要失效繪圖 (也就是說,我們需要重新繪製顯示清單,然後再次將其轉為點陣圖)。需要讓無效的情況,可能是來自 DOM 的變更,或是樣式或版面配置的變更。不過,我們當然只想讓實際變更的部分失效。也就是說,您必須找出受影響的複合圖層,然後讓這些圖層的部分或全部顯示清單失效。

這表示無效化取決於 DOM、樣式、版面配置和先前的層化決策 (過去:指先前顯示的框架)。但目前的層級化也取決於所有這些因素。由於我們沒有所有資料層化資料的兩個副本,因此很難判斷過去和未來的資料層化決策之間的差異。因此,我們最終得到許多有循環論證的程式碼。因此,如果我們不小心,有時會導致程式碼不合邏輯或不正確,甚至發生當機或安全性問題。

為了因應這種情況,我們在早期就引入了 DisableCompositingQueryAsserts 物件的概念。在大多數情況下,如果程式碼嘗試查詢過去的層次化決策,會導致斷言失敗,並在偵錯模式下導致瀏覽器當機。這有助於避免引入新錯誤。在每個程式碼合法需要查詢過去分層決策的情況下,我們會透過分配 DisableCompositingQueryAsserts 物件,在程式碼中允許這項操作。

我們的計畫是逐步移除所有呼叫位置 DisableCompositingQueryAssert 物件,然後宣告程式碼安全且正確。不過,我們發現,只要在繪圖前進行分層處理,就無法移除許多呼叫。(我們終於在最近移除這項功能!)這是發現 Composite After Paint 專案的第一個原因。我們發現,即使您為作業定義了明確的管道階段,如果該階段位於管道中的位置不正確,最終還是會卡住。

採用「Composite After Paint」專案的第二個原因是 Fundamental Compositing 錯誤。說明這個錯誤的一種方式是,DOM 元素並非網頁內容有效或完整的層級化方案 1:1 表示法。由於合成作業是在繪製之前進行,因此本質上會依賴 DOM 元素,而非顯示清單或屬性樹狀結構。這與我們推出屬性樹狀結構的原因非常相似,只要找出正確的管道階段、在適當時間執行,並提供正確的關鍵資料結構,解決方案就會直接產生。就像屬性樹狀結構一樣,這也是確保繪製階段完成後,其輸出內容不會在後續管道階段發生變化的絕佳時機。

優點

如您所見,明確定義的轉譯管道可帶來巨大的長期效益。甚至比你想像的還要多:

  • 大幅提升可靠性:這項功能相當直覺。簡潔的程式碼具有明確且易懂的介面,因此更容易理解、編寫及測試。這會讓系統更可靠。這也能讓程式碼更安全、更穩定,減少當機和釋放後使用錯誤。
  • 擴大測試涵蓋範圍:在 BlinkNG 的過程中,我們在套件中加入了許多新測試。這包括可針對內部進行專注驗證的單元測試;可避免我們重新引入已修正的舊錯誤 (數量眾多!) 的回歸測試;以及針對公開的共同維護 Web 平台測試套件 (所有瀏覽器用於評估符合網路標準的準則) 所做的大量新增內容。
  • 更容易擴充:如果系統可細分為明確的元件,您不必瞭解其他元件的任何詳細層級,即可在目前的元件上取得進展。這樣一來,不必是專家也能輕鬆為轉譯程式碼增添價值,也能更輕鬆地推理整個系統的行為。
  • 效能:使用意大利麵條程式碼編寫的最佳化演算法已經很難優化,如果沒有這類管道,要實現更大規模的功能 (例如通用分頁捲動和動畫,或是網站隔離程序和執行緒) 幾乎是不可能的。並行處理可大幅提升效能,但也非常複雜。
  • 產生和容器:BlinkNG 提供多項新功能,可以新穎的方式運作管道。舉例來說,如果我們只想在預算到期前執行轉譯管道,該怎麼辦?或者,如果知道某個子樹目前與使用者無關,是否可以略過其算繪?這就是 content-visibility CSS 屬性可實現的功能。讓元件的樣式取決於其版面配置又如何?這就是容器查詢

個案研究:容器查詢

容器查詢是即將推出的網頁平台功能,也是 CSS 開發人員多年以來最希望推出的功能。如果這麼棒,為何還不存在?這是因為容器查詢的實作方式需要仔細理解並控制樣式和版面配置程式碼之間的關係。我們接著就來進一步瞭解。

容器查詢可讓套用至元素的樣式取決於祖系元素的版面配置大小。由於版面配置大小是在版面配置期間計算,因此我們需要在版面配置後執行樣式重新計算作業;但樣式重新計算作業會在版面配置前執行!這就是為什麼我們無法在 BlinkNG 之前實作容器查詢的根本原因。

我們該如何解決這個問題?這不是回溯管道依附元件,也就是說,這與 Composite After Paint 解決的問題相同嗎?更糟的是,如果新樣式變更了祖系的大小,這有時會導致無限迴圈嗎?

原則上,您可以使用 CSS 屬性來解決循環相依性問題,讓元素外部的轉譯作業不依賴該元素子樹內的轉譯作業。也就是說,容器套用的新樣式不會影響容器大小,因為容器查詢需要容器

但這還不夠,我們必須引入較弱的封裝類型,而非僅限於大小封裝。這是因為容器查詢容器通常會根據內嵌式尺寸,只在單一方向 (通常是塊) 調整大小。因此我們新增了內嵌大小限制的概念。不過,您可以從該部分的長篇附註中看到,在很長一段時間內,我們都無法確定是否可以使用內嵌大小限制。

在抽象規格語言中描述包含性是一回事,正確實作則是另一回事。請回想,BlinkNG 的其中一個目標,就是將包含原則應用於構成轉譯主要邏輯的樹狀檢查:在檢查子樹狀結構時,不應要求從子樹狀結構外部取得任何資訊。實際上 (雖然不完全是意外),如果轉譯程式碼遵循容納原則,實作 CSS 容納功能會更簡單、更清晰。

未來:主執行緒外合成,以及更多!

這裡顯示的算繪管道其實比目前的 RenderingNG 實作更先進。它顯示了分層處理作業已離開主執行緒,但目前仍在主執行緒中。不過,現在 Composite After Paint 已推出,且圖層化作業會在繪製後執行,因此這只是時間問題。

為了瞭解這項功能的重要性,以及可能帶來的其他效益,我們需要從較高層級的角度來考量算繪引擎的架構。改善 Chromium 效能最常見的障礙之一,就是轉譯器的主執行緒同時處理主要應用程式邏輯 (也就是執行指令碼) 和大部分的轉譯作業。因此,主執行緒經常會因為工作量過多而飽和,而主執行緒壅塞通常是整個瀏覽器的瓶頸。

好消息是,你不必非得這麼做!Chromium 架構的這項特點可追溯至 KHTML 時期,當時單執行緒執行是主要的程式設計模式。當多核心處理器在消費級裝置上變得普遍時,單執行緒假設已完全整合至 Blink (先前為 WebKit)。我們一直想在轉譯引擎中引入更多執行緒,但舊系統根本無法做到。這就是 Rendering NG 的主要目標之一,我們希望能解決這個問題,並讓您將部分或全部轉譯工作移至其他執行緒。

由於 BlinkNG 即將完成,我們已開始探索這個領域;非阻斷式提交是首次嘗試變更轉譯器的執行緒模型。合成器提交 (或只提交) 是主執行緒和合成器執行緒之間的同步步驟。在提交期間,我們會複製主執行緒產生的轉譯資料,供在轉譯器執行緒上執行的下游合成程式碼使用。在同步處理期間,主執行緒會停止執行,而複製程式碼會在轉譯器執行緒上執行。這麼做是為了確保主執行緒在轉譯器執行緒複製時,不會修改其轉譯資料。

由於非封鎖提交功能可讓主執行緒繼續執行,無須等待提交階段結束,因此主執行緒會在轉譯器執行緒上同時執行提交作業,並繼續執行工作。非阻斷式提交的淨效應,就是減少主執行緒上轉譯工作所需的時間,進而減少主執行緒的壅塞情形,並提升效能。截至本文撰寫時 (2022 年 3 月),我們已完成非阻斷式提交的可用原型,並準備對其對效能造成的影響進行詳細分析。

主執行緒外合成功能即將推出,其目標是將分層從主執行緒移出,並移至背景工作執行緒,讓轉譯引擎與插圖相符。與非封鎖提交一樣,這項功能會減少主執行緒的轉譯工作負載,進而降低主執行緒的壅塞情形。沒有 Composite After Paint 的架構改善功能,這類專案就不可能實現。

我們還有更多專案正在進行中 (雙關語)!我們終於有了基礎,可以嘗試重新分配轉譯工作,我們非常期待看到可能的結果!