Chrome 開發人員工具的堆疊追蹤速度提升 10 倍

Benedikt Meurer
Benedikt Meurer

網頁開發人員在對程式碼偵錯時,幾乎不會感覺到效能受到影響。不過,這並非普遍的期待。C++ 開發人員絕對不會預期應用程式的偵錯版本將能達到正式版效能。在 Chrome 早期,開啟開發人員工具就會大幅影響網頁的效能。

我們投入多年時間對DevToolsV8 的偵錯功能投入多年時間,研究這項效能降低的原因。不過,我們永遠無法將 DevTools 的效能開銷降至零。設定中斷點、逐步執行程式碼、收集堆疊追蹤記錄、擷取效能追蹤記錄等,都會對執行速度造成不同程度的影響。畢竟觀察會影響觀察對象

當然,開發人員工具的額外負擔 (如同任何偵錯工具) 應保持在合理範圍內。我們最近發現,在某些情況下,DevTools 會使應用程式速度變慢,甚至導致無法使用,因此相關回報數量大幅增加。下方為報表 chromium:1069425 的並排比較圖,說明只開啟開發人員工具時的效能開銷。

從影片中可以看到,速度減慢的幅度約為 5-10 倍,這顯然無法接受。首先,我們必須瞭解時間都花在哪裡,以及開啟 DevTools 時為何會造成大幅的速度減緩。在 Chrome 轉譯器程序使用 Linux perf,系統就會顯示整體轉譯器執行時間的分佈情形:

Chrome 轉譯器執行時間

雖然我們預期會看到與收集堆疊追蹤記錄相關的內容,但並未預期整體執行時間的 90% 會用於符號化堆疊框架。這裡的符號化是指從原始堆疊框架解決函式名稱及具體來源位置 (指令碼中的行數和欄號) 的做法。

方法名稱推論

更令人驚訝的是,雖然我們幾乎都會使用 V8 中的 JSStackFrame::GetMethodName() 函式,但根據先前的調查結果JSStackFrame::GetMethodName() 對效能問題並不陌生。這個函式會嘗試針對視為方法叫用的影格 (代表 obj.func() 格式函式叫用的影格,而非 func()) 計算方法名稱。快速查看程式碼後,我們發現這個函式會執行物件和其原型鏈結的完整檢查,並尋找

  1. 資料屬性,其 valuefunc 關閉,或
  2. 存取子屬性,其中 getset 等於 func 關閉。

雖然這項功能本身不算太便宜,但也不會造成這種嚴重的速度變慢。因此,我們開始深入研究 chromium:1069425 中回報的範例,並發現系統為非同步工作和來自 classes.js (10 MiB JavaScript 檔案) 的記錄訊息收集堆疊追蹤記錄。仔細一探究竟,這基本上是 Java 執行階段,加上編譯為 JavaScript 的應用程式程式碼。堆疊追蹤包含多個影格,且物件 A 上叫用了方法,因此我們認為應該瞭解要處理的物件種類。

物件的堆疊追蹤

顯然,Java 到 JavaScript 編譯器產生的單一物件含有 82,203 個函式,這顯然開始變得有趣起來。接下來,我們回到 V8 的 JSStackFrame::GetMethodName(),瞭解其中是否有可留出的低落水果。

  1. 這個方法會先查詢函式的 "name" 做為物件上的屬性,如果找到,就會檢查屬性值是否與函式相符。
  2. 如果函式沒有名稱,或是物件沒有相符的屬性,則會改為透過遍歷物件和其原型的所有屬性,進行反向查詢。

在本範例中,所有函式都是匿名的,且具有空白的 "name" 屬性。

A.SDV = function() {
   // ...
};

第一項發現是,反向查詢分為兩個步驟 (針對物件本身和原型鏈結中的每個物件執行):

  1. 擷取所有可枚舉屬性的名稱,並
  2. 針對每個名稱執行一般屬性查詢,測試產生的屬性值是否符合我們要尋找的關閉式函式。

