轉譯 NG 深入解析:BlinkNG

柴格 (Stefan Zager)
Stefan Zager
克里斯哈利遜 (Chris Harrelson)
Chris Harrelson

Blink 是指 Chromium 採用的網路平台,包含合成之前的所有轉譯階段 (包括合成器修訂版本)。如要進一步瞭解閃爍轉譯架構,請參閱本系列文章中的這篇文章

Blink 原本就是 WebKit 的分支,它本身是 KHTML 的分支,其歷史可追溯至 1998 年。其中包含 Chromium 中最舊 (且最關鍵) 的部分程式碼,而到了 2014 年,它確實會顯示程式碼的存在時間。同年,我們在名為 BlinkNG 的橫幅開展了一系列宏大的專案,目標是解決組織中長期存在的缺陷以及 Blink 程式碼結構。本文將介紹 BlinkNG 及相關組成專案:我們完成這些專案的原因、完成的成果、制定設計指導原則的指導原則,以及日後可進行的改進。

BlinkNG 前後的轉譯管道。

轉譯 NG 之前的版本

Blink 中的轉譯管道在概念上一直分為多個階段 (stylelayoutpaint 等),但抽象化障礙已浮漏。大致來說,與算繪相關的資料是由長期的可變動物件組成。這些物件隨時都有可能修改過,而且經常被連續轉譯更新回收並重複使用。我們無法可靠地回答以下這類簡單的問題:

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

其中有許多範例,包括:

樣式會根據樣式表產生 ComputedStyle,但 ComputedStyle 無法變更;在某些情況下,則會在之後的管道階段修改。

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

樣式會產生決定「合成」的配件資料結構,且會在 style 之後每個階段中修改這些資料結構。

在較低層級,算繪資料類型主要由特殊樹狀結構 (例如 DOM 樹狀結構、樣式樹狀結構、版面配置樹狀結構、繪製屬性樹狀結構) 組成;轉譯階段則是以遞迴樹散步的方式實作。理想情況下,樹步道應包含:處理指定的樹狀結構節點時,我們不應存取位於該節點子樹狀結構中的任何資訊。這從以前不是真正的轉譯模型,而是從正在處理的節點祖系中經常存取的資訊。這使得系統變得十分脆弱,而且容易出錯。從任何位置都能開始對樹,只不過是樹木的根部。

最後,在整個程式碼中,有不少要塞進轉譯管道的難度包括:由 JavaScript 觸發的版面配置、文件載入期間觸發的部分更新、為了準備事件指定而強制更新、顯示系統要求的排程更新,以及只向測試程式碼公開的特殊 API 等等。轉譯管道中有一些「遞迴」和「重複」路徑 (也就是從另一個階段中間跳到某個階段的起始處)。每個 VM 預設坡道都有各自的慣用行為,在某些情況下,算繪結果則取決於觸發算繪更新的方式。

異動內容

BlinkNG 由許多大大小小的專案組成,其共同的目標在於消除先前所述的結構缺陷。這些專案共用一些指導原則,旨在讓轉譯管道更接近實際管道:

  • 統一進入點:我們一律應在開頭輸入管道。
  • 功能階段:每個階段都應有定義明確的輸入和輸出內容,而且其行為應為「功能性」,也就是可確定且可重複,而且輸出內容只取決於定義的輸入內容。
  • 常數輸入內容:在階段執行期間,任何階段的輸入內容都必須有效一致。
  • 不可變動的輸出:階段完成後,其輸出內容對於其餘轉譯更新的部分應該無法變更。
  • 檢查點一致性:在每個階段結束時,到目前為止產生的轉譯資料應該處於自我一致的狀態。
  • 簡化工作:只計算一次工作一次。

一份完整的 BlinkNG 子專案清單會造成繁瑣的讀數,不過下列是一些特殊的結果。

文件生命週期

DocumentLifecycle 類別可用來追蹤轉譯管道的進度。這可讓我們執行基本檢查,強制執行上述的不變體,例如:

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

在實作 BlinkNG 的過程中,我們有系統地刪除違反這些不變的程式碼路徑,並在整個程式碼中加入了更多斷言,確保不會迴歸。

