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 微基準測試

不過,回到 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) 時,只要要求堆疊框架的任何資訊,我們也會一併計算方法名稱。事實上,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 例項在 JavaScript 中為唯讀且無法設定的 "name" 屬性問題而新增的解決方法,但從未標準化,也未廣泛使用,因為瀏覽器開發人員工具已新增函式名稱推論功能,可在 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 Canary開發人員版Beta 版設為預設開發人員版瀏覽器。這些預覽管道可讓您存取最新的 DevTools 功能,測試最新的網路平台 API,並在使用者發現問題前,協助您找出網站的問題!

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

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