瀏覽器渲染流水線解析

阿里云云棲社群發表於2019-03-04

摘要:
若干年前,我寫過一篇介紹瀏覽器渲染流水線的文章 - How Rendering Work (in WebKit and Blink),這篇文章,一來部分內容已經過時,二來缺少一個全域性視角來對流水線整體進行分析,所以打算重新寫一篇新的文章,從一個更高抽象層次和高度簡化的方式對瀏覽器的渲染流水線進行解析,能讓大部分頁端同學都能夠看的明白,並以此作為指引來分析和優化頁面的渲染/動畫效能。

若干年前,我寫過一篇介紹瀏覽器渲染流水線的文章 - How Rendering Work (in WebKit and Blink),這篇文章,一來部分內容已經過時,二來缺少一個全域性視角來對流水線整體進行分析,所以打算重新寫一篇新的文章,從一個更高抽象層次和高度簡化的方式對瀏覽器的渲染流水線進行解析,能讓大部分頁端同學都能夠看的明白,並以此作為指引來分析和優化頁面的渲染/動畫效能。

有些基本概念如圖層,分塊,光柵化基本沒有發生變化,如果讀者不理解的話請參考 How Rendering Work (in WebKit and Blink),本文不再過多解釋。

本文基於當前版本的 Chrome 瀏覽器寫成(60 左右),理論上部分知識可以應用於其它瀏覽器(當然術語會有一定差別)或者 Chrome 後續的版本,但是並不完全保證這一點。

1. 渲染流水線

Browser Render Pipeline

上圖顯示了 Chrome 一個高度簡化後的渲染流水線示意圖:

  1. 最底層的是 Chrome 最核心的部分 Blink,負責JS的解析執行,HTML/CSS解析,DOM操作,排版,圖層樹的構建和更新等任務;
  2. Layer Compositor(圖層合成器)接收 Blink 的輸入,負責圖層樹的管理,圖層的滾動,旋轉等矩陣變幻,圖層的分塊,光柵化,紋理上傳等任務;
  3. Display Compositor 接收 Layer Compositor 的輸入,負責輸出最終的 OpenGL 繪製指令,將網頁內容通過 GL 貼圖操作繪製到目標視窗上,如果忽略掉作業系統本身的視窗合成器,也可以簡單認為是繪製在螢幕上;

當我們說 Compositor,在沒有加修飾語的情況下,一般都是指 Layer Compositor。另外術語 Child Compositor(子合成器)也是指 Layer Compositor,相對於作為 Parent 的 Display Compositor 而言。

1.1 程式與執行緒

一個 Chrome 瀏覽器一般會有一個 Browser 程式,一個 GPU 程式,和多個 Renderer 程式,通常每個 Renderer 程式對應一個頁面。在特殊架構(Android WebView)或者特定配置下,Browser 程式可以兼作 GPU 程式或者 Renderer 程式(意味著沒有獨立的 GPU 或者 Renderer 程式),但是 Browser 跟 Renderer,Browser 跟 GPU,Renderer 跟 GPU 之間的系統架構和通訊方式基本保持不變,執行緒架構也是同樣。

  1. Blink 主要執行在 Renderer 程式的 Renderer 執行緒,我們通常會稱之為核心主執行緒;
  2. Layer Compositor 主要執行在 Renderer 程式的 Compositor 執行緒;
  3. Display Compositor 主要執行在 Browser 程式的 UI 執行緒;

Display Compositor 未來應該會移到 GPU 程式的主 GPU 執行緒,當然對父子合成器進行排程的部分仍然是在 Browser 程式的 UI 執行緒。

1.2 幀

所有的渲染流水線都會有幀的概念,幀這個概念抽象描述了渲染流水線下級模組往上級模組輸出的繪製內容相關資料的封裝。我們可以看到 Blink 輸出 Main Frame 給 Layer Compositor,Layer Compositor 輸出 Compositor Frame 給 Display Compositor,Display Compositor 輸出 GL Frame 給 Window。我們覺得一個動畫是否流暢,最終取決於 GL Frame 的幀率(也就是目標視窗的繪製更新頻率),而覺得一個觸屏操作是否響應即時,取決於從 Blink 處理事件到 Window 更新的整個過程的耗時(理論上應該還要加上事件從 Browser 傳送給 Compositor,再傳送給 Blink 的這個過程的耗時)。

