剖析極速 CSS 引擎:Quantum CSS(原名 Stylo)(下)

為什麼 CSS 引擎跑得飛快?

這是樣式計算未經優化的樣子:

裡面有很多任務要處理,而且,這些工作不只需在第一次載入頁面時執行。每當使用者與頁面互動、當他們的游標停留在頁面元素上或對 DOM 進行變更時,便會需要重新排樣式,這些工作就得一次又一次地重複執行。

正因為如此,CSS 樣式計算是最佳的優化目標,而各家瀏覽器在過去 20 年間已嘗試過各種不同的優化策略。Quantum CSS 做的,便是從不同引擎中擷取出最佳的策略,並用它們打造出一個超快的新引擎。

讓我們來看看這些最佳策略結合後迸發出的成果

平行處理

Servo 專案(Quantum CSS 的源頭)的目標是要打造一個能平行執行網頁渲染過程各項任務的實驗性瀏覽器。這代表什麼意義?

電腦好比人腦,裡面有個負責思考(ALU,算術邏輯單元)的區塊,其附近還有一些短期記憶體(register,暫存器),它們共同組成了 CPU。另外,還有長期的記憶體,也就是 RAM。

早期的電腦使用這種 CPU,一次只能思考一件事。但在過去十年間,CPU 已經進化成擁有由多個 ALU 和暫存器組成的核心。這意味著現代的 CPU 可同時 —即平行 — 思考好幾件事情。

Quantum CSS 把不同 DOM 節點的樣式計算工作分散到不同的核心上,以充分運用現代電腦的這個優勢。

這做法看似簡單(只要拆開 DOM 樹的分枝,在不同核心上執行即可),實則相當複雜。其中一個原因是 DOM 樹的枝幹往往不對稱,也就是說,某個核心恐怕得擔負遠重於其他核心的工作量。

為了更平均分配工作,Quantum CSS 用了一種名為「工作竊取」(work stealing)的技巧。當引擎正在處理 DOM 節點時,程式碼會拿其直系後代(direct children),將其分解為 1 或多個的「工作單元」,並將這些單元放入佇列。

當一個核心完成佇列內的工作後,它可在別的佇列中找事做。這意味我們可以平均分配工作,而不必事前花時間上下查看樹的節點來找分配的方法。

在大多數瀏覽器上,這都不是容易的事。平行處理是眾所周知的難題,而 CSS 引擎又極其複雜。更雪上加霜的是,它又被渲染引擎的另外兩個最複雜的元素(DOM 與頁面佈局)所夾擊,所以特別容易形成 bug。平行處理也可能造成一種極難追蹤的錯誤,名為資料競爭(data race)。我曾在另一篇文章中說明過此種程式錯誤。

如果你的程式接受了成百上千名工程師的無私貢獻,你該如何放心地讓程式平行運作呢?這就是我們要用 Rust 的最大原因。

透過 Rust,你可以靜態地驗證你沒有資料競爭,也就是說,你可以一開始就阻擋此種 bug 進入程式碼,而能完全避免發生棘手的錯誤 —因為過不了編譯器那一關。我將在以後的文章中做更進一步的說明。不過,在那之前,你可先參考有關 Rust 平行處理的影片或這篇關於工作竊取的深入解說

因為這個緣故,CSS 的樣式計算變成一個簡單到極點的平行問題 —幾乎沒有東西能阻擋你快速執行平行運算。這意味著我們可以達成近線性的加速。如果你的機器有四核,其執行速度便能提高近四倍。

以規則樹加快樣式重設(restyle)

針對每個 DOM 節點,CSS 引擎都需查看所有規則才能完成選擇器配對(selector matching)。對於大多數節點來說,這樣的配對不至於經常變化。譬如,當使用者把游標停在一個父元素(parent)上時,其對應的規則便可能改變。我們仍需重新計算其後代元素的樣式來處理屬性繼承,卻不見得必須改變與那些後代相配的規則。

如果我們可以記下哪些規則適用於哪些後代,我們就不必再為它們做選擇器配對,該有多好?好消息是,(從 Firefox 上一代 CSS 引擎借來的)規則樹就辦得到!

