使用鑑識和偵探作業解決 JavaScript 效能謎題

John McCutchan
John McCutchan

簡介

近幾年來,網頁應用程式大幅成長。現在許多應用程式的執行速度都夠快,許多開發人員都想知道:「網路速度是否夠快?」。據我們所知,在某些應用程式上可能只有如此,但對於負責高效能應用程式的開發人員來說,我們知道速度不夠快。即使 JavaScript 虛擬機器技術有令人驚豔的進展,近期研究也顯示,Google 應用程式耗費在 V8 內省下 50% 至 70% 的時間。應用程式有無限的時間,只要一個系統就能形成循環,其他系統就能處理更多事務。請記住,以 60 fps 執行的應用程式每個影格只能處理 16 毫秒,其他情況則有「資源浪費」。請閱讀下文,深入瞭解 V8 團隊的效能偵測專家在「尋找奧茲之旅」的重大效能問題後,接下來會閱讀本文,瞭解如何最佳化 JavaScript 和剖析 JavaScript 應用程式。

2013 年 Google I/O 大會講座

我在 2013 年 Google I/O 大會上發表了這些內容。歡迎觀看下方影片:

成效為何如此重要?

CPU 週期是一項零總和。減少系統使用某個部分,可以讓您在另一個部分使用較多,或讓整體運作更為順暢。加快執行速度和執行更多工作,通常是彼此競爭的目標。使用者需要新功能,同時也希望應用程式的執行更加順暢。JavaScript 虛擬機器的處理速度日新月異,但這並非忽略效能問題導致您立即修正的原因。許多開發人員都已經知道,這也不少。即時且高畫面更新率,對於沒有卡頓的壓力是最重要的。Insomniac Games 製作了一項研究,其中顯示穩定的持續影格速率對於遊戲的成功至關重要:「穩固的影格速率仍是專業打造產品的跡象。」網頁程式開發人員請注意。

解決效能問題

解決效能問題就像處理犯罪一樣。你必須仔細確認證據、找出疑似原因,然後嘗試不同的解決方案。過程中你必須記錄測量結果,才能確保問題已順利解決。此方法與罪犯偵測的破解方式幾乎沒有差異。警探會檢查證據、偵查嫌疑犯,並執行試圖找出煙火的槍枝。

V8 CSI:奧茲

開發《Find Your Way to Oz》這款遊戲的巫師超讚,與 V8 團隊攜手解決無法自行解決的效能問題。有時 Oz 會停止運作並造成卡頓。Oz 開發人員已透過 Chrome 開發人員工具時間軸面板進行初步調查,看看他們遇到的記憶體用量問題:垃圾收集器每秒收集一次 10 MB 的垃圾,且與卡頓的垃圾收集暫停情形有關。下方是 Chrome 開發人員工具時間軸的螢幕截圖:

開發人員工具時間軸

V8 偵察者 Jakob 和 Yang 接下了這個案件。V8 團隊和 Oz 團隊從 Jakob 和 Yang 經歷了一段漫長的時刻。目前我討論了有助於追蹤這個問題的重要事件。

證明

第一步是收集和研究初步證據。

我們正在審查哪些類型的申請?

Oz 示範是互動式 3D 應用程式。因此,垃圾收集造成的暫停十分敏感。提醒您,在 60fps 執行的互動式應用程式總共有 16 毫秒來執行 JavaScript,且必須讓 Chrome 處理圖形呼叫及繪製畫面

Oz 會對雙值執行大量算術運算,並經常呼叫 WebAudio 和 WebGL。

我們發現的效能問題屬於哪個類型?

我們可以看到有停滯情形,也就是影格遺失,又稱卡頓。這些暫停作業與垃圾收集執行作業相關。

開發人員是否遵循最佳做法?

是,Oz 開發人員精通 JavaScript VM 的效能和最佳化技巧。值得注意的是,Oz 開發人員使用 CoffeeScript 做為來源語言,並透過 CoffeeScript 編譯器產生 JavaScript 程式碼。由於 Oz 開發人員編寫的程式碼與 V8 使用的程式碼之間有斷層,因此這部分調查會較為困難。Chrome 開發人員工具現已支援來源對應,使用起來更加輕鬆。

為什麼要執行垃圾收集器?

JavaScript 的記憶體是由 VM 自動為開發人員管理。V8 使用常見的垃圾收集系統,其中記憶體會分為兩個 (或更多個)「世代」generations。年輕代會保留最近配置的物件。物件在存續時間足夠長的情況下,會移至舊的世代。

年輕一代的收集頻率遠高於舊代的收集頻率。這是考量的設計,因為年輕一代的收藏非常便宜。通常可以安心假設,頻繁的 GC 暫停是是由年輕世代收集所導致。

在 V8 中,年輕的記憶體空間分為兩個大小相等的連續記憶體區塊。在任何指定時間內,都只使用這兩個記憶體區塊的其中一個,稱為空間。雖然空間中仍有剩餘記憶體,但分配新物件是成本低廉。系統會將遊標移到空格中的遊標,往前移動新物件所需的位元組數。一直到太空空間耗盡為止。此時程式會停止,並開始收集資料。