如果您曾遇見兔子洞,查看低階轉譯程式碼,不妨問問自己:「這是怎麼到來的?」如前文所述,轉譯管道有許多不同的進入點。先前,這包括遞迴和重新呼叫的呼叫路徑,以及以中繼階段進入管道的位置,而非從頭開始。在 BlinkNG 的過程中,我們分析了這些呼叫路徑,並確定這些路徑全都可以減少到兩種基本情境:

  • 所有顯示資料都必須更新,例如產生多媒體廣告的新像素,或針對指定事件進行命中測試時。
  • 我們需要特定查詢的最新值,以便在不更新所有顯示資料的情況下回答。這包括大部分的 JavaScript 查詢,例如 node.offsetTop

轉譯管道現在只有兩個進入點,對應這兩種情境。移除或重構的重複程式碼路徑已移除,且無法再從中繼階段開始進入管道。這已經消除了算繪更新作業的時機和方式等諸多問題,讓您更輕鬆地瞭解系統的行為。

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

整體而言,paint 之前的轉譯階段會負責下列事項:

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

這份清單並未變更,但在 BlinkNG 之前,大部分工作都是以臨時方式進行,並分散至多個轉譯階段,且內建許多重複功能,而且效率不彰。舉例來說,style 階段一直主要負責計算節點的最終樣式屬性,但在少數特殊案例中,我們只有在 style 階段完成後才會確定最終樣式屬性值。在轉譯過程中,我們並沒有正式或可執行的階段,可以說是樣式資訊完整且不可變的。

「套用無效預先連結」問題的另一個例子是繪製無效問題。先前,繪製無效現象在整個轉譯階段都是在繪製前發生。修改樣式或版面配置程式碼時,我們很難判斷需要哪些繪製無效邏輯需要哪些變更,因此很容易犯下錯誤,導致出現不足或無效的錯誤。如要進一步瞭解舊版繪製無效系統的細節,請參閱 LayoutNG 系列文章。

將子像素版面配置幾何圖形,貼齊用於繪畫的整個像素邊界,就是一種例子,我們多次實作相同的功能,並且執行大量多餘的工作。繪製系統使用一個像素貼齊程式碼路徑,以及每當需要在繪製程式碼以外即時計算像素相隔座標時,即可使用完全獨立的程式碼路徑。不用說,每個實作項目都有各自的錯誤,結果也不一定一致。由於這項資訊沒有快取內容,因此有時可能會重複執行相同的運算,這反而會增加效能。

以下列舉幾項重要專案,在繪製前消除了算繪階段的架構缺陷。

Project Squad:融合風格階段

這項專案在風格階段解決了兩大缺陷,這導致無法清理管道:

樣式階段有兩個主要輸出:ComputedStyle,包含在 DOM 樹狀結構上執行 CSS 階層演算法的結果;以及 LayoutObjects 的樹狀結構 (用於建立版面配置階段的作業順序)。從概念上來說,執行階層式演算法必須嚴格執行,再產生版面配置樹狀結構;之前,這兩個操作是交錯的。Project Squad 成功將這兩個專案分為不同的序列階段。

先前,ComputedStyle 不一定每次在重新計算樣式時取得最終值;在某些情況下,ComputedStyle 會在後續管道階段更新。Project Squad 成功重構這些程式碼路徑,因此在樣式階段之後,ComputedStyle 就一律不會修改。

LayoutNG:繪製版面配置階段

這項臨時專案是 RenderingNG 的基石之一,是版面配置轉譯階段的完整重寫。我們不會在這裡對整個專案做出正規,但整體 BlinkNG 專案有幾點值得注意:

  • 先前,版面配置階段會接收由樣式階段建立的 LayoutObject 樹狀結構,並以大小和位置為樹狀結構加上註解。因此,輸出的輸入沒有乾淨的區隔。LayoutNG 導入了片段樹狀結構,這是版面配置的主要唯讀輸出內容,可做為後續轉譯階段的主要輸入。
  • LayoutNG 將 Containment 屬性帶到版面配置中:在計算特定 LayoutObject 的大小和位置時,我們不會再查看根層級以該物件為主的子樹狀結構。更新特定物件版面配置所需的所有資訊都會事先計算,並以唯讀的方式提供給演算法使用。
  • 先前在極端案例中,版面配置演算法並非完全正常運作:演算法的結果取決於最新的版面配置更新。因此 LayoutNG 已消除這些案例。