CSS 引擎會先找到相應的選擇器,再根據明確性來排序。據此,它便會製作出一個連結的規則清單。

而此清單會被加到樹中。

CSS 引擎會試圖把樹的分支減到最少。為此,它會盡可能重複使用分支。

若清單中大部分的選擇器和已有的分支一樣,它就會沿用同樣的路徑。但是,它也可能到後來發現清單中的下一條規則並不在樹的分支中 — 只有碰到這種情況時,它才會增加一個新的分支。

DOM 節點會得到一個指標,該指標會指向最後被插入的規則(在這個例子中,就是div#warning 規則)。這會是最明確的規則。

針對樣式重設,CSS 引擎會先做一次快速檢查、看看父元素的改變是否可能影響到與子元素相應的規則。如果不受影響的話,引擎便針對所有的後代元素,經由後代節點上的指標來取得規則。它可從那裡順著樹幹回到根節點,以獲得配對規則的完整清單(從最明確到最不明確的都有)。換言之,它能夠完全跳過選擇器配對及排序的步驟。

因此,這可大大減輕樣式重設的工作量。只不過,初始化樣式的工作依然繁重。如果你有 1 萬個節點,就還是得做1萬次的選擇器配對。幸好,有另一個方式可以加快速度。

利用樣式共享快取(style sharing cach)加快初始渲染(及串接)速度

試想一個有數千個節點的頁面,許多節點會與同樣的規則配對。譬如,在一個很長的維基百科頁面裡,主內容區塊內的段落應都會對應到一模一樣的規則,並擁有同樣之計算過的樣式。

如果沒有優化,CSS 引擎就非得為每個段落逐一匹配選擇器和計算樣式不可。但若有一種方法能證明不同段落的樣式都一樣,引擎便可只做一次工,並把每個段落節點指向同樣的計算樣式。

這就是所謂的「樣式共享快取」,其靈感來自於 Safari 和 Chrome。當引擎處理完一個節點後,它會把計算過的樣式放入快取。然後,在引擎開始計算下一個節點的樣式前,就會先做一些檢查來查看快取區裡有沒有可用的東西。

這些檢查包括:

  • 兩個節點是否擁有相同的 id、類別等特點?如果有,它們便會配到同樣的規則。
  • 針對不是來自選擇器配對的元素(如:行內樣式,inline style),引擎會檢查這些節點是否具有同樣的值。若是,先前的規則便不會被覆寫,或會被以同樣的方式被覆寫。
  • 兩個父元素是否指向同樣的計算樣式物件?若是,它們將有一樣的繼承值。

這些檢查從一開始就存在於早先的樣式共享快取中,只不過有很多樣式不匹配的小個案。譬如,如果 CSS 規則使用了 :first-child 選擇器,即使這些檢查顯示它們是匹配的,兩個段落還是不見得會配到相同的規則。

在 WebKit 和 Blink 中,若碰到這些情況,樣式共享快取便會放棄而不用快取。隨著越來越多網站採用這些現代的選擇器,優化就變得越來越不管用,也因為這個關係,Blink 團隊最近已移除了這個功能。不過,其實有另一種辦法可以讓樣式共享快取跟上現代的改變。

在 Quantum CSS 裡,我們把這些奇怪的選擇器全都收集起來,檢查它們是否適用於 DOM 節點。然後,我們把結果存為 1 與 0 的組合。如果有兩個元素擁有同樣的 1 和 0 排列,就能確定兩者相配。

如果一個 DOM 節點可共享計算過的樣式,你就能略過大部分的工作。由於頁面往往有許多樣式相同的 DOM 節點,樣式共享快取便可節省記憶體並確實加快處理速度。

結語

這是從第一波從 Servo 技術到 Firefox 瀏覽器的重大技術轉移。在這個過程中,我們學會如何將基於 Rust 語言的現代高效能程式碼帶入 Firefox 的核心。

我們非常開心能呈現量子專案的豐碩成果,並為 Firefox 使用者帶來第一手的體驗。希望你能不吝使用,如果發現任何問題也歡迎賜教。

原文連結

Like what you read? Give Mozilla Taiwan a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.