V8 年輕回憶

此時,系統會交換從空格到太空的模式。到底是,到目前太空層的一切內容,Google 會從頭到尾掃描所有仍存在的物件,並將所有仍在運作的物件複製到空間,或提升至舊的堆積。詳情請參閱 Cheney 的演算法一文。

您應該瞭解,每次以隱含方式或明確分配物件 (透過呼叫 new、[] 或 {}) 的方式分配到更接近垃圾收集,且被讀取的應用程式暫停。

這項申請需要 10 MB/秒的垃圾流量嗎?

簡單來說,不行。開發人員未執行任何預期 10 MB/秒的垃圾。

懷疑

調查的下一階段是找出潛在嫌疑人,然後將它們縮小。

<ph type="x-smartling-void-element"><br></ph>

在影格期間呼叫新的 。請記住,分配到的每個物件都會使您達到 GC 暫停時間。尤其是以高畫面更新率執行的應用程式,應盡力避免每個影格分配零次。一般而言,這需要審慎考量,具體的應用程式專屬物件回收系統。V8 警探與 Oz 團隊確認,他們並未致電給新進人員。事實上,Oz 團隊已經清楚瞭解這項規定,並說:「這太尷尬了」。現在就把它從清單上劃掉吧。

可疑項目 2

在建構函式外修改物件的「形狀」。在建構函式外的物件新增屬性時,會發生這種情況。這樣會為物件建立新的隱藏類別。當最佳化的程式碼發現這個新的隱藏類別時,將會觸發停用狀態,未最佳化的程式碼將會執行,直到程式碼歸類為熱且最佳化為止。這項最佳化及重新最佳化的動作會導致卡頓,但不包含過多的垃圾建立作業。在仔細稽核程式碼後,確認物件形狀為靜態,因此懷疑 2 號就是排除了。

可疑項目 3

未最佳化程式碼中的算術。在未最佳化的程式碼中,所有運算結果都會導致實際分配的物件。例如,以下程式碼片段:

var a = p * d;
var b = c + 3;
var c = 3.3 * dt;
point.x = a * b * c;

產生 5 個 HeapNumber 物件。前三個指令分別是變數、a、b 和 c。第 4 項代表匿名值 (a &ast; b),第 5 項則來自 #4 &ast;c;第 5 項最終會指派給 Point.x。

Oz 為每個影格進行了數千項操作。如果這些運算發生在從未最佳化的函式中,就可能是垃圾收集的原因。因為未最佳化的運算會分配記憶體,即使是暫時性結果也一樣。

可疑項目 4

將雙精度數字儲存至屬性。您必須建立 HeapNumber 物件,才能儲存數字和屬性,使其指向這個新物件。變更指向 HeapNumber 的屬性不會產生垃圾。不過,物件屬性儲存時,也可能有許多雙精度浮點數。程式碼是完整的陳述式,如下所示:

sprite.position.x += 0.5 * (dt);

在最佳化程式碼中,每當 x 獲派剛計算的值 (看似無害的陳述式),就會隱含地分配新的 HeapNumber 物件,使我們更接近垃圾收集暫停的時間。

請注意,使用型別陣列 (或僅包含雙倍數的一般陣列) 時,您可以完全避免發生這種問題,因為雙精度數字的儲存空間只分配一次,且重複變更該值不需要分配新的儲存空間。

嫌犯 4 可能是可能。

鑑識服務

此時,偵測器有兩個可能的嫌疑:將堆積編號儲存為物件屬性,並在未最佳化的函式中執行算術計算。現在該前往實驗室判斷有罪惡的嫌疑人。注意:本節將重現實際 Oz 原始碼中發現的問題。相較於原始程式碼,這個重現問題的數量比原始程式碼少很多,因此更容易理解。

實驗 #1

正在檢查可疑項目 3 (未最佳化函式內的算術運算)。V8 JavaScript 引擎內建記錄系統,可針對實際情況提供深入分析。

從 Chrome 完全無法運作時,使用以下旗標啟動 Chrome:

--no-sandbox --js-flags="--prof --noprof-lazy --log-timer-events"

然後完全退出 Chrome,就會在目前的目錄中開啟 v8.log 檔案。

如要解讀 v8.log 的內容,請務必下載 Chrome 目前使用的 v8 版本 (查看 about:version),然後進行建構

成功建構 v8 後,您可以使用滴答處理器處理記錄:

$ tools/linux-tick-processor /path/to/v8.log

(視您的平台而定,請以 macros 或視窗取代 linux。) (此工具必須在 v8 的頂層來源目錄執行)。

刻點處理器會顯示具有最多刻點的 JavaScript 函式文字表格:

[JavaScript]:
ticks  total  nonlib   name
167   61.2%   61.2%  LazyCompile: *opt demo.js:12
 40   14.7%   14.7%  LazyCompile: unopt demo.js:20
 15    5.5%    5.5%  Stub: KeyedLoadElementStub
 13    4.8%    4.8%  Stub: BinaryOpStub_MUL_Alloc_Number+Smi
  6    2.2%    2.2%  Stub: BinaryOpStub_ADD_OverwriteRight_Number+Number
  4    1.5%    1.5%  Stub: KeyedStoreElementStub
  4    1.5%    1.5%  KeyedLoadIC:  {12}
  2    0.7%    0.7%  KeyedStoreIC:  {13}
  1    0.4%    0.4%  LazyCompile: ~main demo.js:30