預先繪製階段

我們先前並未執行正式的前置算繪階段,而只是版面配置後作業需要的擷取袋。從辨識到的「預繪製」階段開始,我們發現有些相關函式在版面配置完成後,最適合做為版面配置樹狀結構的系統週遊遍佈;最重要的是:

  • 發出繪製無效情況:在版面配置過程中,如果資訊不完整,就難以正確繪製無效繪製。如果它分成兩個不同的程序,這樣做會比較簡單,而且效率也非常高:在樣式和版面配置期間,內容能以簡單的布林值標記標示,例如「可能需要繪製無效」。在油漆前散步期間,我們會檢查這些旗標,並在必要時產生無效問題。
  • 產生油漆屬性樹:以下程序會詳細說明。
  • 計算及記錄像素相鄰的繪製位置:錄製結果可用於繪製階段,以及任何需要這些結果的下游程式碼,不必進行多餘運算。

屬性樹:一致的幾何圖形

我們在轉譯 NG 初期推出屬性樹狀結構,以處理捲動的複雜性,且網頁結構的結構與所有其他視覺效果不同。在屬性樹狀結構之前,Chromium 的合成器使用單一的「圖層」階層來表示複合內容的幾何關係,但此架構很快就完全離不開,十分複雜的功能,例如 position:fix 表示已變得明顯。圖層階層會產生額外的非本機指標,用於指出圖層的「捲動父項」或「clip 父項」,但長期下來,對程式碼難以理解。

屬性樹狀結構可分別代表內容溢位捲動及內容裁剪部分,與所有其他視覺效果分開修正。進而正確地模擬網站的視覺和捲動結構。接下來,我們「只需」在屬性樹狀結構上方實作演算法,例如合成圖層的螢幕空間轉換,或是決定要捲動哪些圖層。

事實上,我們很快就發現程式碼中還有許多其他地方有類似的幾何問題。(鍵資料結構發布則具有更完整的清單)。其中多個開發人員重複實作了合成器程式碼正在執行的相同作業,而且所有錯誤子集均不同,而且也沒有正確建立真正的網站結構。接著,解決方案變得很清楚:將所有幾何圖形演算法集中在同一處,然後重構所有程式碼即可使用。

這些演算法反過來依賴屬性樹狀結構,因此屬性樹狀結構是「索引鍵」資料結構,也就是轉譯 NG 管道各採用的結構。因此,為了實現集中式幾何程式碼的目標,我們需要在管道前,在管道中更早引入屬性樹狀結構的概念,並且變更所有目前依賴這些樹狀圖的 API 才能在執行前執行。

本故事是 BlinkNG 重構模式的另一個方面:識別鍵運算、進行重構以避免重複,以及建立定義明確的管線階段,建立提供資料結構的資料結構。等到所有必要資訊都可供使用時,我們就會計算屬性樹狀結構;並確保屬性樹狀結構在執行之後的算繪階段時無法變更。

油漆後合成物:管線繪製和合成

「圖層化」是指確認哪些 DOM 內容會歸入自己的複合式圖層 (然後代表 GPU 紋理)。在轉譯 NG 之前,圖層會在繪製之前執行,而不是之後 (請參閱這裡瞭解目前的管道 - 請注意順序的變更)。我們會先決定 DOM 的哪些部分進入合成圖層,然後只針對這些紋理繪製顯示清單。當然,做決定的因素取決於多項因素,例如哪些 DOM 元素是動畫元素或捲動畫面,或是由 3D 轉換所呈現,以及哪些元素繪製在哪些元素上。

這會造成重大問題,因為程式碼中存在循環依附元件的更多程度或更少,這對轉譯管道而言是一大問題。現在來看看為什麼。假設我們需要「撤銷」繪製結果 (這代表我們必須重新繪製顯示清單,然後再次光柵化)。invalidate之所以需要無效,可能是因為 DOM 的變更,或者樣式或版面配置有所變更。但當然,我們當然只會將已實際變更的部分撤銷。這意味著您得找出受影響的複合式圖層,並使這些圖層的部分或所有顯示清單失效。

