極速優(yōu)化:十倍提升JS代碼運(yùn)行效率的技巧
作者 | ecznlai@騰訊文檔
前段時間通過優(yōu)化業(yè)務(wù)里的相關(guān)實(shí)現(xiàn),將高頻調(diào)用場景性能優(yōu)化到原來的十倍,使文檔核心指標(biāo)耗時達(dá)到 10~15% 的下降。本文將從 V8 整體架構(gòu)出發(fā),深入淺出 V8 對象模型,從匯編細(xì)節(jié)點(diǎn)出其 ICs 優(yōu)化細(xì)節(jié)以及原理,最后根據(jù)這些優(yōu)化原理來編寫超快的 JS 代碼 。
一、V8 compiler pipeline
js 代碼從源碼到執(zhí)行 —— v8 編譯器管線:
parser 將源碼編譯為 AST,并在 AST 基礎(chǔ)上編譯為「字節(jié)碼 bytecode」
ignition 是 v8 的字節(jié)碼解釋器,可以運(yùn)行字節(jié)碼,并在運(yùn)行過程中持續(xù)收集「feedback」即綠線,給到 turbofan 做最終的機(jī)器碼編譯優(yōu)化。
而由于 js 是相當(dāng)動態(tài)的語言,編譯出來的「機(jī)器指令」未必能正確,因此其運(yùn)行過程中有可能要回滾到 ignition 解釋器來運(yùn)行,這些問題通過「紅線」反饋給 ignition 解釋器,這個過程叫做「反優(yōu)化」。
—— 更具體來說:
(1) Parser (Source => Token => AST)
將源碼一段線性 buffer string 解析為 Token 流,最后依據(jù) Token 流生成 AST 樹狀構(gòu)造,這是所有語言都會有的過程。
(2) 綠線與 feedback
運(yùn)行過程中產(chǎn)生并持續(xù)收集的反饋信息,比如多次調(diào)用 add(1, 2) 就會產(chǎn)生「add 函數(shù)的兩個參數(shù) “大概率” 是整數(shù)」的反饋,v8 會收集這類信息,并在后續(xù) TurboFan codegen 的時候根據(jù)這些反饋來做假設(shè),并依據(jù)這些假設(shè)做深度優(yōu)化,后文將從匯編的角度討論這個細(xì)節(jié)。
(3) 紅線與反優(yōu)化 deoptimize
前面提到 「add 函數(shù)的兩個參數(shù) “大概率” 是整數(shù)」 的假設(shè),當(dāng)假設(shè)被打破的時候會觸發(fā)所謂的「deoptimize」反優(yōu)化,比如你在運(yùn)行了很久的 add(number, number) 上突然來一個 add("123", "abc") 那么此時就會降級重新回到 ignition bytecode 執(zhí)行。
(4) Iginition 和 TurboFan
前者生成 byte code,后者根據(jù)執(zhí)行過程中收集的 feedback 來生成深度優(yōu)化的 machine code
二、V8 核心組件:Ignition 與字節(jié)碼 / TurboFan 與機(jī)器碼
1. 代碼的執(zhí)行層次: 從源碼到字節(jié)碼再到機(jī)器碼其實(shí)就是不斷編譯的過程
世界上能執(zhí)行代碼的地方有很多,數(shù)軸上的兩個極端: 左邊是抽象程度最高的人腦,右邊是抽象程度最低的 CPU:
上圖中三個實(shí)體以不同的角度理解下面這樣的代碼,從源碼到字節(jié)碼再到機(jī)器碼其實(shí)就是不斷編譯為另外一個語言的過程:
const a = 3 + 4;
(1) 人腦的理解
計(jì)算 3+4 存儲到 js 變量 const a 中。
(2) V8 parser 的理解
將代碼解析為 AST 樹(一種 JSON 結(jié)構(gòu)):
(3) V8 iginition 的理解
iginition 會將代碼理解編譯為 bytecode :
(4) V8 TurboFan 的理解
TurboFan 會將代碼理解為匯編:
2. 本質(zhì)上來說 bytecode 和 x86 匯編是一樣的
本質(zhì)上來說 v8 bytecode 和 x86 匯編是一樣的,只是世界上沒有裸機(jī)能跑出 v8 所理解的 bytecode 而已,機(jī)器碼為什么快是因?yàn)?CPU 能在硬件層面上裸跑匯編,因此速度特別快。
總之為了充分表達(dá) js 動態(tài)特性以及方便優(yōu)化為 CPU 能直接裸跑的匯編,v8 引入了 bytecode 這個層次,它比 AST 更接近物理機(jī),因?yàn)樗鼪]有層次嵌套,是一種基于寄存器的指令集。
3. 編譯時機(jī):JIT / AOT
JIT 指的是邊運(yùn)行邊優(yōu)化為機(jī)器碼的編譯技術(shù),其中的代表有 jvm / lua jit / v8,這類優(yōu)化技術(shù)會在運(yùn)行過程中持續(xù)收集執(zhí)行信息并優(yōu)化程序性能。AOT 指的是傳統(tǒng)的編譯行為,在靜態(tài)類型語言(如 C、C++、Rust)和某些動態(tài)類型語言(如 Go、Swift)中得到了廣泛應(yīng)用,由于能提前看到完整代碼,編譯器/語言運(yùn)行時可以在編譯階段進(jìn)行充分的優(yōu)化,從而提高程序的性能。
由于 JIT 語言并不能提前分析代碼并優(yōu)化執(zhí)行,因此 JIT 語言的「編譯期」很薄,而「運(yùn)行時」相當(dāng)厚實(shí),諸多編譯優(yōu)化都是在代碼運(yùn)行的過程中實(shí)現(xiàn)的。
4. Ignition 與字節(jié)碼
ignition 負(fù)責(zé)解釋執(zhí)行 V8 引入的中間層次字節(jié)碼,上接人腦里的 js 規(guī)范,下承底層 CPU 機(jī)器指令
5. TurboFan 與機(jī)器碼
TurboFan 可以將字節(jié)碼編譯為最快的機(jī)器碼,讓裸機(jī)直接運(yùn)行,達(dá)到最快的執(zhí)行速度。
三、V8 內(nèi)置 runtime 指令 --allow-natives-syntax
利用這個參數(shù)開啟 v8 注入的 runtime call,幫助分析和調(diào)試 v8:
# node 下開啟
$ node --allow-natives-syntax
# chrome 下開啟
$ open -a Chromium --args --js-flags="--allow-natives-syntax"
下面是一些常用指令說明。
1. %DebugPrint(something);
可以打印對象在 v8 的內(nèi)部信息,比如打印一個函數(shù):
2. %OptimizeFunctionOnNextCall(fn);
告訴 v8 下次調(diào)用主動觸發(fā)優(yōu)化函數(shù) fn
3. %GetOptimizationStatus(fn);
獲取函數(shù)當(dāng)前的優(yōu)化 status,后文會詳細(xì)介紹:
對應(yīng)的是 V8 源碼里的這個枚舉:
從開發(fā)視角來看,一個函數(shù)最佳的 status 應(yīng)該是 00000000000001010001 (81) 即:
4. %HasFastProperties(obj);
%HasFastProperties 可以用來打印對象是否是 Fast Properties 模式
后文會介紹這個 Fast Properties 和與之對立的 Slow Properties。
四、V8 Tagged Pointer
首先 Tagged Pointer 是 C/C++ 里常用的優(yōu)化技術(shù),不只在 V8 里有用,具體來說就是依據(jù) pointer 自身的數(shù)值的某些位來決定 pointer 的行為,也就是說這類指針的特點(diǎn)是「其指針數(shù)值上的某些位有特殊含義」。
比如在 v8 里,js 堆指針和 SMI 小整數(shù)類型(small intergers)是通過 Tagged Pointer 來表達(dá)和引用的,區(qū)別就在于最低一位是不是 0 來決定其指針類型:
對象指針(32 位):
xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxx1
SMI 小整數(shù)(32 位)其中 xxx 部分為數(shù)值部分:
xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxx0
用 C 表達(dá)就是這樣:
#include <stdio.h>
void printTaggedPointer(void * p) {
// 強(qiáng)轉(zhuǎn)一下, 關(guān)注 p 本身的數(shù)值
unsigned int tp = ((unsigned int) p);
if ((tp & 0b1) == 0b0) {
printf("p 是 SMI, 數(shù)值大小為 0x%x \n", tp >> 1);
return;
}
printf("p 是堆對象指針, Object<0x%x> \n", tp);
// printObject(*p); // 假設(shè)有個方法可以打印堆對象
}
int main() {
printTaggedPointer(0x1234 << 1); // smi
printTaggedPointer(17); // object
return 0;
}
運(yùn)行效果:
備注:
- void * 是 C 的 any,強(qiáng)轉(zhuǎn)比較多,請忽略 warning;
- 從這也可以看到超過 2^31 的整數(shù)或者浮點(diǎn)數(shù)就不能用 SMI 了,此時會裝箱為特殊的 HeapObject 放進(jìn)堆里 );
- 你可以通過 %DebugPrint({}) 和 %DebugPrint(123) 來看看其指針數(shù)值是不是整數(shù),然后你會發(fā)現(xiàn)所有對象的指針數(shù)值都是奇數(shù) Tagged Pointer ( 實(shí)際上 heap 內(nèi)的都是奇數(shù) heapdump 里也能看到這個細(xì)節(jié) )。
五、V8 基于 assumption 的 JIT 機(jī)器碼優(yōu)化
我們先來看這個例子,一個 add(x,y) 函數(shù),如果運(yùn)行期間出現(xiàn)了多種類型的傳參,那么會導(dǎo)致代碼變慢:
我們可以看到,L15 速度慢了非常多,比一開始的 66ms 慢了幾倍。
原因:
- 一開始只會傳數(shù)字的時候,V8 會假設(shè)這是數(shù)字加法,可以極致優(yōu)化。(66 毫秒可以跑完)
- L13 傳入其他參數(shù),上述假設(shè)會被推翻,此時打印一次優(yōu)化狀態(tài)可以看看出現(xiàn)了 反優(yōu)化,在 L13 執(zhí)行的時候?qū)嶋H走的是 iginition 解釋器去跑的。
- 執(zhí)行 L15 for 循環(huán)走了足夠多次后,V8 收集到足夠的 feedback 后會重新建立假設(shè)來做優(yōu)化,不過這次的假設(shè)是「入?yún)⒖赡苁?number 也可能是 string」—— 這意味著調(diào)用的時候要多判斷入?yún)㈩愋褪?string 還是 number 從而導(dǎo)致了最終的性能劣化 (一模一樣的代碼要 243 毫秒才跑完,慢了有三倍吧)。
1. assumption 被打破的時候不會 crash / 硬件錯誤 / 段錯誤嗎?
比如一開始傳的是 number,走到了優(yōu)化過的代碼,里面走的是匯編指令 add;當(dāng)傳入 string 或者 其他什么合法的 JSValue 后,編譯為匯編的 add 函數(shù)的執(zhí)行真的沒問題嗎?—— 不會有問題,因?yàn)?TurboFan 在編譯后的「機(jī)器碼」里會帶上很多 checkpoint,其實(shí)這些 checkpoint 就是在做類型檢查 type guard,如果類型對不上立刻就會終止這次調(diào)用并執(zhí)行「反優(yōu)化」讓 ignition 走字節(jié)碼解釋執(zhí)行。
上述說法可能會比較含糊,我們可以具體看看打出來的匯編是咋樣的,可以通過以下方式打印出優(yōu)化后的 x86 匯編(m1 芯片的蘋果電腦應(yīng)該是 arm 指令)。
$ node --print-opt-code --allow-natives-syntax --trace-opt --trace-deopt ./a.js
$ node --print-opt-code --allow-natives-syntax --trace-opt --trace-deopt ./a.js如下圖所示,這個 test 函數(shù)實(shí)現(xiàn)是將第一個入?yún)⒓由?0x1234 并返回,而這個核心邏輯對應(yīng) L37 那行匯編,而其他的部分除了 v8 自身的「調(diào)用約定」外,其他的就是 checkpoint 檢查類型,以及一些 debug 斷點(diǎn)了:
從前面的 Tagged Pointer 的相關(guān)討論可知,L19 ~ L22 其實(shí)就是在判斷入?yún)⑹遣皇?SMI,具體來說是 [rbx+0xf] 與 0x1 做按位與操作([rbx+0xf] 是通過棧傳遞的參數(shù),是 v8 里 js 的調(diào)用約定)如果結(jié)果是 0 則跳轉(zhuǎn) 0x10b7cc34f 即后續(xù)的正常流程,否則走到 CompileLazyDeoptimizedCode 走反優(yōu)化流程用字節(jié)碼解釋器去執(zhí)行了,我這里大概寫了一個反匯編偽碼對照:
另外我們也可以看到,核心邏輯對應(yīng)到匯編也就一行,剩余的指令要么是 checkpoint 要么是 v8/js 的調(diào)用約定,在這么多冗余指令的情況下執(zhí)行性能依然很快,可見匯編的執(zhí)行效率比起 line-by-line 的解釋器要高得多了。
2. 哪里可以打印所謂 feedback ?
通過 %DebugPrint 可以看到
當(dāng)打破這個 assumption 后,會變成 Any:
3. 多態(tài) return 會導(dǎo)致優(yōu)化效果打折嗎?
不會
4. feedback slot 里的{Mono|Poly|Mega|}morphic是?
- Monomorphic 單態(tài):指參數(shù)的類型只有一種,不會變
- Polymorphic 多態(tài):指參數(shù)的類型有多種 (比較短的 union type)
- Megamorphic 巨態(tài):指參數(shù)的類型非常復(fù)雜 (非常長的 union type)
根據(jù)前面提到的 checkpoint,上面三個 mono 的 checkpoint 最少,而最后的 mega 將會非常多,優(yōu)化性能最差,或者 V8 干脆就不會對這類函數(shù)做更深度的機(jī)器碼優(yōu)化了(比如后文會提到的 ICs)
5. TurboFan 過程本身耗時怎么樣?
從 JS AST / bytecode 編譯到機(jī)器碼也需要開銷,毫秒級。
6. 反優(yōu)化太多次怎么辦?
根據(jù)這篇文章 V8 function optimization - Blog by Kemal Erdem 如果某個函數(shù)「反優(yōu)化」超過 5 次后,v8 以后就不再會對這個函數(shù)做優(yōu)化了,不過我無法復(fù)現(xiàn)他說的這個情況,可能是老版本的 v8 的表現(xiàn),node16 不會這樣,不管怎樣只要 run 了足夠多次都turbofanned,只是如果「曾經(jīng)傳的參數(shù)類型太 union typed」會導(dǎo)致優(yōu)化效果出現(xiàn)非常大的折損。
7. 什么時候會啟動 TutboFan ?
前面我們已經(jīng)知道了「運(yùn)行足夠多次」會觸發(fā)優(yōu)化,而這只是其中一種情況,具體可以參考 v8 里 ShouldOptimize 的實(shí)現(xiàn),里面有詳細(xì)定義何時啟動優(yōu)化:
作為開發(fā)視角來看:
- L371 已經(jīng)優(yōu)化過的代碼不會再優(yōu)化;
- L375 這段邏輯決定是否啟用 maglev (具體見備注);
- L386 通過參數(shù)主動禁用/或者省電模式等這類不會優(yōu)化 ( 比如 node --v8-optinotallow="--turbo_filter=xxxxx" );
- L394 運(yùn)行足夠多次才會優(yōu)化 (還有個配置項(xiàng) efficiency_mode_delay_turbofan 配置延遲多久啟動 turbofan);
- L402 太長的函數(shù)不會優(yōu)化。
備注:maglev 是去年 chrome v8 團(tuán)隊(duì)搞的新特性 —— 編譯層次優(yōu)化,總的來說就是根據(jù) feedback 對機(jī)器碼的編譯層次做精細(xì)控制來達(dá)到更好的優(yōu)化效果,下圖是 v8 團(tuán)隊(duì)發(fā)布的 benchmark 對比:
具體可參考 v8.dev/blog/maglev
8. 編譯后的代碼會占內(nèi)存嗎?
會的,而且有時候這部分內(nèi)存占用非常多,這也是 Chrome 經(jīng)常被調(diào)侃為內(nèi)存殺手的重要原因之一,以 qq.com 為例,具體對應(yīng)是 heapdump 里的 (compiled code) 包含了編譯后的代碼內(nèi)存占用:
六、?? V8 對象模型
本節(jié)開始是本文的重點(diǎn)部分,因?yàn)橹挥辛私?V8 對象的內(nèi)存構(gòu)造,才能真正理解 V8 諸多優(yōu)化的理由。
1. C 語言的 struct 是怎么實(shí)現(xiàn)「點(diǎn)讀」的 ?
在正式進(jìn)入之前,我們先看看 C 里面 struct 的「點(diǎn)讀」是怎么做的。
C 會將 struct 理解為一段連續(xù)的線性 buffer 結(jié)構(gòu),并在上面根據(jù)字段的類型來劃分好從下標(biāo)的哪里到哪里是哪個字段(對齊),因此在編譯 point.x 的時候會改成 base+4 的方式進(jìn)行屬性訪問,如下圖所示,時間復(fù)雜度是 O(1) 的:
也因此 C 里面沒提供從字段 key 名的方式去取 struct value 的方法,也就是不支持 point['x'] 這樣,需要你自己寫 getter 才能實(shí)現(xiàn)類似操作。
這類根據(jù) string value 來從對象取值的技術(shù)通常在現(xiàn)代編程語言里都是自帶了的,通常稱為反射,可以在運(yùn)行時訪問源碼信息。
但在 JS 里,對象是動態(tài)的,可以有任意多的 key-values,而且這些 kv 鍵值對還可能在運(yùn)行時期間動態(tài)發(fā)生變化,比如我可以隨時 p.xxx =123 又或者 delete p.xxx 去刪掉它,這意味著一個 object 的 “shapes” 及其「內(nèi)存結(jié)構(gòu)」是無法被靜態(tài)分析出來的,而且這種內(nèi)存結(jié)構(gòu)必然不是「定長固定」的,是需要動態(tài) malloc 變長的。
假設(shè)現(xiàn)在是 2008 年,你是 google 的工程師,正在 chrome v8 項(xiàng)目組開發(fā),你會怎樣設(shè)計(jì) JS 的對象的內(nèi)存結(jié)構(gòu)?
const obj = { x: 3, y: 5 }
// obj 的內(nèi)存結(jié)構(gòu)可以設(shè)計(jì)成怎樣?
一眼丁真,開搞:
一個 key 定義加一個值,然后將這個結(jié)構(gòu)數(shù)組化就可以表達(dá)對象的 kv 結(jié)構(gòu),增加屬性就在后面繼續(xù)擴(kuò)增,查找算法則是從頭查到尾,時間復(fù)雜度為 O(n)
但是如果按這個設(shè)計(jì),下面兩個 obj 就會有重復(fù)的 key 定義內(nèi)存消耗了:
const obj1 = { x: 11, y: 22 } // "x" 11 "y" 22
const obj1 = { x: 33, y: 44 } // "x" 33 "y" 44
// 會重復(fù) "x" 和 "y"
好了就上面這樣簡單弄一下就搞出了好多問題了。從下面開始正式進(jìn)入,V8 是如何描述對象,參見下文。
2. JSObject 與 named-properties & indexed-elements
在 js 標(biāo)準(zhǔn)里 Array 是一類特殊的 Object,但出于性能考慮 V8 底層針對對象和數(shù)組的處理是不同的:
- 所謂 indexed-elements 指的是數(shù)組元素(以數(shù)字下標(biāo)作為 key)存儲于 *elements,是一段線性內(nèi)存空間,可以直接用下標(biāo)直接訪問,查找速度非??欤?/li>
- 而其他的普通成員所謂 named-properties 則存儲于 *properties 查找速度比較慢,需要遍歷對比。
如下圖所示,JSObject:
在 V8 里:
- Array-indexed 的屬性存儲在 *elements 里,查找速度快;Named Properties 則存儲在 *properties 里,查找速度慢;
- Properties/Elements 這兩個結(jié)構(gòu)可以是數(shù)組,但有時候也會變成字典(比如稀疏數(shù)組場景,線性內(nèi)存空間就不夠性能了);
- 每個 JSObject 都有一個 *hiddenClass,用于保存對象的 Shapes。
嗯?對象的 Shapes?那是什么?
3. 對象的 Shapes
所謂對象的 shapes,其實(shí)就是對象上有什么 key,前面提到過 V8 的優(yōu)化需要在運(yùn)行時不斷收集 feedback,比如當(dāng)執(zhí)行下面這段代碼的時候,引擎就可以知道「obj 有兩個 key,一個是 a 一個是 b」:
const obj = {}
obj.a = 123;
obj.b = 124;
doSomething(obj);
V8 通過 Hidden Class 結(jié)構(gòu)來記錄 JSObject 在運(yùn)行時的時候有哪些 key,也就是記錄對象的 shapes,由于 JSObject 是動態(tài)的,后續(xù)也可以隨意設(shè)置 obj.xxx = 123,也就是對象的 shapes 會變,也因此對象持有的 Hidden Class 會隨著特定代碼的運(yùn)行而變化
Hidden Class 是比較學(xué)術(shù)的說法,在 V8 源碼里的「工程命名」是 Map,在微軟 Edge Chakra (edge) 里叫做 Types,在 JavaScriptCore (WebKit Safari) 里叫做 Structure,在 SpiderMonkey (FireFox) 里叫做 Shapes .... 總之各個主流引擎都有實(shí)現(xiàn)追蹤「對象 shapes 變化」
后文可能會混淆上面幾個用語,它們都是指 Hidden Class,用來描述對象的 shapes。
4. Hidden Class DescriptorArrays 與 in-object properties
前面提到除了 *properties 和 *elements 可以用來存儲對象成員之外,JSObject 還提供了所謂 in-object properties 的方式來存儲對象成員,也就是將對象成員保存在「JSObject 結(jié)構(gòu)體」上,并配合 Hidden Class 進(jìn)行鍵值描述:
上圖里 Hidden Class 里底下有個叫做 DescriptorArrays 的子結(jié)構(gòu),這個結(jié)構(gòu)會記錄對象成員 key 以及其對應(yīng)存儲的 in-object 下標(biāo),也就是上面的紫框。
或許你會問:
- 為什么要這樣,這樣做能幫助提升性能么?別急,后文會扣回來。
- 什么時候用 in-object 什么時候用 *properties 存儲,兩者做的是同一件事,不會沖突嗎?別急,后文會提。
5. 變化中的 Hidden Class
如果 Hidden Class 是靜態(tài)的,那么這圖就足夠描述 Hidden Class 了:
但是對象的 shapes 會變,也因此對象持有的 Hidden Class 會隨著特定代碼的運(yùn)行而變化,V8 使用了 Transition Chain,一種基于鏈表構(gòu)造的方式來描述「變化中的 Hidden Class」:
備注:為了方便討論,后文可能不會將 Hidden Class 畫成鏈表,而是畫成一起并且省略空對象的 shapes,另外 Hidden Class Node 上還有其他字段,相對不那么重要,就忽略了
由于鏈表的特性,顯然可以比較容易地讓具有相同 shapes 的對象能復(fù)用同一個 Hidden Class ,比如下面這個 case,o1 o2 均復(fù)用了地址為 0xABCD 的 Hidden Class 節(jié)點(diǎn):
當(dāng)出現(xiàn)不同走向的時候,此時會單獨(dú)開一個 branch 來描述這種情況,此時 o1 和 o2 就不再一樣了:
6. V8 對象模型總結(jié)
從前文的討論,可以得到的結(jié)論:
- V8 使用 JSObject 來描述對象,上面有若干個字段(除了上面那些還有 prototype 原型鏈那些,相對不那么重要,就沒畫出);
- V8 還使用 Tagged Pointer 來描述對象指針(前文有提);
- named properties 成員存儲在 *properties 里,可以為數(shù)組,也可以為字典
- named properties 也可以存儲在 in-object properties 里,可以動態(tài)增長;
- 數(shù)字下標(biāo)成員存儲在 *elements 里,可以為數(shù)組,也可以為字典(稀疏數(shù)組場景)。
懸而未決的問題:
- 何時用 in-object properties 何時用 *properties ?
- 為什么看起來 Hidden Class 這套機(jī)制下屬性查找依然是 O(n) 的操作?追蹤對象的 shapes 意義在哪?
請帶著這兩個問題到下一章 Inline Caches 繼續(xù)閱讀。
七、?? Inline Caches (ICs) 優(yōu)化原理
引入 Hidden Class 后,為了讀取某個成員,那不還得查一次 Hidden Class 拿到 in-object 的下標(biāo),這個過程不還是 O(n) 嗎?
是的,如果事先不知道 JSObject 的 shapes 的情況下去讀取成員確實(shí)是 O(n) 的,但前面我已經(jīng)提過了:
V8 的諸多優(yōu)化是基于 assumption 的,那么在已知 obj 的 Shapes 的情況下,你會怎么優(yōu)化下面這個 distance 函數(shù)?
如此優(yōu)化就可以將「通過遍歷 *properties訪問成員的O(n) 過程」直接優(yōu)化為「直接按下標(biāo)偏移直接讀取 `in-object` 的 O(1)過程」了,這種優(yōu)化手段就叫做 Inline Caches (ICs),有點(diǎn)類似 C 語言的 struct 將字段點(diǎn)讀編譯為偏移訪問,只不過這個過程是 JIT 的,不是 C 那樣 AOT 靜態(tài)編譯確定的,是 V8 在函數(shù)執(zhí)行多次收集了足夠多的 feedback 后實(shí)現(xiàn)的。
你可能還會問:在調(diào)用優(yōu)化后的 distance2 的時候具體要怎么確定傳入的 p1 p2 的 shapes 是否有變化?還記得前面那個 0xABCD 嗎?沒錯,編譯后的匯編 checkpoint 就是直接判斷傳入對象的 hidden classs 指針數(shù)值是不是 *0xABCD*,如果不是就觸發(fā)「反優(yōu)化」兜底解釋器模式運(yùn)行即可。
—— 下面這個實(shí)例將手把手介紹 ICs 的真實(shí)場景以及匯編細(xì)節(jié)
1. 匯編實(shí)例:為什么靜態(tài)的比動態(tài)的要好 ?
從前面 Inline Cache 的討論中可以得知,必須要確定了訪問的 key 才能做 ICs 優(yōu)化,因此寫代碼的過程中,如有可能請盡量避免下面這樣通過 key string 動態(tài)查找對象屬性:
function test(obj: any, key: string) {
return obj[key];
}
如果能明確知道 key 的具體值,此時建議寫為:
function test(obj: any, key: 'a' | 'b') {
if (key === 'a') return obj.a;
if (key === 'b') return obj.b;
}
即使確實(shí)不得不動態(tài)查詢,但是你知道某個子 case 占了 99% 的調(diào)用次數(shù),此時也可以這樣優(yōu)化:
function test(obj, key: 'a' | 'b') {
// 為 'a' 的調(diào)用次數(shù)占了 99% 可以這樣提前優(yōu)化
if (key === 'a') return obj.a;
return obj[key];
}
靜態(tài)和動態(tài)兩種寫法風(fēng)格可能會有幾倍甚至上百倍的差距,如果業(yè)務(wù)里有大幾百萬次的調(diào)用 test,優(yōu)化后能省不少毫秒,比如下面這個「簡化的服務(wù)發(fā)現(xiàn)」例子有近百倍的差距:
原因是 s2.js 里那些屬性訪問都被 ICs 技術(shù)優(yōu)化成 O(1) 訪問了,速度很快 —— 為了探究內(nèi)部的 ICs 相關(guān)匯編邏輯,嘗試輸出 serviecMap 的 Hidden Class (V8 里 hidden class 別名是 Map) 以及匯編源碼:
首先 %DebugPrint 出 serviceMap 的 Hidden Class 的物理地址,可以看到是 0x3a8d76b74971 然后看后續(xù)編譯優(yōu)化的 arm machine code 是怎么利用這個地址實(shí)現(xiàn) ICs 技術(shù)優(yōu)化的:(筆者這會的電腦是 mac m1 因此是 arm 匯編,不是 x86 匯編)。
可以看到,ICs 優(yōu)化后匯編的 checkpoint 其實(shí)就是將 Hidden Map 的指針物理地址直接 Inline 到匯編里了,通過判等的方式來驗(yàn)證假設(shè),然后就可以直接將屬性訪問優(yōu)化為 O(1) 的 in-object properties 訪問了,這也是這個技術(shù)為什么叫做 Inline Cahce (ICs) 了。
(這幾乎是 V8 里效果最好的優(yōu)化了,也因此部分 benchmark 里 nodejs 對象可能比 Java 對象還快,因?yàn)?Java 里有可能濫用反射導(dǎo)致對象性能非常差)。
2. Fast Properties 和 Slow Properties
如果知道 ICs 技術(shù)內(nèi)涵的話,理解 Fast Properties 和 Slow Properties (或者稱字典模式) 就不會有困難了。
下圖描述了 JSObject 的主要構(gòu)造:當(dāng)把對象成員存儲到 in-object properties 的時候,此時稱對象是 Fast Properties 模式,這意味著對象訪問 V8 會在合適的時候?qū)⑵?Inline Cache 到優(yōu)化后的匯編里;反之,當(dāng)成員存儲到 *properties 的時候,此時稱為 Slow Properties,此時就不會對這類對象做 inline cache 優(yōu)化了,此時對象訪問性能最差(因?yàn)橐闅v *properties 字典,通常慢幾十到幾百倍,取決于對象成員數(shù)量)。
我們可以用 %HasFastProperties 來打印對象是否是 Fast Properties 模式,如下圖所示:
delete 會將對象轉(zhuǎn)為 slow properties 模式,為什么呢?因?yàn)?delete 帶來的問題可太多了,緩存技術(shù)最怕的就是 delete,如圖所示:
我拍腦子就能想到上面四個問題,要完整的確保 delete 的安全性可太難了,因此維護(hù) delete 后的 hidden class 非常麻煩,V8 采取的方式是直接將 in-object 釋放掉,然后將對象屬性都復(fù)制存儲到 *properties 里了,以后這個對象就不再開啟 ICs 優(yōu)化了,此時這種退化后的對象就稱為 slow properties (或者稱字典模式)。
3. 利用 Hidden Class 來查找內(nèi)存溢出 (heapdump)
Hidden Class 是比較學(xué)術(shù)的名字,在 V8 里對應(yīng)的「工程命名」是 Map,可以在 heapdump 里看到:
利用查找 Hidden Class 的方式可以快速定位大批量相同 shapes 的對象哦,很方便查找內(nèi)存溢出問題。
八、V8 其他優(yōu)化
1. inline 展開
跟 C++ 里的 inline 關(guān)鍵字一樣,將函數(shù)直接提前展開,少一次調(diào)用棧和函數(shù)作用域開銷。
2. 逃逸分析
基于 Sea Of Nodes 的 PL 理論進(jìn)行優(yōu)化,分析對象生命周期,如果對象是一次性的,那么就可以做編譯替換提升性能,比如下圖里對象 o 只用到了 a,那么就可以優(yōu)化成右邊那樣,減少對象內(nèi)存分配并提升尋址速度:
3. 提前為空對象申請 in-object 內(nèi)存空間
通過打 heapdump 的方式可以發(fā)現(xiàn)下面第二行的空對象的 shallow size 是 28 字節(jié),而后一個是 16 字節(jié):
window.arr = []; // 打一次 heapdump
arr.push({}); // 打一次 heapdump
arr.push({ ggg: undefined });
原因:V8 假設(shè)空對象后面都會設(shè)置新的 key 上去,因此會預(yù)先 malloc 了一些 in-object 字段到 JSObject 上,最后就是 28,比 16 要大;而第三行這樣固定就只會 malloc 一個 in-object 字段了(其實(shí)看圖里還有一個 proto 字段)。
那么 new Object() 呢?一樣會;如果是 Object.create(null) 呢?這種情況就不會申請了,shallow size 此時最小,為 12 字節(jié)。
28 - 12 = 16 字節(jié),而一個指針占 4 字節(jié),因此 V8 對一個空對象會默認(rèn)為其多創(chuàng)建 4 個 in-object 字段以備后續(xù)使用,而這類預(yù)分配的內(nèi)存空間,會在下次 GC 的時候?qū)]用到的回收掉,這項(xiàng)技術(shù)叫做 「Slack Tracking 松弛追蹤」。
4. 其他優(yōu)化技術(shù)
v8 里還有很多針對 string / Array 的優(yōu)化技術(shù),本次技術(shù)優(yōu)化主要涉及 ICs 相關(guān)優(yōu)化,就不展開寫了,參見后文鏈接(其實(shí)大部分對象優(yōu)化技術(shù)都是圍繞 V8 對象模型來進(jìn)行的)。
九、Safari 也有 JIT 也有 ICs 技術(shù)
Safari 的 WebKit JSCore 引擎也有基于 LLVM 后端的 JIT 技術(shù),因此很多優(yōu)化手段是共通的,比如 safari 也有 type feedback 和屬性追蹤,也有自己的 hidden class / ICs 實(shí)現(xiàn),可以打開 safari 的調(diào)試工具看到運(yùn)行時的 type feedback:(macOS、iOS、iPadOS 上都有 JIT,在 chrome 上優(yōu)化后全平臺都能受益)。
在這些優(yōu)化技術(shù)的加持上,safari jscore 某些情況下甚至?xí)?chrome v8 還要快:
十、高性能 JS 編寫建議
大部分業(yè)務(wù)場景里更關(guān)心可維護(hù)性,性能不是最重要的,另外就是面向引擎/底層優(yōu)化邏輯寫的 js 未必是符合最佳實(shí)踐的,有時候會顯得非常臟,這里總結(jié)一下個人遇到的常見實(shí)例對照,供參考:
1. 熱點(diǎn)函數(shù)(Hot Function)
熱點(diǎn)函數(shù)會優(yōu)先走 turbofan 編譯為機(jī)器碼,性能會更好,要如何利用好這個特性?將項(xiàng)目里的一些高頻原子操作拆成獨(dú)立函數(shù),人為制造熱點(diǎn)代碼,比如計(jì)算點(diǎn)距離,單位換算等等這些需要高性能的地方:
2. 函數(shù)拆解
除了前面提到的熱區(qū)之外,拆解后的函數(shù)如果足夠短,那么 V8 在調(diào)用的時候會做 inline 展開優(yōu)化,節(jié)省一次調(diào)用棧開銷。
3. 減少函數(shù)狀態(tài)(Mono)
從前面的 add 的例子我們可以知道,V8 TurboFan 優(yōu)化是基于 assumption 的,應(yīng)該盡量保持函數(shù)的單態(tài)性 (Monomorphic),或者說減少函數(shù)的狀態(tài),具體來說高頻函數(shù)不要傳 Union Types 作為參數(shù)。(這個不夠準(zhǔn)確,最好是不要打破參數(shù)的 V8 內(nèi)部類型表示以及匯編 checkpoint,比如一會傳浮點(diǎn)數(shù)、一會傳 SMI 這樣即使都是 number 也會打破 v8 的假設(shè),因?yàn)?v8 內(nèi)部實(shí)現(xiàn)的浮點(diǎn)數(shù)會裝箱,而小整數(shù) SMI 不會,兩者的匯編邏輯不一樣)。
推薦使用 TypeScript 來寫 js 應(yīng)用,限制函數(shù)的入?yún)㈩愋涂梢杂行ПWC函數(shù)的單態(tài)性質(zhì),更容易編寫高性能的 js 代碼
4. 保持對象賦值順序不變(Hidden Class)
賦值順序的不同會產(chǎn)生不同的 Hidden Class 鏈,不同的鏈不能做 ICs 優(yōu)化。
5. class 里的字段聲明最好加上一個默認(rèn)值
class A {
a?: number
}
class A {
a = undefined // 或 null
}
理由跟前一點(diǎn)一樣,前者 A 有 shapes 鏈?zhǔn)?空對象+a,而后者就是確定的 a 了。
但是,賦值會多消耗一點(diǎn)內(nèi)存,內(nèi)存敏感型場景慎用。
6. 避免使用 delete
delete 后會將對象轉(zhuǎn)為 Slow Properties 模式,這種模式下的對象不會被 inline cache 到優(yōu)化后的匯編機(jī)器碼里,對性能影響比較大,另外這樣的對象如果到處傳的話就會到處觸發(fā)「反優(yōu)化」將污染已經(jīng)優(yōu)化過的代碼。
7. 避免反優(yōu)化
前面的例子里提到,反優(yōu)化后的函數(shù)再優(yōu)化性能不會比最開始要好,換言之被「feedback 污染」了,我們應(yīng)當(dāng)盡量避免反優(yōu)化的出現(xiàn)(即 checkpoint 被打破的情況)。
8. 靜態(tài)的比動態(tài)的好
前面已經(jīng)討論過這類情況了,靜態(tài)種寫法 V8 可以做 ICs 優(yōu)化,將屬性訪問直接改為 in-object 訪問,速度可以比動態(tài) key 查找快近百倍。
9. 字面量聲明比過程式聲明更好
const obj = { a: 1, b: 2 };
const obj = {};
obj.a = 1;
obj.b = 2;
從 Hidden Class 的角度來看,第二種會使 Hidden Class 變化三次,而第一種直接聲明其實(shí)就隱含了 Hidden Class 了,V8 可以直接提前靜態(tài)分析得出。
10. 盡量保證對象就只作用在一個函數(shù)內(nèi)(逃逸分析)
v8 會分析 ast,將左側(cè)優(yōu)化成右側(cè)。
11. Ref<T> 性能問題
在 React / Vue 里有這種 Ref 構(gòu)造來實(shí)現(xiàn)訪問同一個實(shí)例的操作(類似指針)
type Ref<T> = {
ref: T
}
// React 的是 current 作為 key
type ReactRef<T> = { current: T }
前面提到過的 ICs 優(yōu)化,因此上述這樣的構(gòu)造并不會造成嚴(yán)重的性能損失,會多消耗一點(diǎn)內(nèi)存,大多數(shù)情況下可以放心使用(多消耗 16 字節(jié))。