可以看到 demo.js 有三個函式:Opt、unopt 和 main。最佳化函式的名稱旁邊會顯示星號 (*)。您會發現函式最佳化功能為最佳化狀態,但未啟用最佳化功能。

V8 偵探工具包的另一項重要工具是繪圖計時器事件。執行方式如下:

$ tools/plot-timer-event /path/to/v8.log

執行後,系統會在目前的目錄中,找到名為 timer-events.png 的 png 檔案。開啟工具後,畫面上應如下所示:

計時器事件

除了底部的圖表外,資料也會顯示在資料列中,X 軸為時間 (毫秒)。左側列出每個資料列的標籤:

計時器事件 Y 軸

在 V8 執行 JavaScript 程式碼的設定檔刻點,V8.Execute 列會在每個設定檔刻點繪製了黑色垂直線。V8.GCScavenger 在 V8 執行新一代收集作業的每個設定檔刻點上,繪有藍色垂直線條。瞭解 V8 狀態的其餘狀態。

其中一個最重要的資料列是「程式碼種類正在執行」。每次執行最佳化的程式碼時,這個按鈕都是綠色;而在執行未最佳化的程式碼時,則加上紅色和藍色。以下螢幕截圖顯示了從最佳化到尚未最佳化,然後回到最佳化程式碼的階段:

執行的程式碼類型

在理想的情況下,這條線條會呈現實心,但永遠不會立即出現。代表您的程式已轉換至最佳化的穩定狀態。未最佳化的程式碼一律會執行最佳化作業的速度。

若您達到這個長度,建議您重構應用程式,讓應用程式能在 v8 偵錯殼層中執行 (d8) 更快完成工作。使用 d8 時,您可以使用滴答處理器和繪圖計時器事件工具加快疊代時間。使用 d8 的另一個副作用是,更容易找出實際問題,減少資料中的雜訊量。

從 Oz 原始碼查看計時器事件圖表,顯示從最佳化到未最佳化的程式碼,同時執行未最佳化的程式碼時,觸發許多新一代集合,如以下螢幕截圖所示 (請注意,中間會移除時間):

計時器事件圖表

仔細觀察後,您會發現黑線代表 V8 執行 JavaScript 程式碼的時間,與新一代集合 (藍線) 的設定檔滴答時間完全相同。這可以清楚看出,系統在收集垃圾資料時,指令碼會暫停。

透過 Oz 原始碼查看刻點處理器輸出內容,頂層函式 (updateSprites) 沒有最佳化。換句話說,程式花最多時間的函式也未進行最佳化。這極力表示疑似 3 是罪犯。updateSprites 的來源包含如下的迴圈:

function updateSprites(dt) {
    for (var sprite in sprites) {
        sprite.position.x += 0.5 * dt;
        // 20 more lines of arithmetic computation.
    }
}

熟悉 V8 的運作方式,他們立即意識到 for i-in 迴圈結構有時候並未針對 V8 進行最佳化。換句話說,如果函式包含 For-in 迴圈結構,就無法最佳化。這是一個特殊的案例,未來可能會改變,也就是說,V8 可能需要一天的時間針對這個迴圈結構進行最佳化。我們不是 V8 偵探,也不太瞭解 V8 的背面 該如何判斷 updateSprites 沒有進行最佳化的原因?

實驗 #2

執行 Chrome 時具有這個旗標:

--js-flags="--trace-deopt --trace-opt-verbose"

顯示最佳化和去最佳化資料的詳細記錄。在資料中搜尋我們找到的 updateSprites:

[已停用 updateSprites 的最佳化功能,原因:ForInStatement 並非快速案例]

正如探員所假設的,1 號迴圈就是主因。

結案

發現 updateSprites 未最佳化的原因後,修正方法很簡單,只要將運算作業移至其專屬的函式即可,如下所示:

function updateSprite(sprite, dt) {
    sprite.position.x += 0.5 * dt;
    // 20 more lines of arithmetic computation.
}

function updateSprites(dt) {
    for (var sprite in sprites) {
        updateSprite(sprite, dt);
    }
}

updateSprite 會經過最佳化,導致大幅減少 HeapNumber 物件,導致 GC 暫停頻率降低。您只要進行相同的實驗並使用新的程式碼,即可確認這一點。小心的讀取器會發現,連串的號碼仍會儲存為屬性。如果剖析指出值得這麼做,請將位置變更為雙精度浮點數或型別資料陣列,會進一步減少建立的物件數量。

結語

Oz 開發人員並未阻止他們。掌握 V8 偵探與其共用的工具和技術後,他們發現其他幾個函式卡在去最佳化系統中,並將運算程式碼分解為最佳化的分葉函式,進而獲得更優異的效能。

趕快出門,開始解決表演犯罪吧!