1.1.1 Main Frame

Main Frame 包含了對網頁內容的描述,主要以繪圖指令的形式,或者可以簡單理解為某個時間點對整個網頁的一個向量圖快照(可以區域性更新)。當前版本的 Chrome,圖層化的決策仍然由 Blink 來負責,Blink 需要決定如何根據網頁的 DOM 樹來生成一顆圖層樹,並以 DisplayList 的形式記錄每個圖層的內容(未來圖層化決策應該會轉移到 Layer Compositor,Blink 只輸出 DisplayList 樹和 DisplayList 節點的關鍵屬性,同時 DisplayList 不再以圖層作為單位,而是以每個排版物件作為單位)。

圖層化決策一般由以下幾個因素決定:

  1. 特殊元素如 Plugin,Video,Canvas(WebGL);
  2. 維護正確的層級關係來保證繪製順序是正確的,比如 Overlap 的計算
  3. 減少圖層樹的結構變更,減少圖層內容的變更(目前 Blink 網頁內容的變更是以圖層為原子單位的,如果以一個元素為根節點生成圖層,該元素的某些 CSS 屬性如 Transform 的變更不會引起所屬圖層內容的變更);

第三點是可以被頁端所直接控制來優化圖層結構及 Main Frame 效能,像傳統的 translate3d hack 和新的 CSS 屬性 will-change。

1.2.2 Compositor Frame

Layer Compositor 接收 Blink 生成的 Main Frame,並轉換成合成器內部的圖層樹結構(因為圖層化決策仍然由 Blink 負責,所以這裡的轉換基本上可以認為是生成一棵同樣的樹,再對逐個圖層的進行拷貝)。

Layer Compositor 需要為每個圖層進行分塊,為每個分塊分配 Resource(Texture 的封裝),然後安排光柵化任務。

當 Layer Compositor 接收到來自 Browser 的繪製請求時,它會為當前可見區域的每個圖層的每個分塊生成一個 Draw Quad 的繪製指令(矩形繪製,指令實際上指定了座標,大小,變換矩陣等屬性),所有的 Draw Quad 指令和對應的 Resource 的集合就構成了 Compositor Frame。Compositor Frame 被髮送往 Browser,並最終到達 Display Compositor(未來也可以直接發給 Display Compositor)。

1.2.3 GL Frame

Display Compositor 將 Compositor Frame 的每個 Draw Quad 繪製指令轉換一個 GL 多邊形繪圖指令,使用對應 Resource 封裝的 Texture 對目標視窗進行貼圖,這些 GL 繪圖指令的集合就構成了一個 GL Frame,最終由 GPU 執行這些 GL 指令完成網頁在視窗上佔據的可見區域的繪製。

1.3 排程

Chrome 渲染流水線的排程是基於請求和狀態機響應,排程的最上級中樞執行在 Browser UI 執行緒,它按顯示器的 VSync(垂直同步)週期向 Layer Compositor 發出輸出下一幀的請求,而 Layer Compositor 根據自身狀態機的狀態決定是否需要 Blink 輸出下一幀。

Display Compositor 則比較簡單,它持有一個 Compositor Frame 的佇列不斷的進行取出和繪製,輸出的頻率唯二地取決於 Compositor Frame 的輸入頻率和自身繪製 GL Frame 的耗時。基本上可以認為 Layer Compositor 和 Display Compositor 是生產者和消費者的關係。

2. 網頁動畫

動畫可以看做是一個連續的幀序列的組合。我們把網頁的動畫分成兩大類 —— 一類是合成器動畫,一類是非合成器動畫(UC 內部也將其稱為核心動畫,雖然這不是 Chrome 官方的術語)。

  1. 合成器動畫顧名思義,動畫的每一幀都是由 Layer Compositor 生成並輸出的,合成器自身驅動著整個動畫的執行,在動畫的過程中,不需要新的 Main Frame 輸入;
  2. 核心動畫,每一幀都是由 Blink 生成,都需要產生一個新的 Main Frame;

2.1 合成器動畫

合成器動畫又可以分為兩類:

  1. 合成器本身觸發並執行的,比如最常見的網頁慣性滾動,包括整個網頁或者某個頁內可滾動元素的滾動;
  2. Blink 觸發然後交由合成器執行,比如說傳統的 CSS Translation 或者新的 Animation API,如果它們觸發的動畫經由 Blink 判斷可以交由合成器執行;

