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

John McCutchan
John McCutchan

簡介

近年來,網頁應用程式的速度大幅提升。許多應用程式現在都能以足夠快的速度執行,我聽到一些開發人員大聲問道:「網路速度夠快嗎?」對於某些應用程式來說,這可能沒問題,但對於開發高效能應用程式的開發人員來說,這可能不夠快。儘管 JavaScript 虛擬機器技術已取得驚人進展,但近期研究顯示,Google 應用程式在 V8 中花費的時間介於 50% 到 70% 之間。應用程式有有限的時間,如果從一個系統中減少週期,其他系統就能執行更多工作。請注意,以 60fps 執行的應用程式每個影格只有 16 毫秒,否則會出現「jank」。請繼續閱讀,瞭解如何最佳化 JavaScript 和剖析 JavaScript 應用程式,並在Find Your Way to Oz 中,追查 V8 團隊效能偵探員追蹤的不明效能問題。

Google I/O 2013 大會專題講座

我在 2013 年 Google I/O 大會上介紹過這份內容。請觀看下方影片:

為什麼效能很重要?

CPU 週期是零和遊戲。讓系統的某部分使用較少資源,即可在其他部分使用更多資源,或整體運作更順暢。加快速度和執行更多工作通常是相互衝突的目標。使用者需要新功能,同時也希望應用程式運作更順暢。JavaScript 虛擬機器的速度不斷提升,但這並非忽略您現在就能解決的效能問題的理由,許多開發人員都知道,網頁應用程式效能問題需要處理。在即時高影格率應用程式中,不卡頓的壓力是最重要的。Insomniac Games 進行了一項研究,結果顯示穩定且持續的畫面更新率對遊戲的成功至關重要:「穩定的畫面更新率仍是專業、精心製作產品的象徵。」網頁程式開發人員請注意。

解決效能問題

解決效能問題就像偵破罪案一樣,您必須仔細檢查證據、檢查可能的原因,並嘗試不同的解決方案。您必須記錄所有測量結果,確保自己確實已修正問題。這個方法與刑事偵探破案的方式相去不遠。偵探會檢查證據、審問嫌疑人,並執行實驗,希望能找到關鍵證據。

V8 CSI:Oz

開發 Find Your Way to Oz 的神奇巫師們向 V8 團隊提出他們無法自行解決的效能問題。Oz 有時會凍結,導致卡頓。Oz 開發人員已使用 Chrome 開發人員工具中的時間軸面板進行初步調查。查看記憶體用量時,他們遇到了令人擔心的鋸齒狀圖表。垃圾收集器每秒收集 10 MB 的垃圾,且垃圾收集作業的暫停時間與卡頓情形相符。類似下列 Chrome 開發人員工具時間軸的螢幕截圖:

開發人員工具時間軸

8 號偵探雅各和楊接手調查此案。當時,V8 團隊和 Oz 團隊的 Jakob 和 Yang 之間,有過一場長時間的來回討論。我已將這場對話濃縮為重要事件,以便追蹤這個問題。

證據

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

我們正在查看哪種類型的應用程式?

Oz 試用版是互動式 3D 應用程式。因此,垃圾收集作業所造成的暫停情形,會對此類應用程式造成很大的影響。請注意,以 60fps 執行的互動式應用程式有 16 毫秒的時間執行所有 JavaScript 工作,且必須保留部分時間讓 Chrome 處理圖形呼叫並繪製畫面

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

我們發現哪些效能問題?

我們發現影片出現暫停 (即影格遺失) 或卡頓情形。這些暫停時間與垃圾收集執行作業相關。

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

是的,Oz 開發人員精通 JavaScript VM 效能和最佳化技巧。值得一提的是,Oz 開發人員使用 CoffeeScript 做為原始語言,並透過 CoffeeScript 編譯器產生 JavaScript 程式碼。由於 Oz 開發人員編寫的程式碼與 V8 使用的程式碼之間存在斷層,因此這項調查變得更加棘手。Chrome 開發人員工具現在支援來源對應,可讓您更輕鬆地進行這項操作。

垃圾收集器為何執行?

VM 會自動為開發人員管理 JavaScript 中的記憶體。V8 使用常見的垃圾收集系統,將記憶體分為兩個 (或更多) 世代。年輕代會保留最近才配置的物件。如果物件存活時間夠長,就會移至舊代。

新世代的收集頻率遠高於舊世代。這是設計上的考量,因為新世代的收集方式成本較低。通常可以安全地假設,經常出現的 GC 暫停是由於新生代收集作業所致。