這似乎是相當容易的做法,因為要擷取名稱就必須遍歷所有屬性。我們可以改為在單一階段中執行所有作業,直接檢查屬性值,而非執行兩個階段 (名稱擷取作業的 O(N) 和測試的 O(N log(N)))。這讓整個函式加快 2 到 10 倍

第二項發現更有趣。雖然函式在技術上屬於匿名函式,但 V8 引擎卻記錄了我們所謂的「推測名稱」。對於以 obj.foo = function() {...} 格式顯示在指派項目右側的函式常值,V8 剖析器會將 "obj.foo" 儲存為函式常值的推測名稱。所以在本範例中,雖然沒有可查詢的專有名稱,但已得到夠近查詢的適當名稱:就上述 A.SDV = function() {...} 範例而言,我們有 "A.SDV" 做為推論名稱,可以找出最後一個點,然後從推論的名稱得出屬性名稱,然後搜尋物件的 "SDV" 屬性。這在幾乎所有情況下都有效,因為我們可以用單一屬性查詢取代耗時的完整檢查作業。我們已根據這個 CL 推出這兩項改善措施,並大幅降低 chromium:1069425 回報的範例速度。

Error.stack

我們可以將這天稱為「一天」。但這裡有點奇怪,因為開發人員工具從未使用堆疊框架的方法名稱。事實上,C++ API 中的 v8::StackFrame 類別甚至無法公開取得方法名稱的方法。因此,我們一開始呼叫 JSStackFrame::GetMethodName() 似乎是錯誤的做法。我們只會在 JavaScript 堆疊追蹤 API 中使用 (及公開) 方法名稱。如要瞭解這項用法,請參考下列簡單的例子 error-methodname.js

function foo() {
    console.log((new Error).stack);
}

var object = {bar: foo};
object.bar();

這裡的函式 foo 是透過 object"bar" 名稱安裝。在 Chromium 中執行這個程式碼片段會產生以下輸出內容:

Error
    at Object.foo [as bar] (error-methodname.js:2)
    at error-methodname.js:6

這裡我們看到方法名稱查閱:最上方的堆疊框架顯示,透過名為 bar 的方法,在 Object 的例項上呼叫函式 foo。因此,非標準的 error.stack 屬性會大量使用 JSStackFrame::GetMethodName(),而我們的效能測試也顯示,這些變更可大幅提升速度。

StackTrace Micro 基準測試加快

不過,回到 Chrome 開發人員工具的主題,即使未使用 error.stack,方法名稱仍會計算,這似乎不太正確。我們可以利用一些歷史資料來進行分析:傳統上,V8 有兩種不同的機制,可收集並呈現上述兩種不同 API 的堆疊追蹤記錄 (C++ v8::StackFrame API 和 JavaScript 堆疊追蹤 API)。使用兩種 (大致) 相同的做法容易出錯,且經常導致不一致和錯誤,因此我們在 2018 年底啟動了一個專案,以便解決堆疊追蹤擷取的單一瓶頸。

該專案非常成功,也大幅減少了堆疊追蹤收集相關問題的數量。透過非標準 error.stack 屬性提供的大部分資訊也都是延遲計算,只在真正需要時才計算,但在重構中,我們將相同的技巧套用至 v8::StackFrame 物件。系統會在初次叫用堆疊框架的所有方法時,計算堆疊框架的所有資訊。

這通常可提升效能,但不幸的是,這與在 Chromium 和 DevTools 中使用這些 C++ API 物件的做法有些相悖。特別是,由於我們導入了新的 v8::internal::StackFrameInfo 類別,用來保存透過 v8::StackFrameerror.stack 公開的堆疊框架所有資訊,我們一律會計算兩個 API 提供的超集資訊,這意味著對於 v8::StackFrame (尤其是開發人員工具) 的使用,我們也會在要求堆疊框架的任何資訊後,立即計算方法名稱。事實上,DevTools 一律會立即要求來源和指令碼資訊。

