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

班尼迪克特米爾勒
Benedikt Meurer

為程式碼偵錯時,網頁開發人員應該幾乎完全不會影響效能。然而,這個期望並不是通用。C++ 開發人員絕對不會預期應用程式的偵錯版本達到實際工作環境的效能,而在 Chrome 成立年初時,只要開啟開發人員工具就大幅影響網頁效能。

我們不再覺得效能降低,是因為需要多年投資 DevToolsV8 的功能偵錯。不過,我們絕不能將開發人員工具的效能負擔降低為零。設定中斷點、逐步檢查程式碼、收集堆疊追蹤、擷取效能追蹤記錄等,都會影響執行速度。畢竟,觀察結果發生變化

不過,如同任何偵錯工具,開發人員工具的負擔仍應合理。最近我們發現,在部分情況下產生的報告數量大幅增加,因此在某些情況下,開發人員工具會拖慢應用程式,導致應用程式無法再使用。下方是 chromium:1069425 報表的並列比較,顯示只是單純開啟開發人員工具,造成效能負擔。

從影片中可以看到,緩慢情形的上傳順序為 5 到 10 倍,這是很不可接受的規定。首先,您需要瞭解開發人員工具開啟時,持續的運作位置,以及導致這類速度大幅下滑的原因。在 Chrome 轉譯器程序中使用 Linux 效能可發現整體轉譯器執行時間的分佈情形如下:

Chrome 轉譯器執行時間

雖然我們預期會在整體執行時間中以符號表示堆疊追蹤,不過這應該不會有大約 90% 的執行時間。這裡的符號是指從原始堆疊框架解析函式名稱和具體來源位置 (指令碼中的行和欄編號) 的行為。

方法名稱推論

更令人驚訝的是,儘管我們從先前的調查發現 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:1069425 取得,並大幅減少了 chromium:1069425 報告的緩慢情形。

Error.stack

我們今天能打電話到這裡了。不過,由於 DevTools 從未針對堆疊框架使用方法名稱,因此發生棘手問題。事實上,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 和開發人員工具中的 C++ API 物件使用方式有些微差異。具體來說,我們推出新的 v8::internal::StackFrameInfo 類別,該類別保存了透過 v8::StackFrameerror.stack 公開的堆疊框架所有相關資訊,因此一律會計算這兩個 API 提供的資訊超集。也就是說,在要求堆疊框架相關資訊時,我們也會立即計算方法名稱,特別是適用於 DevTools 的部分。v8::StackFrame因此開發人員工具一律會立即要求原始碼和指令碼資訊。

根據這樣的發展,我們能夠重構並大幅簡化堆疊框架表示法,而且出現延遲情況,因此在 V8 和 Chromium 之間進行這些操作時,現在只須針對所要求的資訊付費。此做法大幅提升了開發人員工具和其他 Chromium 用途的效能。堆疊框架相關資訊僅佔部分堆疊框架 (基本上只是指令碼名稱和來源位置,格式為行與欄偏移),而且開啟大門提升效能。

函式名稱

隨著上述重構程序的進行,符號化 (v8_inspector::V8Debugger::symbolize 耗費的時間) 負擔已降低至整體執行時間約 15%,而且我們能夠更清楚瞭解 V8 在開發人員工具中 (收集和) 透過符號呈現堆疊框架而花費時間的地方。

象徵成本

最先瞭解的就是計算行與欄數的累計費用。這部分最昂貴的部分,就是根據我們從 V8 取得的位元碼偏移計算,在指令碼中實際計算字元偏移的情形,由於我們上述重構了兩次,因此在計算行數時,重複計算了行數,另一次則是計算欄數。在 v8::internal::StackFrameInfo 執行個體上快取來源位置有助於快速解決這個問題,並從任何設定檔中完全刪除 v8::internal::StackFrameInfo::GetColumnNumber

我們看到的相關發現比較有趣,是因為我們調查的所有商家檔案中,「v8::StackFrame::GetFunctionName」的表現都極高。深入探討後發現,在開發人員工具的堆疊框架中,計算要為函式顯示的名稱會產生高昂的成本。

  1. 首先,尋找非標準 "displayName" 屬性,如果產生的資料屬性含有字串值,我們就會使用該屬性
  2. 否則請返回尋找標準 "name" 屬性,然後再次檢查是否產生了值為字串的資料屬性。
  3. 最終改回由 V8 剖析器所推測並儲存在函式常值上的內部偵錯名稱。

新增 "displayName" 屬性,做為 Function 執行個體上的 "name" 屬性的解決方法,該屬性在 JavaScript 中為唯讀狀態且不可設定,但一直未標準化,而且並無廣泛使用,因為在 99.9% 的情況下,瀏覽器開發人員工具新增了函式名稱推論,比起該 ES2015,您還可以設定 Function 執行個體上的 "name" 屬性,完全無需使用特殊的 "displayName" 屬性。由於 "displayName" 的負查詢作業所費不貲,且非常不必要 (ES2015 已於五年前推出),因此我們決定從 V8 (和開發人員工具) 停止支援非標準 fn.displayName 屬性

"displayName" 跳出的負查詢後,v8::StackFrame::GetFunctionName 的一半費用已移除。另一半則屬於一般 "name" 屬性查詢。幸好,我們已設定一些邏輯,避免在 (未處理) Function 例項上查詢 "name" 屬性的代價高昂。我們在 V8 之前導入了這項功能,希望能加快 Function.prototype.bind() 本身的速度。我們已完成必要檢查,一開始就能略過昂貴的一般查詢作業,因此 v8::StackFrame::GetFunctionName 不會顯示在我們考慮的任何個人資料中。

結語

經過上述改善後,我們大幅降低了堆疊追蹤的開發人員工具負擔。

我們知道仍有各種可能改善項目,例如使用 MutationObserver 時的負擔仍明顯改善 (如 chromium:1077657 所回報),但目前我們已解決這些主要問題點,未來或許會回來進一步簡化偵錯效能。

下載預覽頻道

建議您使用 Chrome Canary開發人員版Beta 版做為預設開發瀏覽器。這些預覽管道可讓您使用最新的開發人員工具、測試最先進的網路平台 API,以及在使用者操作之前在網站上找出問題!

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

使用下列選項,在文章中討論新功能和異動,或與開發人員工具相關的任何其他內容。

  • 透過 crbug.com 提供建議或意見。
  • 如要回報開發人員工具問題,請在開發人員工具中依序點選「更多選項」更多   >「說明」 >「回報開發人員工具的問題」
  • @ChromeDevTools 張貼推文。
  • 歡迎前往開發人員工具的 YouTube 影片或開發人員工具的 YouTube 影片提供新功能留言。