Blink 觸發的動畫,如果是 Transform 和 Opacity 屬性的動畫基本上都可以由合成器執行,因為它們沒有改變圖層的內容。不過即使可以交由合成器執行,它們也需要產生一個新的 Main Frame 提交給合成器來觸發這個動畫,如果這個 Main Frame 包含了大量的圖層變更,也會導致觸發的瞬間卡頓,頁端事先對圖層結構進行優化可以避免這個問題。

2.2 非合成器動畫

非合成器動畫也可以分為兩類:

  1. 使用 CSS Translation 或者 Animation API 建立的動畫,但是無法由合成器執行;
  2. 使用 Timer 或者 RAF 由 JS 驅動的動畫,比較典型的就是 Canvas/WebGL 遊戲,這種動畫實際上是由頁端自己定義的,瀏覽器本身並沒有對應的動畫的概念,也就是說瀏覽器本身是不知道這個動畫什麼時候開始,是否正在執行,什麼時候結束,這些完全是頁端自己的內部邏輯;

合成器動畫和非合成器動畫在渲染流水線上有較大的差異,後者更復雜,流水線更長。上面四種動畫的分類,按渲染流水線的複雜程度和理論效能排列(複雜程度由低到高,理論效能由高到低):

  1. 合成器本身觸發並執行的動畫;
  2. Blink 觸發,合成器執行的動畫;
  3. Blink 觸發,無法由合成器執行的動畫;
  4. 由 Timer/RAF 驅動的 JS 動畫;

長久以來,瀏覽器渲染流水線的設計都主要是為了合成器動畫的效能而優化,甚至在某種程度上導致核心動畫效能的下降,比如說合成器的非同步光柵化機制。不過這兩年,隨著對 WebApp 渲染效能包括 WebGL 效能的重視,並且隨著主流移動裝置的硬體效能持續提升,合成器動畫的效能也已經基本不成問題,Chrome 的渲染流水線已經更多地針對核心動畫的效能進行優化,甚至會導致在某些特定狀況下合成器動畫效能的下降,比方說傾向於為了維持圖層樹的穩定性,減少變更,而生成更多的圖層。不過總的說來,目前 Chrome 的渲染流水線,在主流的移動裝置上,大部分場景下,兩者效能都能獲得一個較好的平衡。

3. 動畫效能分析基礎

這裡的效能分析主要是針對移動裝置,以桌面處理器的效能,大部分場景下都不存在效能問題。目前移動裝置的螢幕重新整理率基本上都是 60hz,而瀏覽器跟其它應用一樣,需要跟螢幕重新整理保持垂直同步,也就是動畫幀率的上限是 60 幀,這也是我們能夠達到的最理想的結果。不過考慮瀏覽器本身的複雜程度,可能有很多後臺任務在執行,而且作業系統本身也可能同時執行其它後臺任務,並且移動平臺要考慮能耗和散熱,CPU/GPU 的排程策略會頻繁地發生變化,要完全鎖定 60 幀是非常困難的。

如果上限超過 60 幀,實際平均幀率超過 60 反而不難,但是如果上限是 60 幀,垂直同步下要鎖定 60 幀是非常困難的,要求每一幀的各個環節耗時都要保持非常穩定。

一般而言:

  1. 幀率在 55 ~ 60 之間已經可以認為是非常優秀的水平,這時使用者幾乎感覺不到卡頓;
  2. 幀率在 50 ~ 55 之間可以認為是良好的水平,使用者感覺到輕微卡頓,但整體來說還是比較流暢;

要達到 50 幀以上的水平,我們就需要對動畫在渲染流水線的每個重要環節進行效能計算,需要知道這些環節最長允許的耗時上限和網頁影響這些環節耗時的主要原因,雖然實際上很難完全鎖定 60 幀,但是一般來說效能分析/優化還是會以 60 幀為目標來倒推各個環節的最大耗時。

如果是場景比較複雜的 Canvas/WebGL 遊戲,以 30 幀為目標幀率是一個合理的訴求。

3.1 光柵化機制

