?V8 引入全新的非優(yōu)化 JS 編譯器,性能大幅提升!



作者:V8團(tuán)隊(duì)
https://v8.dev/blog/sparkplug
想要編寫高性能的 JavaScript 引擎,光是有高度優(yōu)化的編譯器(如 TurboFan)是不夠的。特別是對(duì)于短生命周期的會(huì)話(例如加載網(wǎng)站或命令行工具),在高優(yōu)化編譯器開始優(yōu)化之前就已經(jīng)有很多工作要做,更沒有時(shí)間去生成什么優(yōu)化代碼了。
正因如此,自 2016 年起,我們不再跟蹤綜合基準(zhǔn)測(cè)試(如 Octane)的成績(jī),而是轉(zhuǎn)而去衡量實(shí)際場(chǎng)景中的性能表現(xiàn)。并且從那時(shí)起,我們就一直在努力研究如何提升高優(yōu)化編譯器作用范圍之外的 JavaScript 性能。這意味著我們需要在解析器、流式處理、對(duì)象模型、垃圾收集中的并發(fā)性、緩存編譯后的代碼等事項(xiàng)上逐個(gè)攻關(guān)……每一個(gè)領(lǐng)域都有新鮮的感覺。
當(dāng)我們轉(zhuǎn)向提升現(xiàn)實(shí)場(chǎng)景中初始 JavaScript 的執(zhí)行性能,我們?cè)趦?yōu)化解析器時(shí)開始遇到諸多局限。V8 的解析器經(jīng)過高度優(yōu)化,速度極快,但解析器總有一些固有開銷是我們無(wú)法擺脫的;字節(jié)碼解碼開銷或調(diào)度開銷是解析器功能的內(nèi)在組成部分。
基于我們目前的雙編譯器模式,我們很難更快地升級(jí)(tier-up)到優(yōu)化代碼;我們可以(并且正在)提升優(yōu)化的效果,但在某些時(shí)候,想要提升速度就只能去掉一些優(yōu)化項(xiàng),但這會(huì)降低峰值性能。更糟糕的是,我們還無(wú)法提前優(yōu)化進(jìn)程,因?yàn)槲覀冞€沒有穩(wěn)定的對(duì)象形態(tài)反饋。
今天我們向大家介紹 Sparkplug:這是我們將隨 V8 v9.1 發(fā)布的,全新的非優(yōu)化 JavaScript 編譯器,位于 Ignition 解析器和 TurboFan 優(yōu)化編譯器之間。

Sparkplug 的設(shè)計(jì)目標(biāo)是快速編譯。非常快,如此之快,讓我們可以隨時(shí)隨地進(jìn)行編譯,于是我們就可以比 TurboFan 代碼更積極地升級(jí)到 Sparkplug 代碼。
Sparkplug 編譯器的速度來自于一些技巧。首先,它會(huì)作弊;它所編譯的函數(shù)已經(jīng)被編譯為字節(jié)碼,并且字節(jié)碼編譯器已經(jīng)完成了大多數(shù)艱苦的工作,例如變量解析、弄清楚括號(hào)是否實(shí)際上是箭頭函數(shù)、消除結(jié)構(gòu)化語(yǔ)句等等。Sparkplug 從字節(jié)碼而不是 JavaScript 源代碼進(jìn)行編譯,因此不必操心這些麻煩的事情。
第二招是,Sparkplug 不會(huì)像大多數(shù)編譯器那樣生成任何中間表示(IR)。相反,Sparkplug 通過字節(jié)碼的一次線性 pass 直接編譯為機(jī)器碼,并發(fā)出與該字節(jié)碼的執(zhí)行相匹配的代碼。實(shí)際上,整個(gè)編譯器是一個(gè) for 循環(huán)內(nèi)的一個(gè) switch 語(yǔ)句,分派給固定的,按字節(jié)碼的機(jī)器碼生成函數(shù)。
for (; !iterator.done(); iterator.Advance()) {VisitSingleBytecode()}
缺少 IR 意味著編譯器的優(yōu)化機(jī)會(huì)有限,只能做一些非常本地的小幅度優(yōu)化。這也意味著我們必須將整個(gè)實(shí)現(xiàn)分別移植到我們支持的每種架構(gòu)上,因?yàn)檫@里沒有架構(gòu)無(wú)關(guān)的中間階段。但事實(shí)證明這些都不是問題:快速編譯器是簡(jiǎn)單編譯器,因此代碼很容易移植;并且 Sparkplug 不需要大量?jī)?yōu)化,因?yàn)槲覀兩院髸?huì)在管道中提供優(yōu)化效果很出色的編譯器。
從技術(shù)上講,我們目前對(duì)字節(jié)碼進(jìn)行了兩次 pass——一次用來發(fā)現(xiàn)循環(huán),第二次生成實(shí)際代碼。不過,我們最終的計(jì)劃是擺脫第一個(gè)。
向現(xiàn)有的成熟 JavaScript VM 添加新的編譯器是一項(xiàng)艱巨的任務(wù)。除了標(biāo)準(zhǔn)執(zhí)行之外,你還需要支持各種各樣的事情;V8 有一個(gè)調(diào)試器、一個(gè) stack-walking CPU profiler、針對(duì)異常的堆棧跟蹤、集成到升級(jí)、堆棧替換以優(yōu)化代碼實(shí)現(xiàn)熱循環(huán)……實(shí)在很多。
Sparkplug 巧妙地簡(jiǎn)化了所有這些問題,具體方法就是保持一個(gè)“與解析器兼容的堆棧框架”。
稍微解釋下。堆棧框架(Stack frame)是代碼執(zhí)行存儲(chǔ)函數(shù)狀態(tài)的方式。每當(dāng)你調(diào)用一個(gè)新函數(shù)時(shí),它都會(huì)為該函數(shù)的局部變量創(chuàng)建一個(gè)新的堆棧框架。一個(gè)堆棧框架由一個(gè)框架指針(標(biāo)記其開始)和一個(gè)堆棧指針(標(biāo)記其結(jié)束)定義:

看到這里,很多讀者會(huì)表示抗議:“這張圖不對(duì)啊,堆棧明顯是朝著相反的方向的!”。別急,我為你做了一個(gè)按鈕:
當(dāng)一個(gè)函數(shù)被調(diào)用時(shí),返回地址被推入這個(gè)堆棧;該函數(shù)返回時(shí)會(huì)彈出它,來知道該返回到何處。然后,當(dāng)該函數(shù)創(chuàng)建一個(gè)新框架時(shí),它將舊的框架指針保存在堆棧上,并將新的框架指針設(shè)置為指向它自己的堆棧框架的起始。因此,這個(gè)堆棧有了一個(gè)框架指針鏈,每個(gè)框架指針都指向前一個(gè)框架的起始:

嚴(yán)格來說這只是一個(gè)約定,后面是生成的代碼,它不是必需的。不過這是一種相當(dāng)常見的方式;唯一真正中斷的一次是堆棧框架完全清除的時(shí)候,或者可以改用調(diào)試邊表(side-table)遍歷堆棧框架的時(shí)候。
這是針對(duì)所有函數(shù)類型的常規(guī)堆棧布局;然后是關(guān)于如何傳遞參數(shù),以及函數(shù)如何在其框架中存儲(chǔ)值的約定。在 V8 中,我們有針對(duì) JavaScript 框架的約定,即在調(diào)用函數(shù)之前將參數(shù)(包括接收器)以相反的順序推入堆棧,并且堆棧上的前幾個(gè)槽為:被調(diào)用的當(dāng)前函數(shù);被調(diào)用的上下文;以及傳遞的參數(shù)數(shù)量。這是我們的“標(biāo)準(zhǔn)”JS 框架布局:

這個(gè) JS 調(diào)用約定在優(yōu)化框架和解析框架之間共享,這樣一來,當(dāng)我們?cè)谡{(diào)試器的性能面板中調(diào)優(yōu)代碼時(shí),就能以最小的開銷遍歷堆棧,諸如此類。
對(duì)于 Ignition 解析器來說,約定變得更加顯式。Ignition 是基于寄存器的解析器,這意味著存在一些虛擬寄存器(請(qǐng)勿與機(jī)器寄存器混淆!)來存儲(chǔ)解析器的當(dāng)前狀態(tài)——其中包括 JavaScript 函數(shù)的本地變量(var/let/const 聲明)和臨時(shí)值。這些寄存器與要執(zhí)行的字節(jié)碼數(shù)組指針,以及該數(shù)組中當(dāng)前字節(jié)碼的偏移量一起存儲(chǔ)在解析器的堆棧框架中:

Sparkplug 會(huì)有意創(chuàng)建并維護(hù)一個(gè)與解析器的框架相匹配的框架布局;只要解析器存儲(chǔ)一個(gè)寄存器值,Sparkplug 也會(huì)存儲(chǔ)一個(gè)值。這樣做有幾個(gè)原因:
它簡(jiǎn)化了 Sparkplug 的編譯過程;Sparkplug 可以只鏡像解析器的行為,而無(wú)需保留從解析器寄存器到 Sparkplug 狀態(tài)的某種映射。 由于字節(jié)碼編譯器完成了分配寄存器的重活兒,因此它還加快了編譯速度。 它大大簡(jiǎn)化了與系統(tǒng)其余部分的集成工作。調(diào)試器、profiler、異常堆棧展開、堆棧跟蹤打印,所有這些操作都會(huì)執(zhí)行堆棧遍歷以發(fā)現(xiàn)當(dāng)前正在執(zhí)行的函數(shù)堆棧,并且所有這些操作都不需要做什么更改就能繼續(xù)搭配 Sparkplug,因?yàn)榫退鼈兌裕鼈冇械闹皇且粋€(gè)解析器框架。 它簡(jiǎn)化了堆棧替換(OSR)。OSR 是指在執(zhí)行過程中替換當(dāng)前正在執(zhí)行的函數(shù);當(dāng)前,當(dāng)一個(gè)已解析函數(shù)在一個(gè)熱循環(huán)內(nèi)(在該循環(huán)中它升級(jí)為優(yōu)化代碼),以及在優(yōu)化代碼取消優(yōu)化(在其降級(jí)并繼續(xù)在解析器中執(zhí)行該函數(shù))時(shí),就會(huì)發(fā)生這種情況。使用 Sparkplug 框架鏡像解析器框架時(shí),任何適用于解析器的 OSR 邏輯都將適用于 Sparkplug;更棒的是,我們可以在解析器和 Sparkplug 代碼之間切換,而框架轉(zhuǎn)換開銷幾乎為零。
我們對(duì)解析器堆棧框架做了一個(gè)小更改,即在 Sparkplug 代碼執(zhí)行期間,我們不讓字節(jié)碼偏移保持最新。相反,我們存儲(chǔ)一個(gè)從 Sparkplug 代碼地址范圍到對(duì)應(yīng)的字節(jié)碼偏移量的雙向映射。這是一種相對(duì)簡(jiǎn)單的編碼映射,因?yàn)?Sparkplug 代碼是直接從字節(jié)碼上的一個(gè)線性遍歷發(fā)出的。每當(dāng)一個(gè)堆棧框架訪問想要知道一個(gè) Sparkplug 框架的“字節(jié)碼偏移量”時(shí),我們都會(huì)在此映射中查找當(dāng)前執(zhí)行的指令,并返回相應(yīng)的字節(jié)碼偏移量。類似地,每當(dāng)我們想將 OSR 從解析器轉(zhuǎn)換為 Sparkplug 時(shí),我們都可以在映射中查找當(dāng)前字節(jié)碼偏移量,然后跳轉(zhuǎn)到相應(yīng)的 Sparkplug 指令。
你可能會(huì)注意到,我們現(xiàn)在在堆棧框架上有一個(gè)未使用的插槽,字節(jié)碼偏移量就會(huì)在這個(gè)插槽上。由于我們希望保持堆棧的其余部分不變,因此我們不能放棄它。我們重新調(diào)整了這個(gè)堆棧插槽的功能,讓它為當(dāng)前正在執(zhí)行的函數(shù)緩存“反饋向量”。這是用于存儲(chǔ)對(duì)象形態(tài)數(shù)據(jù)的向量,大多數(shù)操作都需要加載它。我們要做的只是謹(jǐn)慎一點(diǎn)對(duì)待 OSR,確保我們?yōu)檫@個(gè)插槽要么換入正確的字節(jié)碼偏移量,要么換入正確的反饋向量。
于是 Sparkplug 堆棧框架為:

一個(gè) V8 Sparkplug 堆棧框架
實(shí)際上,Sparkplug 很少生成自己的代碼。JavaScript 語(yǔ)義很復(fù)雜,即使執(zhí)行最簡(jiǎn)單的操作也需要大量代碼。由于多種原因,強(qiáng)制 Sparkplug 在每次編譯時(shí)內(nèi)聯(lián)重新生成這些代碼都是不好的:
由于需要生成大量代碼,這將明顯增加編譯時(shí)間, 這會(huì)增加 Sparkplug 代碼的內(nèi)存消耗,并且 我們必須重新實(shí)現(xiàn)用于 Sparkplug 的一堆 JavaScript 功能的代碼源,這可能意味著會(huì)有更多的錯(cuò)誤和更大的受攻擊面。
因此,大多數(shù) Sparkplug 代碼只是調(diào)用“內(nèi)置代碼”,即嵌入二進(jìn)制文件中的小段機(jī)器碼片段,以完成那些臟活兒。這些內(nèi)置代碼要么就是解析器用的那些,或者至少與解析器的字節(jié)碼處理程序共享大部分代碼。
實(shí)際上,Sparkplug 代碼基本上只是內(nèi)置代碼的調(diào)用和控制流:
你現(xiàn)在可能會(huì)想,“那么,這一切到底有什么意義?Sparkplug 不是在做與解析器相同的工作嗎?”——你的疑問是有道理的。在許多方面,Sparkplug 只是解析器執(zhí)行的一個(gè)序列化,它調(diào)用相同的內(nèi)置函數(shù)并維護(hù)相同的堆棧框架。但這樣做也是值得的,因?yàn)樗ɑ蚋鼫?zhǔn)確地說是預(yù)編譯)了那些不可移動(dòng)的解析器開銷,例如操作數(shù)解碼和下一個(gè)字節(jié)碼分派。
事實(shí)證明,解析器破壞了許多 CPU 優(yōu)化工作:解析器從內(nèi)存中動(dòng)態(tài)讀取靜態(tài)操作數(shù),從而迫使 CPU 停頓或推測(cè)值可能是多少。分派到下一個(gè)字節(jié)碼需要成功的分支預(yù)測(cè)才能保持高性能,即使推測(cè)和預(yù)測(cè)正確,你還是要執(zhí)行所有解碼和分派代碼,并且你還是會(huì)在各個(gè)緩沖區(qū)和緩存中浪費(fèi)寶貴的空間。CPU 實(shí)際上本身就是一個(gè)解析器,只不過它是機(jī)器碼的解析器。這樣看來,Sparkplug 是從 Ignition 字節(jié)碼到 CPU 字節(jié)碼的一個(gè)“轉(zhuǎn)譯器”,將你的函數(shù)從在“仿真器”中運(yùn)行移到了“原生”運(yùn)行。
那么,Sparkplug 在現(xiàn)實(shí)場(chǎng)景中的性能表現(xiàn)如何呢?我們用 Chrome M91 跑了一些基準(zhǔn)測(cè)試,用了幾個(gè)性能 bot,分別啟用和關(guān)閉 Sparkplug 來觀察其影響。
劇透:我們非常滿意。
以下基準(zhǔn)測(cè)試列出了運(yùn)行多個(gè)操作系統(tǒng)的 bot。雖說系統(tǒng)和 bot 的名字差不多,但我們認(rèn)為它并不會(huì)對(duì)結(jié)果產(chǎn)生太大影響。另外,不同的機(jī)器也有不同的 CPU 和內(nèi)存配置,我們認(rèn)為這是差異的主要來源。
Speedometer 是一個(gè)基準(zhǔn)測(cè)試,它使用一些流行的框架構(gòu)建一個(gè) TODO 列表跟蹤 Web 應(yīng)用程序,并通過添加和刪除 TODO 對(duì)應(yīng)用程序進(jìn)行性能壓力測(cè)試,來模擬現(xiàn)實(shí)世界中網(wǎng)站框架的使用情況。我們發(fā)現(xiàn)它很好地反映了現(xiàn)實(shí)世界中的負(fù)載和互動(dòng)行為,并且我們屢屢發(fā)現(xiàn),Speedometer 的成績(jī)提升反映在了我們的現(xiàn)實(shí)世界指標(biāo)中。
使用 Sparkplug,Speedometer 得分提高 5-10%,具體取決于我們觀察的 bot。

使用 Sparkplug 在多個(gè)性能 bot 中改善了 Speedometer 的得分中位數(shù)。誤差線表示四分位間距。
Speedometer 是一個(gè)很好的基準(zhǔn)測(cè)試,但它只反映了部分情況。此外,我們還有一組“瀏覽基準(zhǔn)測(cè)試”,它們記錄了一組真實(shí)的網(wǎng)站,我們可以重播這些內(nèi)容、編寫一些交互腳本,并更真實(shí)地了解我們的各種指標(biāo)在現(xiàn)實(shí)世界中的表現(xiàn)。
在這些基準(zhǔn)測(cè)試上,我們選擇查看“V8 主線程時(shí)間”指標(biāo),其測(cè)試主線程(不包括流解析或后臺(tái)優(yōu)化的編譯)在 V8 中花費(fèi)的總時(shí)間(包括編譯和執(zhí)行)。這是在不排除其他基準(zhǔn)噪聲源的情況下查看 Sparkplug 自身回報(bào)的最佳方法。
結(jié)果各不相同,并且完全取決于機(jī)器和網(wǎng)站,但總體而言它們看起來不錯(cuò):我們看到大約有 5-15%的改進(jìn)。

在我們的瀏覽基準(zhǔn)測(cè)試中,V8 主線程時(shí)間得到了 10 個(gè)百分點(diǎn)的中位數(shù)改進(jìn)。誤差線表示四分位間距。
結(jié)論:V8 有了全新的超快速非優(yōu)化編譯器,可將 V8 在實(shí)際基準(zhǔn)測(cè)試中的性能提高 5-15%。V8 v9.1 中已經(jīng)在 --sparkplug 標(biāo)志后面提供了這一工具,并且隨著 M91 的發(fā)布,我們將在 Chrome 中推出該編譯器。