在意識到這個問題後,我們重構並大幅簡化堆疊框架表示法,並讓堆疊框架更為懶惰,以便在 V8 和 Chromium 中使用時,只需支付運算所需資訊的成本。這項功能可大幅提升 DevTools 和其他 Chromium 用途的效能,因為這類用途只需要堆疊框架的部分資訊 (基本上就是以行和欄偏移形式顯示的腳本名稱和來源位置),進而讓效能獲得更多提升。

函式名稱

完成上述重構作業後,符號化作業的額外負擔 (v8_inspector::V8Debugger::symbolize 所花費的時間) 已降至整體執行時間的 15%,我們也能更清楚瞭解 V8 在收集和符號化堆疊框架時,花費時間在 DevTools 中進行使用。

符號化成本

首先,我們發現計算列和欄號的累積成本。這裡的高成本部分實際上是計算指令碼中的字元位移 (根據從 V8 取得的位元碼偏移),結果是因為我們執行以上重構而重複計算了兩次,也就是在計算行數時,另一次計算行數,另一次計算欄數。在 v8::internal::StackFrameInfo 執行個體上快取來源位置,有助於快速解決這個問題,並且將 v8::internal::StackFrameInfo::GetColumnNumber 從任何設定檔中完全刪除。

更有趣的是,在我們查看的所有設定檔中,v8::StackFrame::GetFunctionName 的數值都出乎意料地高。進一步深入瞭解後,我們發現在 DevTools 中,計算堆疊框架中函式顯示名稱的成本過高,

  1. 首先尋找非標準 "displayName" 屬性,如果傳回的資料屬性為字串值,我們將使用該屬性
  2. 否則會改為尋找標準 "name" 屬性,並再次檢查是否會產生值為字串的資料屬性。
  3. 並最終改用由 V8 剖析器推斷並儲存在函式字面值中的內部偵錯名稱。

已新增 "displayName" 屬性做為 Function 執行個體上 "name" 屬性的解決方法,但在 JavaScript 中具有唯讀和無法設定,但是並未標準化,而且沒有廣泛使用,因為瀏覽器開發人員工具會在 99.9% 的案例中加入函式名稱推論。除了上述內容之外,ES2015 還讓 Function 例項上的 "name" 屬性可設定,完全不需要特殊的 "displayName" 屬性。由於 "displayName" 的負向查詢成本相當高且並非必要 (ES2015 已於五年前發布),我們決定在 V8 (和 DevTools) 中移除對非標準 fn.displayName 屬性的支援

排除了 "displayName" 的負數查詢,因此刪除了 v8::StackFrame::GetFunctionName 費用的一半。另半部則屬於一般的 "name" 屬性查詢。幸運的是,我們已製定一些邏輯來避免對 (未經修改) Function 例項進行昂貴的 "name" 屬性查詢;這是 V8 前一陣子推出,目的是讓 Function.prototype.bind() 本身加快。我們支援必要的檢查程序,讓我們一開始就能略過成本高昂的一般查詢,結果是 v8::StackFrame::GetFunctionName 不會再出現在我們考慮的任何設定檔中。

結論

透過上述改善措施,我們大幅降低了 DevTools 在堆疊追蹤方面的負擔。

我們知道仍有許多可能的改善措施,例如使用 MutationObserver 時的負擔仍相當明顯,如 chromium:1077657 中所述。目前我們已解決幾個主要的問題點,未來我們可能會再來進一步簡化偵錯效能。

下載預覽管道

建議您使用 Chrome CanaryDevBeta 版做為預設的開發瀏覽器。這些預覽管道可讓您存取最新的 DevTools 功能,測試最新的網路平台 API,並在使用者發現問題前,協助您找出網站的問題!

與 Chrome 開發人員工具團隊聯絡

請使用下列選項討論新功能、更新或任何與開發人員工具相關的內容。