在對動畫效能進行分析之前,需要先說明一下目前的 Chrome 的光柵化機制。合成器會監控是否需要安排新的光柵化任務,當需要光柵化排程時:

  1. 合成器找到所有在當前可見區域的圖層;
  2. 合成器找到這些圖層在當前可見區域的分塊;
  3. 合成器檢查這些分塊是否需要光柵化,如果需要,生成一個對應的光柵化任務並分配所需要的 Resource 放入任務佇列裡面;
  4. Renderer 程式會預先建立一個或者多個 Worker 執行緒(移動平臺一般是兩個),這些執行緒會從任務佇列裡面順序取出每一個光柵化任務並執行;
  5. 光柵化任務執行後,會通知合成器,合成器根據需要檢查哪些任務已經完成,已經完成的任務, Resource 會轉交給對應的分塊;

實際的光柵化區域會比當前可見區域要更大一些,一般是增加一個分塊大小單位,對不可見區域的預光柵化有助於提升合成器動畫的效能和減少出現空白的機率。

從上可知,合成器的光柵化排程完全是非同步的,合成器在 Compositor 執行緒需要執行的就是安排光柵化任務和檢查哪些任務已經完成,Compositor 執行緒本身不會被真正執行光柵化任務的 Worker 執行緒所阻塞。

4 合成器動畫效能分析和優化指南

4.1 動畫流水線

Compositor Animation

上圖顯示了合成器動畫的渲染流水線示意圖,根據 Android WebView 平臺的實現進行繪製,其它平臺可能略微不同,但對後面的效能分析,在大部分情況下影響不大

整個流水線的大概過程是:

  1. 位於 Browser 程式 UI 執行緒的視窗管理器接收到來自作業系統的螢幕重新整理垂直同步訊號(VSync),開始準備輸出新的一幀,它首先給位於 Renderer 程式 Compositor 執行緒的 Layer Compositor 傳送一個 Begin Frame 訊息;
  2. Layer Compositor 接收到 Begin Frame 訊息後,更新合成器內部的狀態機,開始準備輸出 Compositor Frame,在這個過程中的一個重要動作就是 Animate,合成器會檢查當前是否有正在執行的動畫,然後執行這些動畫,並根據動畫執行的結果改變關聯圖層的對應屬性(比如慣性滾動動畫改變圖層的 Scroll Offset,Transform 動畫改變圖層的 Transform),Animate 的結果會傳送回給 UI 執行緒告訴其是否有動畫正在執行,需要更新視窗;
  3. 如果 UI 執行緒確定合成器需要更新視窗,則會傳送一個 Draw 訊息請求合成器輸出下一幀 Compositor Frame;
  4. 合成器按下面的過程產生新的 Compositor Frame 併傳送給 Display Compositor; 4.1 合成器找出在當前可見區域內顯示的圖層; 4.2 合成器找出這些圖層在可見區域內的分塊; 4.3 如果該分塊已經有分配 Resource(說明此分塊已經完成光柵化),則產生一個 Draw Quad 的命令置入 Compositor Frame 中,如果沒有則跳過;
  5. Display Compositor 接受到新的 Compositor Frame 後,對 Compositor Frame 進行 Render,將每一個 Draw Quad 命令轉換成一個 GL Draw Call,然後 GPU 執行所有的 GL 指令完成最後的視窗繪製;

上述流程的一些關鍵點是:

  1. Draw 的過程中,合成器不會等待可見的分塊光柵化完成,這讓合成器充分利用了非同步光柵化的機制來提升效能,但是也會造成動畫過程中可能會出現空白的分塊,比如快速滾動頁面有時會看到空白區域;
  2. 在合成器動畫過程中,Layer Compositor 和 Display Compositor 是非同步併發的,在 Display Compositor 輸出 GL Frame N 的時候,Layer Compositor 已經可以開始輸出下一幀 Compositor Frame N + 1;

4.2 動畫耗時分析

  1. Begin Frame 的耗時一般很短,大概 1 ~ 2 毫秒左右;
  2. Draw 的耗時也不長,一般不超過 5 毫秒,耗時主要取決於網頁的圖層複雜度,總的來說合成器動畫過程中 Compositor 執行緒的開銷一般都不會構成效能瓶頸;
  3. Render 的耗時也不長,一般也是不超過 5 毫秒,耗時主要取決於當前可見區域內的可見分塊的數量;
  4. GPU 部分的耗時比較長,耗時主要取決於當前可見區域內的可見分塊的總面積,也就是繪製的總面積,一旦 Render + GPU 部分的耗時大於 16.7 毫秒,動畫就會出現掉幀;