在 V8 中,新生記憶體空間會分割為兩個大小相等的連續記憶體區塊。在任何特定時間,這兩個記憶體區塊中只有一個會使用,稱為「to」空間。只要目標空間還有剩餘記憶體,分配新物件就不會造成太大負擔。將游標向前移動新物件所需的位元組數。直至 to 空間用盡為止。此時程式會停止,並開始收集資料。

V8 新生代記憶體

此時,from 和 to 會互換。系統會從頭到尾掃描「到」空間 (現在是「從」空間),並將任何仍在運作的物件複製到「到」空間,或升級至舊世代堆疊。如需詳細資訊,建議您參閱 Cheney 演算法

您應該能直覺地瞭解,每當以隱含或明確的方式 (透過呼叫 new、[] 或 {}) 配置物件時,應用程式就會越來越接近垃圾收集,並進入令人擔心的應用程式暫停狀態。

這個應用程式是否預期產生 10MB/秒的垃圾?

簡單來說,並非如此。開發人員並未做任何事,讓系統產生 10MB/秒的垃圾資料。

嫌疑人

調查的下一階段是找出潛在嫌疑人,然後逐一排除。

Suspect #1

在影格期間呼叫 new。請注意,每個分配的物件都會讓您更接近 GC 暫停狀態。特別是以高畫面更新率運行的應用程式,應盡量避免每個影格都有分配。這通常需要仔細思考的應用程式專屬物件回收系統。V8 偵探已向 Oz 團隊確認,他們並未呼叫新團隊。事實上,澳洲團隊已充分瞭解這項規定,並表示「這會很尷尬」。將這個項目從清單中劃掉。

Suspect #2

在建構函式以外修改物件的「形狀」。只要在建構函式以外的物件中新增新屬性,就會發生這種情況。這會為物件建立新的隱藏類別。當最佳化程式碼看到這個新的隱藏類別時,系統就會觸發 deopt,並執行未經最佳化的程式碼,直到程式碼被歸類為熱門並再次最佳化為止。這種不最佳化、重新最佳化的流失會導致卡頓,但並非與產生過多垃圾有直接關聯。經過仔細的程式碼稽核後,我們確認物件形狀為靜態,因此排除了嫌疑 #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 * b),第 5 個是用於 #4 * 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

(請視您的平台而將 linux 替換為 mac 或 windows)。(此工具必須從 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。經最佳化的函式名稱旁會顯示星號 (*)。請注意,函式 opt 已最佳化,而 unopt 則未最佳化。

V8 偵探工具包中的另一個重要工具是 plot-timer-event。執行方式如下:

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

執行後,目前目錄中會出現名為 timer-events.png 的 PNG 檔案。開啟後,畫面應如下所示:

計時器事件

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

計時器事件 Y 軸

在 V8.Execute 列中,每當 V8 執行 JavaScript 程式碼時,就會在該列畫面上畫上黑色垂直線。在 V8 執行新一代收集作業的每個設定檔刻度上,V8.GCScavenger 都會繪製藍色垂直線。其他 V8 狀態也是如此。

其中最重要的一列是「正在執行的程式碼類型」。執行最佳化程式碼時,指標會顯示綠色;執行未最佳化程式碼時,則會顯示紅色和藍色。下圖顯示從最佳化程式碼轉換為未最佳化程式碼,然後再轉換回最佳化程式碼:

正在執行的程式碼類型

理想情況下,這個線會變成綠色,但不會立即變色。這表示您的程式已轉換至最佳化穩定狀態。未經最佳化的程式碼執行速度一律會比經最佳化的程式碼慢。

如果您已完成上述步驟,請注意,您可以透過重構應用程式,讓應用程式在 v8 偵錯殼層 (d8) 中執行,以便加快工作速度。使用 d8 可透過 tick-processor 和 plot-timer-event 工具加快迭代時間。使用 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 瞭若指掌,因此立即發現 V8 有時不會對 for-i-in 迴圈結構進行最佳化。換句話說,如果函式包含 for-i-in 迴圈結構,可能就不會進行最佳化。這是目前的特殊情況,日後可能會有所變動,也就是說,V8 有朝一日可能會對這個迴圈結構進行最佳化。我們不是 V8 偵探,也不熟悉 V8,因此無法判斷為何未對 updateSprites 進行最佳化。

實驗 #2

使用此標記執行 Chrome:

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

顯示最佳化和非最佳化資料的詳細記錄。搜尋更新圖格的資料,我們發現:

[已停用 updateSprites 的最佳化功能,原因:ForInStatement 不是快速情況]

正如偵探所推測的,原因是 for-i-in 迴圈結構。

結案

發現 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 偵探提供的工具和技巧,他們便能找出其他幾個卡在非最佳化地獄的函式,並將運算程式分解為已最佳化的葉函式,進而提升效能。

快去解決一些效能犯罪問題吧!