本文最初發表于 v8.dev(Faster JAVAScript calls),基于 CC 3.0 協議分享,由 InfoQ 翻譯并發布。
JavaScript 允許使用與預期形式參數數量不同的實際參數來調用一個函數,也就是傳遞的實參可以少于或者多于聲明的形參數量。前者稱為申請不足(under-Application),后者稱為申請過度(over-application)。
在申請不足的情況下,剩余形式參數會被分配 undefined 值。在申請過度的情況下,可以使用 rest 參數和 arguments 屬性訪問剩余實參,或者如果它們是多余的可以直接忽略。如今,許多 Web/Node.js 框架都使用這個 JS 特性來接受可選形參,并創建更靈活的 API。
直到最近,V8 都有一種專門的機制來處理參數大小不匹配的情況:這種機制叫做參數適配器框架。不幸的是,參數適配是有性能成本的,但在現代的前端和中間件框架中這種成本往往是必須的。但事實證明,我們可以通過一個巧妙的技巧來拿掉這個多余的框架,簡化 V8 代碼庫并消除幾乎所有的開銷。
我們可以通過一個微型基準測試來計算移除參數適配器框架可以獲得的性能收益。
console.time();
function f(x, y, z) {}
for (let i = 0; i < N; i++) {
f(1, 2, 3, 4, 5);
}
console.timeEnd();
移除參數適配器框架的性能收益,通過一個微基準測試來得出。
上圖顯示,在無 JIT 模式(Ignition)下運行時,開銷消失,并且性能提高了 11.2%。使用 TurboFan 時,我們的速度提高了 40%。
這個微基準測試自然是為了最大程度地展現參數適配器框架的影響而設計的。但是,我們也在許多基準測試中看到了顯著的改進,例如我們內部的 JSTests/Array 基準測試(7%)和 Octane2(Richards 子項為 4.6%,EarleyBoyer 為 6.1%)。
太長不看版:反轉參數
這個項目的重點是移除參數適配器框架,這個框架在訪問棧中被調用者的參數時為其提供了一個一致的接口。為此,我們需要反轉棧中的參數,并在被調用者框架中添加一個包含實際參數計數的新插槽。下圖顯示了更改前后的典型框架示例。
移除參數適配器框架之前和之后的典型 JavaScript ??蚣?。
加快 JavaScript 調用
為了講清楚我們如何加快調用,首先我們來看看 V8 如何執行一個調用,以及參數適配器框架如何工作。
當我們在 JS 中調用一個函數調用時,V8 內部會發生什么呢?用以下 JS 腳本為例:
function add42(x) {
return x + 42;
}
add42(3);
在函數調用期間 V8 內部的執行流程。
Ignition
V8 是一個多層 VM。它的第一層稱為 Ignition,是一個具有累加器寄存器的字節碼棧機。V8 首先會將代碼編譯為 Ignition 字節碼。上面的調用被編譯為以下內容:
0d LdaUndefined ;; Load undefined into the accumulator
26 f9 Star r2 ;; Store it in register r2
13 01 00 LdaGlobal [1] ;; Load global pointed by const 1 (add42)
26 fa Star r1 ;; Store it in register r1
0c 03 LdaSmi [3] ;; Load small integer 3 into the accumulator
26 f8 Star r3 ;; Store it in register r3
5f fa f9 02 CallNoFeedback r1, r2-r3 ;; Invoke call
調用的第一個參數通常稱為接收器(receiver)。接收器是 JSFunction 中的 this 對象,并且每個 JS 函數調用都必須有一個 this。CallNoFeedback 的字節碼處理器需要使用寄存器列表 r2-r3 中的參數來調用對象 r1。
在深入研究字節碼處理器之前,請先注意寄存器在字節碼中的編碼方式。它們是負的單字節整數:r1 編碼為 fa,r2 編碼為 f9,r3 編碼為 f8。我們可以將任何寄存器 ri 稱為 fb - i,實際上正如我們所見,正確的編碼是- 2 - kFixedFrameHeaderSize - i。寄存器列表使用第一個寄存器和列表的大小來編碼,因此 r2-r3 為 f9 02。
Ignition 中有許多字節碼調用處理器。可以在此處查看它們的列表。它們彼此之間略有不同。有些字節碼針對 undefined 的接收器調用、屬性調用、具有固定數量的參數調用或通用調用進行了優化。在這里我們分析 CallNoFeedback,這是一個通用調用,在該調用中我們不會積累執行過程中的反饋。
這個字節碼的處理器非常簡單。它是用 CodeStubAssembler 編寫的,你可以在此處查看。本質上,它會尾調用一個架構依賴的內置 InterpreterPushArgsThenCall。
這個內置方法實際上是將返回地址彈出到一個臨時寄存器中,壓入所有參數(包括接收器),然后壓回該返回地址。此時,我們不知道被調用者是否是可調用對象,也不知道被調用者期望多少個參數,也就是它的形式參數數量。
內置 InterpreterPushArgsThenCall 執行后的框架狀態。
最終,執行會尾調用到內置的 Call。它會在那里檢查目標是否是適當的函數、構造器或任何可調用對象。它還會讀取共享 shared function info 結構以獲得其形式參數計數。
如果被調用者是一個函數對象,它將對內置的 CallFunction 進行尾部調用,并在其中進行一系列檢查,包括是否有 undefined 對象作為接收器。如果我們有一個 undefined 或 null 對象作為接收器,則應根據 ECMA 規范對其修補,以引用全局代理對象。
執行隨后會對內置的 InvokeFunctionCode 進行尾調用。在沒有參數不匹配的情況下,InvokeFunctionCode 只會調用被調用對象中字段 Code 所指向的內容。這可以是一個優化函數,也可以是內置的 InterpreterEntryTrampoline。
如果我們假設要調用的函數尚未優化,則 Ignition trampoline 將設置一個 IntepreterFrame。你可以在此處查看V8 中框架類型的簡短摘要。
接下來發生的事情就不用多談了,我們可以看一個被調用者執行期間的解釋器框架快照。
我們看到框架中有固定數量的插槽:返回地址、前一個框架指針、上下文、我們正在執行的當前函數對象、該函數的字節碼數組以及我們當前正在執行的字節碼偏移量。最后,我們有一個專用于此函數的寄存器列表(你可以將它們視為函數局部變量)。add42 函數實際上沒有任何寄存器,但是調用者具有類似的框架,其中包含 3 個寄存器。
如預期的那樣,add42 是一個簡單的函數:
25 02 Ldar a0 ;; Load the first argument to the accumulator
40 2a 00 AddSmi [42] ;; Add 42 to it
ab Return ;; Return the accumulator
請注意我們在 Ldar(Load Accumulator Register)字節碼中編碼參數的方式:參數 1(a0)用數字 02 編碼。實際上,任何參數的編碼規則都是[ai] = 2 + parameter_count - i - 1,接收器[this] = 2 + parameter_count,或者在本例中[this] = 3。此處的參數計數不包括接收器。
現在我們就能理解為什么用這種方式對寄存器和參數進行編碼。它們只是表示一個框架指針的偏移量。然后,我們可以用相同的方式處理參數/寄存器的加載和存儲??蚣苤羔樀淖詈笠粋€參數偏移量為 2(先前的框架指針和返回地址)。這就解釋了編碼中的 2。解釋器框架的固定部分是 6 個插槽(4 個來自框架指針),因此寄存器零位于偏移量-5 處,也就是 fb,寄存器 1 位于 fa 處。很聰明是吧?
但請注意,為了能夠訪問參數,該函數必須知道棧中有多少個參數!無論有多少參數,索引 2 都指向最后一個參數!
Return 的字節碼處理器將調用內置的 LeaveInterpreterFrame 來完成。該內置函數本質上是從框架中讀取函數對象以獲取參數計數,彈出當前框架,恢復框架指針,將返回地址保存在一個暫存器中,根據參數計數彈出參數并跳轉到暫存器中的地址。
這套流程很棒!但是,當我們調用一個實參數量少于或多于其形參數量的函數時,會發生什么呢?這個聰明的參數/寄存器訪問流程將失敗,我們該如何在調用結束時清理參數?
參數適配器框架
現在,我們使用更少或更多的實參來調用 add42:
add42();
add42(1, 2, 3);
JS 開發人員會知道,在第一種情況下,x 將被分配 undefined,并且該函數將返回 undefined + 42 = NaN。在第二種情況下,x 將被分配 1,函數將返回 43,其余參數將被忽略。請注意,調用者不知道是否會發生這種情況。即使調用者檢查了參數計數,被調用者也可以使用 rest 參數或 arguments 對象訪問其他所有參數。實際上,在 sloppy 模式下甚至可以在 add42 外部訪問 arguments 對象。
如果我們執行與之前相同的步驟,則將首先調用內置的 InterpreterPushArgsThenCall。它將像這樣將參數推入棧:
內置 InterpreterPushArgsThenCall 執行后的框架狀態。
繼續與以前相同的過程,我們檢查被調用者是否為函數對象,獲取其參數計數,并將接收器補到全局代理。最終,我們到達了 InvokeFunctionCode。
在這里我們不會跳轉到被調用者對象中的 Code。我們檢查參數大小和參數計數之間是否存在不匹配,然后跳轉到 ArgumentsAdaptorTrampoline。
在這個內置組件中,我們構建了一個額外的框架,也就是臭名昭著的參數適配器框架。這里我不會解釋內置組件內部發生了什么,只會向你展示內置組件調用被調用者的 Code 之前的框架狀態。請注意,這是一個正確的 x64 call(不是 jmp),在被調用者執行之后,我們將返回到 ArgumentsAdaptorTrampoline。這與進行尾調用的 InvokeFunctionCode 正好相反。
我們創建了另一個框架,該框架復制了所有必需的參數,以便在被調用者框架頂部精確地包含參數的形參計數。它創建了一個被調用者函數的接口,因此后者無需知道參數數量。被調用者將始終能夠使用與以前相同的計算結果來訪問其參數,即[ai] = 2 + parameter_count - i - 1。
V8 具有一些特殊的內置函數,它們在需要通過 rest 參數或 arguments 對象訪問其余參數時能夠理解適配器框架。它們始終需要檢查被調用者框架頂部的適配器框架類型,然后采取相應措施。
如你所見,我們解決了參數/寄存器訪問問題,但是卻添加了很多復雜性。需要訪問所有參數的內置組件都需要了解并檢查適配器框架的存在。不僅如此,我們還需要注意不要訪問過時的舊數據。考慮對 add42 的以下更改:
function add42(x) {
x += 42;
return x;
}
現在,字節碼數組為:
25 02 Ldar a0 ;; Load the first argument to the accumulator
40 2a 00 AddSmi [42] ;; Add 42 to it
26 02 Star a0 ;; Store accumulator in the first argument slot
ab Return ;; Return the accumulator
如你所見,我們現在修改 a0。因此,在調用 add42(1, 2, 3)的情況下,參數適配器框架中的插槽將被修改,但調用者框架仍將包含數字 1。我們需要注意,參數對象正在訪問修改后的值,而不是舊值。
從函數返回很簡單,只是會很慢。還記得 LeaveInterpreterFrame 做什么嗎?它基本上會彈出被調用者框架和參數,直到到達最大形參計數為止。因此,當我們返回參數適配器存根時,棧如下所示:
被調用者 add42 執行之后的框架狀態。
我們需要彈出參數數量,彈出適配器框架,根據實際參數計數彈出所有參數,然后返回到調用者執行。
簡單總結:參數適配器機制不僅復雜,而且成本很高。
移除參數適配器框架
我們可以做得更好嗎?我們可以移除適配器框架嗎?事實證明我們確實可以。
我們回顧一下之前的需求:
- 我們需要能夠像以前一樣無縫訪問參數和寄存器。訪問它們時無法進行檢查。那成本太高了。
- 我們需要能夠從棧中構造 rest 參數和 arguments 對象。
- 從一個調用返回時,我們需要能夠輕松清理未知數量的參數。
- 此外,當然我們希望沒有額外的框架!
如果要消除多余的框架,則需要確定將參數放在何處:在被調用者框架中還是在調用者框架中。
被調用者框架中的參數
假設我們將參數放在被調用者框架中。這似乎是一個好主意,因為無論何時彈出框架,我們都會一次彈出所有參數!
參數必須位于保存的框架指針和框架末尾之間的某個位置。這就要求框架的大小不會被靜態地知曉。訪問參數仍然很容易,它就是一個來自框架指針的簡單偏移量。但現在訪問寄存器要復雜得多,因為它會根據參數的數量而變化。
棧指針總是指向最后一個寄存器,然后我們可以使用它來訪問寄存器而無需知道參數計數。這種方法可能行得通,但它有一個關鍵缺陷。它需要復制所有可以訪問寄存器和參數的字節碼。我們將需要 LdaArgument 和 LdaRegister,而不是簡單的 Ldar。當然,我們還可以檢查我們是否正在訪問一個參數或寄存器(正或負偏移量),但這將需要檢查每個參數和寄存器訪問。顯然這種方法太昂貴了!
調用者框架中的參數
好的,如果我們在調用者框架中放參數呢?
記住如何計算一個框架中參數 i 的偏移量:[ai] = 2 + parameter_count - i - 1。如果我們擁有所有參數(不僅是形式參數),則偏移量將為[ai] = 2 + parameter_count - i - 1.也就是說,對于每個參數訪問,我們都需要加載實際的參數計數。
但如果我們反轉參數會發生什么呢?現在可以簡單地將偏移量計算為[ai] = 2 + i。我們不需要知道棧中有多少個參數,但如果我們可以保證棧中至少有形參計數那么多的參數,那么我們就能一直使用這種方案來計算偏移量。
換句話說,壓入棧的參數數量將始終是參數數量和形參數量之間的最大值,并且在需要時使用 undefined 對象進行填充。
這還有另一個好處!對于任何 JS 函數,接收器始終位于相同的偏移量處,就在返回地址的正上方:[this] = 2。
對于我們的第 1 和第 4 條要求,這是一個干凈的解決方案。另外兩個要求又如何呢?我們如何構造 rest 參數和 arguments 對象?返回調用者時如何清理棧中的參數?為此,我們缺少的只是參數計數而已。我們需要將其保存在某個地方。只要可以輕松訪問此信息即可,具體怎么做沒那么多限制。兩種基本選項分別是:將其推送到調用者框架中的接收者之后,或被調用者框架中的固定標頭部分。我們實現了后者,因為它合并了 Interpreter 和 Optimized 框架的固定標頭部分。
如果在 V8 v8.9 中運行前面的示例,則在 InterpreterArgsThenPush 之后將看到以下棧(請注意,現在參數已反轉):
內置 InterpreterPushArgsThenCall 執行后的框架狀態。
所有執行都遵循類似的路徑,直到到達 InvokeFunctionCode。在這里,我們在申請不足的情況下處理參數,根據需要推送盡可能多的 undefined 對象。請注意,在申請過度的情況下,我們不會進行任何更改。最后,我們通過一個寄存器將參數數量傳遞給被調用者的 Code。在 x64 的情況下,我們使用寄存器 rax。
如果被調用者尚未進行優化,我們將到達 InterpreterEntryTrampoline,它會構建以下??蚣堋?/p>
沒有參數適配器的??蚣?。
被調用者框架有一個額外的插槽,其中包含的參數計數可用于構造 rest 參數或 arguments 對象,并在返回到調用者之前清除棧中參數。
返回時,我們修改 LeaveInterpreterFrame 以讀取棧中的參數計數,并彈出參數計數和形式參數計數之間的較大數字。
TurboFan
那么代碼優化呢?我們來稍微更改一下初始腳本,以強制 V8 使用 TurboFan 對其進行編譯:
function add42(x) { return x + 42; }
function callAdd42() { add42(3); }
%PrepareFunctionForOptimization(callAdd42);
callAdd42();
%OptimizeFunctionOnNextCall(callAdd42);
callAdd42();
在這里,我們使用 V8 內部函數來強制 V8 優化調用,否則 V8 僅在我們的小函數變熱(經常使用)時才對其進行優化。我們在優化之前調用它一次,以收集一些可用于指導編譯的類型信息。在此處閱讀有關 TurboFan 的更多信息(https://v8.dev/docs/turbofan)。
這里,我只展示與主題相關的部分生成代碼。
movq rdi,0x1a8e082126ad ;; Load the function object <JSFunction add42>
push 0x6 ;; Push SMI 3 as argument
movq rcx,0x1a8e082030d1 ;; <JSGlobal Object>
push rcx ;; Push receiver (the global proxy object)
movl rax,0x1 ;; Save the arguments count in rax
movl rcx,[rdi+0x17] ;; Load function object {Code} field in rcx
call rcx ;; Finally, call the code object!
盡管這段代碼使用了匯編來編寫,但如果你仔細看我的注釋應該很容易能懂。本質上,在編譯調用時,TF 需要完成之前在 InterpreterPushArgsThenCall、Call、CallFunction 和 InvokeFunctionCall 內置組件中完成的所有工作。它應該會有更多的靜態信息來執行此操作并發出更少的計算機指令。
帶參數適配器框架的 TurboFan
現在,讓我們來看看參數數量和參數計數不匹配的情況??紤]調用 add42(1, 2, 3)。它會編譯為:
movq rdi,0x4250820fff1 ;; Load the function object <JSFunction add42>
;; Push receiver and arguments SMIs 1, 2 and 3
movq rcx,0x42508080dd5 ;; <JSGlobal Object>
push rcx
push 0x2
push 0x4
push 0x6
movl rax,0x3 ;; Save the arguments count in rax
movl rbx,0x1 ;; Save the formal parameters count in rbx
movq r10,0x564ed7fdf840 ;; <ArgumentsAdaptorTrampoline>
call r10 ;; Call the ArgumentsAdaptorTrampoline
如你所見,不難為 TF 添加對參數和參數計數不匹配的支持。只需調用參數適配器 trampoline 即可!
然而這種方法成本很高。對于每個優化的調用,我們現在都需要進入參數適配器 trampoline,并像未優化的代碼一樣處理框架。這就解釋了為什么在優化的代碼中移除適配器框架的性能收益比在 Ignition 上大得多。
但是,生成的代碼非常簡單。從中返回非常容易(結尾):
movq rsp,rbp ;; Clean callee frame
pop rbp
ret 0x8 ;; Pops a single argument (the receiver)
我們彈出框架并根據參數計數發出一個返回指令。如果實參計數和形參計數不匹配,則適配器框架 trampoline 將對其進行處理。
沒有參數適配器框架的 TurboFan
生成的代碼本質上與參數計數匹配的調用代碼相同??紤]調用 add42(1, 2, 3)。這將生成:
movq rdi,0x35ac082126ad ;; Load the function object <JSFunction add42>
;; Push receiver and arguments 1, 2 and 3 (reversed)
push 0x6
push 0x4
push 0x2
movq rcx,0x35ac082030d1 ;; <JSGlobal Object>
push rcx
movl rax,0x3 ;; Save the arguments count in rax
movl rcx,[rdi+0x17] ;; Load function object {Code} field in rcx
call rcx ;; Finally, call the code object!
該函數的結尾如何?我們不再回到參數適配器 trampoline 了,因此結尾確實比以前復雜了一些。
movq rcx,[rbp-0x18] ;; Load the argument count (from callee frame) to rcx
movq rsp,rbp ;; Pop out callee frame
pop rbp
cmpq rcx,0x0 ;; Compare arguments count with formal parameter count
jg 0x35ac000840c6 <+0x86>
;; If arguments count is smaller (or equal) than the formal parameter count:
ret 0x8 ;; Return as usual (parameter count is statically known)
;; If we have more arguments in the stack than formal parameters:
pop r10 ;; Save the return address
leaq rsp,[rsp+rcx*8+0x8] ;; Pop all arguments according to rcx
push r10 ;; Recover the return address
retl
小結
參數適配器框架是一個臨時解決方案,用于實際參數和形式參數計數不匹配的調用。這是一個簡單的解決方案,但它帶來了很高的性能成本,并增加了代碼庫的復雜性。如今,許多 Web 框架使用這一特性來創建更靈活的 API,結果帶來了更高的性能成本。反轉棧中參數這個簡單的想法可以大大降低實現復雜性,并消除了此類調用的幾乎所有開銷。
原文鏈接:
https://v8.dev/blog/adaptor-frame