總的來說影響合成器動畫效能的最關鍵因素就是過度繪製係數(Overdraw,可以理解為繪製的面積和可見區域面積的比例),如果網頁本身存在大量圖層堆疊情況,導致過度繪製係數過高,就會嚴重影響合成器動畫的效能。經驗顯示,過度繪製係數比較理想的值是在 2 以內,一般建議不超過 3,這樣可以保證在中低端的移動裝置上也有不錯的效能表現。

另外,合成器動畫過程中,Compositor 和 GPU 執行緒是前臺執行緒,它們雖然理論上不會被 Worker 和 Renderer 執行緒阻塞,但是在真實的執行場景中,移動裝置的 CPU/GPU 和記憶體頻寬等硬體資源是有限的,如果 Worker 和 Renderer 執行緒處於高負荷狀態下,也會導致前臺的 Compositor 和 GPU 執行緒阻塞,最終導致合成器動畫掉幀。

這種現象常見於:

  1. 網頁在合成器動畫比如慣性滾動過程中,有大量的 JS 載入圖片或者其它內容,並頻繁地對 DOM 樹進行操作;
  2. 網頁的圖層樹非常複雜,並且其結構在合成器動畫過程中頻繁發生變化,導致大量的光柵化任務在 Worker 執行緒執行;

4.3 動畫效能優化 Checklist

根據上述的耗時分析,我們可以給出一個頁端優化合成器動畫效能的簡單 Checklist:

  1. 檢查網頁的圖層結構是否合理,包括深度和數量,一般來說深度在 10 以內,數量在 100 以內是比較合理的值;
  2. 檢查網頁的合成器動畫,包括網頁的慣性滾動,各種圖層的淡入/淡出等動畫,在動畫過程中,是否同時存在大量的網路載入和 DOM 操作,網頁圖層結構是否保持穩定;
  3. 當網頁處於任一滾動位置上時,它的當前過度繪製係數是否合理;

如何判斷網頁的圖層結構是否穩定,一般而言,如果是位於葉子節點的圖層增加或者移除,對整個圖層結構影響並不大,但是如果是中間節點的圖層增加或者移除,對圖層結構的影響就比較大了,並且越是接近根節點,影響就越大。

現在的頁端都會大量使用非同步載入來優化載入效能和流量,但是容易出現導致動畫掉幀的現象。要平衡好這一點意味著需要實現一個載入和關聯 DOM 操作的排程器,如果檢查到動畫正在執行,則停止載入或者通過節流閥機制降低載入的併發數量和頻率,同時可以通過事先生成相應的 DOM 節點和圖層作為佔位符來避免載入後的圖層結構發生劇烈變化。

5 非合成器動畫效能分析和優化指南

前面已經我們已經把非合成器動畫區分為 Blink 觸發,無法由合成器執行的動畫和由 Timer/RAF 驅動的 JS 動畫兩類,因為前者可以認為是後者的一個簡化版本,所以這一章主要討論 Timer/RAF 驅動的 JS 動畫。

5.1 動畫流水線

Blink Animation

從上圖可以看出非合成器動畫的流水線比合成器動畫更長更復雜,並且非合成器動畫的後半段跟合成器動畫是一致的。

  1. JavaScipt 部分是頁端實現的邏輯,可能包含了計算的部分,和呼叫瀏覽器提供的 API 的部分(修改 DOM 樹,CSS 屬性等),最終改變了網頁的內容;
  2. 網頁內容被改變會導致 Blink 生成新的 MainFrame,MainFrame 包括了重排版,更新圖層樹,和重新記錄發生變更的圖層的內容,生成新的 DisplayList,等等;
  3. Blink 生成新的 MainFrame 後需要向合成器發起 Commit 的請求,合成器在 Commit 過程中根據 MainFrame 生成自身的圖層樹,Blink 在 Commit 的過程中保持阻塞狀態,Commit 完成後再繼續執行;
  4. 合成器實際上有兩棵圖層樹,新提交的 MainFrame 生成的是 Pending 樹,用於繪製 Draw 的是 Active 樹,只有當 Pending 樹當前可見區域部分的分塊全部完成 Rasterize 後,才會進入 Active 步驟,在 Active 的過程中,Pending 樹相對於 Active 樹的變更部分才會被同步到 Active 樹;
  5. Active 後,合成器會向 UI 執行緒的視窗管理器發起重繪請求,視窗管理器會在下一個 VSync 的時候開始繪製新的一幀,後面的流程就跟合成器動畫是一樣的了;