這表示撤銷作業取決於 DOM、樣式、版面配置和過去的分層化決策 (過去:先前轉譯影格的意義)。但是目前的分層機制也取決於所有這些因素。由於我們沒有兩份分層資料的副本,因此很難判斷過去與未來層次化決策之間的差異。最終我們達到了大量的程式碼 採用循環原因這種情況有時會發生程式碼錯誤或不正確的問題,甚至是當機或安全性問題,

為因應這種情況,我們提早介紹 DisableCompositingQueryAsserts 物件的概念。大多數情況下,如果程式碼嘗試查詢過去的分層化決策,在偵錯模式下就會造成斷言或瀏覽器當機。這有助於我們避免引入新的錯誤。在任何情況下,如果程式碼對於查詢過往的層次決策合法,我們會放入程式碼,以配置 DisableCompositingQueryAsserts 物件來允許編寫程式碼。

我們計劃逐步淘汰所有呼叫網站 DisableCompositingQueryAssert 物件,然後宣告程式碼安全無虞且正確無誤。但我們發現,只要層次在繪製前發生分層化,對於這些呼叫基本上就無法移除。(我們終於最近才移除它!)這是我們針對「 Paint」專案首次發現的第一個原因。我們發現,即使您為作業定義了明確的管道階段,如果作業位於管道中的錯誤位置,您最終還是會遇到困難。

「 Paint After」(繪製後) 專案的第二個原因是基本合成錯誤。說明這項錯誤的方式之一,就是 DOM 元素並未以 1:1 的方式呈現網頁內容的有效率或完整的分層配置。此外,由於在繪製前才完成合成,因此它原本取決於 DOM 元素,而不是顯示清單或屬性樹狀結構。這與我們導入屬性樹狀結構的原因非常類似,而且就像屬性樹狀圖一樣,只要找出適當的管道階段、在適當的時機執行程式碼,並提供正確的鍵資料結構,解決方案就會直接發揮作用。與屬性樹狀結構相同,這是一個很好的好機會,可以確保繪製階段完成後,所有後續管道階段的輸出內容都無法變更。

優點

如您所見,定義明確的轉譯管道可帶來極大的長期效益。您或許還會想:

  • 已大幅提升穩定性:這項功能相當容易理解。程式碼經過妥善定義且易於理解,更容易理解、撰寫和測試。這讓內容更可靠。此外,它還能讓程式碼更安全穩定,減少當機情形,並減少使用釋放後記憶體的錯誤。
  • 擴大測試涵蓋範圍:在 BlinkNG 的過程中,我們在套件中新增了許多實用的測試。其中也包括對內部提供專注驗證的單元測試;迴歸測試可避免我們導入我們已修正的舊錯誤 (太多!);以及許多對外維護的網路平台測試套件 (所有瀏覽器皆用於評估是否符合網路標準)。
  • 易於擴充:如果系統將各個系統細分成明確的元件,則不需要瞭解其他元件細節,就能推進目前的元件。這樣一來,每個人都能更輕鬆地為轉譯程式碼增添價值,不必精通專家,也能更輕鬆理解整個系統的行為。
  • 效能:最佳化以精簡程式碼編寫的演算法已相當困難,但如果沒有這類管道,要實現更大規模的事情,例如通用執行緒捲動和動畫,或網站隔離的程序和執行緒幾乎是不可能的。平行處理量可協助我們大幅提高效能,但也難度過於複雜。
  • 產出及遏制:BlinkNG 開發了多項新功能,能以全新及新方式推展管道。例如,如果只想在預算到期前執行轉譯管道,該怎麼辦?或者,您也可以針對目前與使用者無關的子樹略過轉譯程序。這就是 content-visibility CSS 屬性所啟用的功能。元件的樣式是否會取決於版面配置?也就是「容器查詢」

個案研究:容器查詢

容器查詢是備受期待的新網路平台功能 (多年來一直是 CSS 開發人員最期待的功能)。如果很棒,為什麼還不存在?這是因為導入容器查詢時,需要非常謹慎地瞭解與控制樣式和版面配置程式碼之間的關係。一起來仔細看看。

容器查詢可讓套用至元素的樣式取決於祖系的配置大小。由於版面配置大小是在版面配置上計算,因此我們需要在版面配置後執行樣式重新計算,但樣式重新計算功能會在版面配置之前執行!這個雞蛋惡作劇是為何在 BlinkNG 之前無法實作容器查詢的完整原因。