上述流程的一些關鍵點是:

  1. 在合成器動畫中,分塊沒有完成光柵化,出現空白是被允許的,這樣瀏覽器可以更好地保證合成器動畫的幀率,但是在非合成器動畫中出現空白是不被允許的,因為新的 MainFrame 常常會帶來大面積的變更,如果允許空白的話可能會出現非常不好的視覺效果。這樣就導致合成器需要使用兩棵圖層樹來構建一個類似雙緩衝的機制,只有當 Pending 樹在後臺完成可見區域的光柵化時才被允許同步到 Active 樹;
  2. 在非合成器動畫過程中,Main Frame N,Main Frame N Active;Compositor Frame N,GL Frame N 這四個 Block 基本上可以認為是可以併發執行的(唯一會阻塞的環節是 Commit,不過 Commit 耗時一般不長),理論上我們要實現 60 幀的非合成器動畫,只需要保證其中每個 Block 的耗時總和小於 16.7 毫秒即可。當然實際的狀況下,在移動裝置上很難實現這麼多執行緒完全併發執行,加上過多執行緒帶來的互相通訊的開銷,使得每個 Block 的最大允許耗時實際上是小於 16.7 毫秒的;

5.2 動畫耗時分析和優化指南

  1. JavaScipt 的耗時是由頁端自己的邏輯決定的,一般超過 10 毫秒就基本上很難實現 60 幀的非合成器動畫了;
  2. MainFrame 的耗時主要取決於網頁 DOM 樹,圖層樹的複雜程度和變化程度,在變更很小,比如只有幾個元素的內容發生變化,圖層樹不變的情況下,一般耗時都是在 3 ~ 5 毫秒左右,如果變更很大,幾十甚至幾百都是有可能的;
  3. Commit 的耗時主要取決於圖層樹的複雜程度,一般耗時都很短,大概 2 ~ 3 毫秒上下;
  4. Rasterize 的耗時範圍變化極大,取決於網頁內容的複雜程度和新 MainFrame 在當前可見區域內網頁內容發生變化的總面積,另外圖片解碼也發生在這個階段,而圖片解碼也是光柵化耗時最多的一個環節,光柵化的耗時從幾毫秒到幾百毫秒都有可能(圖片在第一次被光柵化時被解碼,一直在可見區域內的圖片不會被反覆重解碼);
  5. Active 跟 Commit 的耗時類似,主要取決於圖層樹的複雜程度,一般耗時很短,大概 2 ~ 3 毫秒上下;

總的來說對非合成器動畫效能影響最大的通常是 JavaScript 和 Rasterize,要實現高效能的非合成器動畫,頁端需要很小心地控制 JavaScript 部分的耗時,並避免在每一幀中引入大面積的網頁內容變化和大幅度的圖層結構變化。另外非合成器動畫的後半段就是合成器動畫,所以對合成器動畫的效能優化要求也同樣適應於非合成器動畫。

另外對於 WebGL 來說,當在 JavaScript 裡面呼叫 WebGL API 時,這些命令只是被 Chrome 快取起來,並不會在 Renderer 執行緒呼叫真正的 GL API,所以 WebGL API 在 JavaScript 部分的耗時只是一個 JS Binding 呼叫的 Overhead,最終繪製 WebGL 內容的 GPU 耗時實際上是被包含在最後的 GPU 的步驟裡面。但是在移動平臺上一個 JS Binding 呼叫的 Overhead 是相當高的,大概在 0.01 毫秒這個範圍,所以每一幀超過 1000 個 WebGL API 呼叫的 WebGL 遊戲,效能阻塞的瓶頸有很大概率會出現在 JavaScript 也就是 CPU 上,而不是 GPU。

文章作者:小扎zack

原文連結:瀏覽器渲染流水線解析



相關文章