我們該如何解決這個問題?它不是回溯管道依附元件,也就是專案解決後 (如 Composite after Paint) 已解決的問題嗎?更糟的是,如果新樣式改變祖系的大小,該怎麼辦?這有時會導致無限迴圈嗎?

原則上,只要使用包含 CSS 屬性,即可解決循環依附元件的問題,這樣不僅能讓元素外的轉譯作業「不依賴該元素子樹狀結構內的算繪情形」。也就是說,容器套用的新樣式不會影響容器的大小,因為容器查詢「需要包含」

但事實上,這樣還不夠,機構才有必要導入較弱的隔離類型,而不僅僅是縮小規模。這是因為容器查詢容器只能根據內嵌尺寸,只向一個方向 (通常是區塊) 調整大小。新增內嵌大小納入的概念。但從那段很長的註解可看出,如果可以內嵌大小,一切都還不夠清楚。

使用抽象規格語言來描述遏制資訊是一回事,但以正確方式實作絕對是另一回事。請回想一下,BlinkNG 的其中一個目標,是要將遏制原則套用到構成轉譯主要邏輯的樹狀圖:當您週遊樹狀子目錄時,不需要從子樹狀結構外提供任何資訊。如果轉譯程式碼符合遏制原則,但發生這種情形 (儘管並非發生意外),我們會更簡潔,也更容易導入 CSS 遏制機制。

未來:從主執行緒合成...還有更多!

這裡顯示的轉譯管道其實比現行的 RenderingNG 實作還要久。其中分層化為非主執行緒,但目前還在主執行緒上。不過,這只是完成這項作業的一點,因為 Paint 已出貨並經過分層處理。

為了瞭解這麼做的重要性,以及這在哪些方面可能影響,我們必須從較高階的角度思考轉譯引擎的架構。改善 Chromium 效能時,最可靠的一項障礙之一,就是轉譯器的主執行緒會同時處理主要應用程式邏輯 (即執行指令碼) 和大量轉譯作業。因此,主要執行緒往往會隨工作量飽滿,而主執行緒壅塞通常是整個瀏覽器的瓶頸。

但好消息是,情況其實可以!這一方面,Chromium 的架構可追溯到 KHTML 天,當時單執行緒執行是主要的程式設計模型。隨著消費者級裝置普遍採用多核心處理器,單一執行緒假設已全面內建於 Blink (原為 WebKit) 中。我們一直想為轉譯引擎導入更多執行緒,但這在舊系統中是不可能的。轉譯 NG 的主要目標之一,就是從我們自己深入探究,並視情況將轉譯工作全部或全部移至其他執行緒 (或執行緒)。

BlinkNG 即將到來,我們就已經開始探索這個部分了;Non-Blocking Commit 是優先變更轉譯器執行緒模型的先驅。合成器修訂版本 (或簡稱「修訂」) 是主執行緒和合成器執行緒之間的同步處理步驟。在修訂期間,我們會建立主執行緒上產生的轉譯資料副本,供下游合成程式碼在合成器執行緒上執行的程式碼使用。進行這項同步處理時,主要執行緒會停止執行,同時複製程式碼會在合成器執行緒上執行。這麼做可確保主要執行緒在合成器執行緒複製資料時,不會修改其算繪資料。

若非封鎖修訂版本,則不需要主執行緒停止,並等待修訂階段結束。當修訂版本在合成器執行緒上並行執行時,主要執行緒會繼續執行工作。非封鎖修訂版本的淨效應會縮短在主執行緒上轉譯工作所需的時間,進而減少主執行緒的壅塞情形,並改善效能。截至本文撰寫時間 (2022 年 3 月),我們已擬定非封鎖承諾的原型,並準備深入分析此做法對成效的影響。

等待機翼屬於「離主執行緒合成」,目標是將分層從主執行緒移至背景工作執行緒,讓轉譯引擎與插圖保持一致。與非封鎖修訂版本相同,這麼做可以降低轉譯工作負載數量,藉此減少主執行緒上的壅塞情形。如果不對繪製後的複合模型進行架構改善,這樣的專案就永遠不可能實現。

管道中還有其他專案 (意外專案)!最後,我們終於有了一項基礎,可以讓您嘗試重新分配算繪工作,並一路見證可能的發展!