hyengine-面向移動端的高效能通用編譯/解釋引擎

一  背景簡介

手機淘寶客戶端在歷史上接過多種多樣的指令碼引擎,用於支援的語言包括:js/python/wasm/lua,其中js引擎接過的就有:javascriptcore/duktape/v8/quickjs 等多個。眾多的引擎會面臨共同面臨包大小及效能相關的問題,我們是否可以提供一套方案,在能支援業務需求的前提下,用一個引擎來支援儘可能多的語言,能較好的兼顧包大小較小和效能優異。為了解決這個問題,我們開始了 hyengine 的探索。

二  設計簡介

"有hyengine就夠全家用了" – hyengine是為統一移動技術所需的各種指令碼語言(wasm/js/python 等)執行引擎而生,以輕量級、高效能、多語言支援為設計和研發目標。目前已透過對 wasm3/quickjs 的 jit 編譯及 runtime 最佳化,以極小包體積的代價實現了 wasm/js 執行速度 2~3 倍的提升,未來將透過實現自有位元組碼和 runtime 增加對 python 及其他語言的支援。

注:由於當前手機絕大多數都已支援 arm64,hyengine 僅支援 arm64 的 jit 實現。
注:由於 ios 不支援 jit,目前 hyengine 只有 android 版本。
hyengine 整體分為兩大塊,編譯(compiler)部分及引擎(vm)部分。

compiler 部分分為前端、中端、後端,其中前端部分複用現有指令碼引擎的實現,比如 js 使用 quickjs,wasm 使用 emscripten,中端計劃實現一套自己的位元組碼、最佳化器及位元組碼轉換器,後端實現了 quickjs 和 wasm 的 jit 及彙編器和最佳化器。

vm 分為直譯器、runtime、api、除錯、基礎庫,由於人力有限,目前VM暫無完整的自有實現,複用quickjs/wasm3 的程式碼,透過實現一套自己的內分配器及gc,和最佳化現有runtime實現來提升效能。
業務程式碼(以wasm為例)透過下圖所示的流程,被編譯為可執行程式碼:

c/c++ 程式碼經過 emscripten 編譯變為 wasm 檔案,wasm 經過 hyengine(wasm3) 載入並編譯為 arm64 指令,arm64 指令經過 optimizer 最佳化產出最佳化後的 arm64 指令,業務方透過呼叫入口 api 來執行對應程式碼。

注:hyengine 本身期望沉澱一套自己的底層(彙編級別)的基礎能力庫,除了用於 jit 相關用途外,還計劃用於手機客戶端的包大小、效能最佳化、除錯輔助等場景。

注:本方案業界的方舟編譯器和 graalvm 可能有一定相似度。

三  實現介紹

1  編譯(compiler)部分

為了讓實現方案較為簡單,hyengine 的編譯採用直接翻譯的方式,直接翻譯出來的程式碼效能一般較慢,需要經過最佳化器的最佳化來提升效能。下面是相關模組的具體實現:

彙編器

為了生成 cpu 能執行的程式碼,我們需要實現一個彙編器,將相關指令碼的 opcode 翻譯成機器碼。

彙編器的核心程式碼基於 golang 的 arch 專案已有的指令資料根據指令碼生成,並輔佐人工修正及對應的工具程式碼。

單個彙編程式碼示例如下:
// Name: ADC// Arch: 32-bit variant// Syntax: ADC <Wd>, <Wn>, <Wm>// Alias: // Bits: 0|0|0|1|1|0|1|0|0|0|0|Rm:5|0|0|0|0|0|0|Rn:5|Rd:5staticinlinevoidADC_W_W_W(uint32_t *buffer, int8_t rd, int8_t rn, int8_t rm){uint32_t code = 0b00011010000000000000000000000000; code |= IMM5(rm) << 16; code |= IMM5(rn) << 5; code |= IMM5(rd); *buffer = code;}
程式碼的作用是彙編ADC <Wd>, <Wn>, <Wm>指令,第一個引數是存放機器碼的 buffer ,後三個引數分別為彙編指令的運算元Wd/Wn/Wm。程式碼中第7行的 code 為機器碼的固定部分,第 8~10 行為將運算元對應的暫存器編號放入機器碼對應的位置(詳見註釋種的 Bits 部分),第 9 行為將機器碼放入 buffer 。其中IMM5表示取數值的低 5 位,因為暫存器是一個 5bits 長的數字。這樣命名的好處是,可以直觀的將彙編器的方法名和其產生的機器碼的助記詞形式相關聯。

其中
IMM5實現如下:
#define IMM5(v) (v & 0b11111)
為了保證彙編方法的正確性,我們基於 golang 的 arch 專案中的gnucases.txt,採取機器生成 + 人工修正的方式,產出瞭如下格式的單測用例:
// 0a011f1a| adc w10, w8, wzr ADC_W_W_W(&buffer, R10, R8, RZR);assert(buffer == bswap32(0x0a011f1a));

第一行註釋中前半部分為機器碼的大端表示,後半部分為機器碼對應的彙編程式碼。第二行為彙編器的彙編方法呼叫。第三行為彙編結果檢查,確認結果和註釋中的機器碼一致,由於註釋中的機器碼為大端表示,需要做 byte swap 才和彙編結果匹配。

反彙編器

這裡的反彙編器不包含完整的反彙編功能,目的是為了用於在最佳化器中識別機器碼,取機器碼中的引數使用。簡單舉例:
#define IS_MOV_X_X(ins) \ (IMM11(ins >> 21) == IMM11(HY_INS_TEMPLATE_MOV_X_X >> 21) && \ IMM11(ins >> 5) == IMM11(HY_INS_TEMPLATE_MOV_X_X >> 5))
這條指令就可以在最佳化器中判斷某條指令是不是mov xd, xm,進而可以透過如下程式碼取出 xd 中 d 的具體數值:
#define RM(ins) IMM5(ins >> 16)#define RN(ins) IMM5(ins >> 5)#define RD(ins) IMM5(ins)
同樣的,我們為反彙編器也做了對應的單測:
// e7031eaa| mov x7, x30assert(IS_MOV_X_X(bswap32(0xe7031eaa)));

wasm編譯

編譯時我們會遍歷 wasm 模組的每個方法,估算存放產物程式碼所需的記憶體空間,然後將方法中的位元組碼翻譯為機器碼。

其中核心的翻譯的整體實現是一個大的迴圈 + switch,每遍歷一個 opcode 即生成一段對應的機器碼,程式碼示例如下:
M3Result h3_JITFunction(IH3JITState state, IH3JITModule hmodule, IH3JITFunction hfunction) {uint32_t *alloc = state->code + state->codeOffset; ......// prologue// stp x28, x27, [sp, #-0x60]!// stp x26, x25, [sp, #0x10]!// stp x24, x23, [sp, #0x20]// stp x22, x21, [sp, #0x30]// stp x20, x19, [sp, #0x40]// stp x29, x30, [sp, #0x50]// add x20, sp, #0x50 STP_X_X_X_I_PR(alloc + codeOffset++, R28, R27, RSP, -0x60); STP_X_X_X_I(alloc + codeOffset++, R26, R25, RSP, 0x10); STP_X_X_X_I(alloc + codeOffset++, R24, R23, RSP, 0x20); STP_X_X_X_I(alloc + codeOffset++, R22, R21, RSP, 0x30); STP_X_X_X_I(alloc + codeOffset++, R20, R19, RSP, 0x40); STP_X_X_X_I(alloc + codeOffset++, R29, R30, RSP, 0x50); ADD_X_X_I(alloc + codeOffset++, R29, RSP, 0x50); ......for (bytes_t i = wasm; i < wasmEnd; i += opcodeSize) {uint32_t index = (uint32_t)(i - wasm) / sizeof(u8);uint8_t opcode = *i; ......switch (opcode) {case OP_UNREACHABLE: { BRK_I(alloc + codeOffset++, 0);break; }case OP_NOP: { NOP(alloc + codeOffset++);break; } ......case OP_REF_NULL:case OP_REF_IS_NULL:case OP_REF_FUNC:default:break; }if (spOffset > maxSpOffset) { maxSpOffset = spOffset; } } ......// return 0(m3Err_none) MOV_X_I(alloc + codeOffset++, R0, 0);// epilogue// ldp x29, x30, [sp, #0x50]// ldp x20, x19, [sp, #0x40]// ldp x22, x21, [sp, #0x30]// ldp x24, x23, [sp, #0x20]// ldp x26, x25, [sp, #0x10]// ldp x28, x27, [sp], #0x60// ret LDP_X_X_X_I(alloc + codeOffset++, R29, R30, RSP, 0x50); LDP_X_X_X_I(alloc + codeOffset++, R20, R19, RSP, 0x40); LDP_X_X_X_I(alloc + codeOffset++, R22, R21, RSP, 0x30); LDP_X_X_X_I(alloc + codeOffset++, R24, R23, RSP, 0x20); LDP_X_X_X_I(alloc + codeOffset++, R26, R25, RSP, 0x10); LDP_X_X_X_I_PO(alloc + codeOffset++, R28, R27, RSP, 0x60); RET(alloc + codeOffset++); ......return m3Err_none;}
上述程式碼會先生成方法的 prologue,然後 for 迴圈遍歷 wasm 位元組碼,生產對應的 arm64 機器碼,最後加上方法的 epilogue。

位元組碼生成機器碼以 wasm 的 opcode
i32.add 為例:
case OP_I32_ADD: { LDR_X_X_I(alloc + codeOffset++, R8, R19, (spOffset - 2) * sizeof(void *)); LDR_X_X_I(alloc + codeOffset++, R9, R19, (spOffset - 1) * sizeof(void *)); ADD_W_W_W(alloc + codeOffset++, R9, R8, R9); STR_X_X_I(alloc + codeOffset++, R9, R19, (spOffset - 2) * sizeof(void *)); spOffset--;break;}
程式碼中的alloc是當前正在編譯的方法的機器碼存放首地址,codeOffset是當前機器碼相對於首地址的偏移,R8/R9代表我們約定的兩個臨時暫存器,R19存放的棧底地址,spOffset是執行到當前 opcode 時棧相對於棧底的偏移。

這段程式碼會生成 4 條機器碼,分別用於載入位於棧上
spOffset – 2spOffset – 1位置的兩條資料,然後相加,再把結果存放到棧上spOffset – 2位置。由於 i32.add 指令會消耗 2 條棧上資料,並生成 1 條棧上資料,最終棧的偏移就要 -1。

上述程式碼生成的機器碼及其對應助記形式如下:
f9400a68: ldr x8, [x19, #0x10]f9400e69: ldr x9, [x19, #0x18]0b090109: add w9, w8, w9f9000a69: str x9, [x19, #0x10]
x表示64位暫存器,w表示 64 位暫存器的低 32 位,由於 i32.add 指令是做 32 位加法,這裡只需要加低 32 位即可。

以如下 fibonacci 的 c 程式碼:
uint32_t fib_native(uint32_t n) {if (n < 2) return n;return fib_native(n - 1) + fib_native(n - 2);}
編譯產生的 wasm 程式碼:
parse | load module: 61 bytes parse | found magic + version parse | ** Type [1] parse | type 0: (i32) -> i32 parse | ** Function [1] parse | ** Export [1] parse | index: 0; kind: 0; export: 'fib'; parse | ** Code [1] parse | code size: 29 compile |compiling:'fib'; wasm-size:29; numArgs:1; return: i32 compile | estimated constant slots: 3 compile | start stack index:1 compile | 0 |0x20 .. local.get compile | 1 |0x41 .. i32.const compile | | .......... (const i32 = 2) compile | 2 |0x49 .. i32.lt_u compile | 3 |0x04 .. if compile | 4 |0x20 .... local.get compile | 5 |0x0f .... return compile | 6 |0x0b .. end compile | 7 |0x20 .. local.get compile | 8 |0x41 .. i32.const compile | | .......... (const i32 = 2) compile | 9 |0x6b .. i32.sub compile | 10 |0x10 .. call compile | | .......... (func= 'fib'; args= 1) compile | 11 |0x20 .. local.get compile | 12 |0x41 .. i32.const compile | | .......... (const i32 = 1) compile | 13 |0x6b .. i32.sub compile | 14 |0x10 .. call compile | | .......... (func= 'fib'; args= 1) compile | 15 |0x6a .. i32.add compile | 16 |0x0f .. return compile | 17 |0x0bend compile | unique constant slots: 2; unused slots: 1 compile | max stack slots:7
經過 hyengine jit 編譯的產出程式碼如下:
0x107384000: stp x28, x27, [sp, #-0x60]!0x107384004: stp x26, x25, [sp, #0x10]0x107384008: stp x24, x23, [sp, #0x20]0x10738400c: stp x22, x21, [sp, #0x30]0x107384010: stp x20, x19, [sp, #0x40]0x107384014: stp x29, x30, [sp, #0x50]0x107384018: add x29, sp, #0x50 ; =0x50 0x10738401c: mov x19, x00x107384020: ldr x9, [x19]0x107384024: str x9, [x19, #0x8]0x107384028: mov w9, #0x20x10738402c: str x9, [x19, #0x10]0x107384030: mov x9, #0x10x107384034: ldr x10, [x19, #0x8]0x107384038: ldr x11, [x19, #0x10]0x10738403c: cmp w10, w110x107384040: csel x9, x9, xzr, lo0x107384044: str x9, [x19, #0x8]0x107384048: ldr x9, [x19, #0x8]0x10738404c: cmp x9, #0x0 ; =0x0 0x107384050: b.eq 0x1073840680x107384054: ldr x9, [x19]0x107384058: str x9, [x19, #0x8]0x10738405c: ldr x9, [x19, #0x8]0x107384060: str x9, [x19]0x107384064: b 0x1073840dc0x107384068: ldr x9, [x19]0x10738406c: str x9, [x19, #0x10]0x107384070: mov w9, #0x20x107384074: str x9, [x19, #0x18]0x107384078: ldr x8, [x19, #0x10]0x10738407c: ldr x9, [x19, #0x18]0x107384080: sub w9, w8, w90x107384084: str x9, [x19, #0x10]0x107384088: add x0, x19, #0x10 ; =0x10 0x10738408c: bl 0x10738408c0x107384090: ldr x9, [x19]0x107384094: str x9, [x19, #0x18]0x107384098: mov w9, #0x10x10738409c: str x9, [x19, #0x20]0x1073840a0: ldr x8, [x19, #0x18]0x1073840a4: ldr x9, [x19, #0x20]0x1073840a8: sub w9, w8, w90x1073840ac: str x9, [x19, #0x18]0x1073840b0: add x0, x19, #0x18 ; =0x18 0x1073840b4: bl 0x1073840b40x1073840b8: ldr x8, [x19, #0x10]0x1073840bc: ldr x9, [x19, #0x18]0x1073840c0: add w9, w8, w90x1073840c4: str x9, [x19, #0x10]0x1073840c8: ldr x9, [x19, #0x10]0x1073840cc: str x9, [x19]0x1073840d0: b 0x1073840dc0x1073840d4: ldr x9, [x19, #0x10]0x1073840d8: str x9, [x19]0x1073840dc: mov x0, #0x00x1073840e0: ldp x29, x30, [sp, #0x50]0x1073840e4: ldp x20, x19, [sp, #0x40]0x1073840e8: ldp x22, x21, [sp, #0x30]0x1073840ec: ldp x24, x23, [sp, #0x20]0x1073840f0: ldp x26, x25, [sp, #0x10]0x1073840f4: ldp x28, x27, [sp], #0x600x1073840f8: ret
這段程式碼執行fib_native(40)耗時 1716ms,而 wasm3 解釋執行 wasm 運行同樣程式碼耗時 3637ms,耗時只有解釋執行的約 47%,但這夠快嗎?

最佳化器

上面的程式碼看起來似乎感覺沒什麼大毛病,但是和 llvm 編譯出來的 native 程式碼一比較,差距就出來的了。fib_native的 c 程式碼經過 llvm 編譯的反彙編程式碼如下:
0x10268cfb8 <+0>: stp x20, x19, [sp, #-0x20]!0x10268cfbc <+4>: stp x29, x30, [sp, #0x10]0x10268cfc0 <+8>: add x29, sp, #0x100x10268cfc4 <+12>: mov x19, x00x10268cfc8 <+16>: cmp w0, #0x20x10268cfcc <+20>: b.hs 0x10268cfd8 0x10268cfd0 <+24>: mov w20, #0x00x10268cfd4 <+28>: b 0x10268cff4 0x10268cfd8 <+32>: mov w20, #0x00x10268cfdc <+36>: sub w0, w19, #0x10x10268cfe0 <+40>: bl 0x10268cfb8 0x10268cfe4 <+44>: sub w19, w19, #0x20x10268cfe8 <+48>: add w20, w0, w200x10268cfec <+52>: cmp w19, #0x10x10268cff0 <+56>: b.hi 0x10268cfdc 0x10268cff4 <+60>: add w0, w19, w200x10268cff8 <+64>: ldp x29, x30, [sp, #0x10]0x10268cffc <+68>: ldp x20, x19, [sp], #0x200x10268d000 <+72>: ret
hyengine 產出的指令有 63 條,而 llvm 產出的指令只有 17 條,指令數量是 llvm 的約 3.7 倍!而實際執行效能差距更大,hyengine 產出的程式碼執行fib_native(40)耗時 1716ms,llvm 產出的程式碼耗時 308ms,耗時是 llvm 的約 5.57 倍。

為了縮小差距,是時候做一些優化了。

1)最佳化器的主要流程

最佳化器的主要流程如下:
先將整個方法體的程式碼按照跳轉指令(如:b/cbz 等)及其跳轉目標地址做拆分,將方法體拆為多個程式碼塊。然後對每個塊跑一下最佳化的 pass ,對塊內程式碼進行最佳化。最後將最佳化後的指令塊重新合併為一個方法體,最佳化完成。

需要把方法體拆分為塊的原因之一在於,最佳化器可能會刪除或者增加程式碼,這樣跳轉指令的跳轉目標地址就會發生改變,需要重新計算跳轉目標,拆成塊後跳轉目標比較容易計算。

在塊拆分及最佳化 pass 的實現中,會用到前面提到反彙編器和彙編器,這也是整個最佳化器的核心依賴。

我們以前文中的程式碼的一部分為例子,做最佳化流程的介紹,首先是塊拆分:
; --- code block 0 ---0x107384048: ldr x9, [x19, #0x8]0x10738404c: cmp x9, #0x0 ; =0x0 -- 0x107384050: b.eq 0x107384068 ; b.eq 6| | ; --- code block 1 ---| 0x107384054: ldr x9, [x19]|0x107384058: str x9, [x19, #0x8]| 0x10738405c: ldr x9, [x19, #0x8]|0x107384060: str x9, [x19]| 0x107384064: b 0x1073840dc|| ; --- code block 2 --- -> 0x107384068: ldr x9, [x19] 0x10738406c: str x9, [x19, #0x10]
這裡會根據程式碼中的第四行的b.eq指令及其跳轉的目標地址第 14 行作拆分,程式碼為拆為了 3 個塊。原本第 11 行的 b 指令也要做一次拆分,但前面的b.eq

已經拆過了,就不再拆了。

接下對會對拆分成塊後的程式碼跑一堆最佳化的 pass ,跑完後的結果如下:
; --- code block 0 ---0x104934020: cmp w9, #0x2 ; =0x2 -- 0x104934024: b.hs 0x104934038 ; b.hs 5| | ; --- code block 1 ---| 0x104934028: mov x9, x20| 0x10493402c: mov x21, x9| 0x104934030: mov x20, x9| 0x104934034: b 0x104934068|| ; --- code block 2 --- -> 0x104934038: subw22, w20, #0x2; =0x2
在跑完一堆 pass 後代碼完全變了樣(關鍵最佳化的實現請看下一節內容),但可以看出 code block 1 的程式碼從 5 條指令變成了 4 條,之前的b.eq被最佳化為了b.hs

跳轉的目標地址的偏移也少 1,從 6 變為 5。

最後把塊重新合併成為新的方法體指令:
0x104934020: cmp w9, #0x2 ; =0x2 0x104934024: b.hs 0x104934038 ; b.hs 50x104934028: mov x9, x200x10493402c: mov x21, x90x104934030: mov x20, x90x104934034: b 0x1049340680x104934038: sub w22, w20, #0x2 ; =0x2

2)關鍵最佳化之暫存器分配

3.7 倍程式碼量的速度慢 5.57 倍的一個主要原因在於,我們生產的程式碼中資料完全存放在棧中,棧在記憶體上,各種ldr/str指令對記憶體的訪問,就算資料在 cpu 的 l1 cache 上,也比對暫存器的訪問慢 4 倍。為此,如果我們將資料儘量放在暫存器,減少對記憶體的訪問,就可以進一步提升效能。

暫存器分配有一些較為成熟的方案,常用的包括:基於 live range 的線性掃描記憶體分配,基於 live internal 的線性掃描記憶體分配,基於圖染色的記憶體分配等。在常見 jit 實現,會採用基於 live internal 的線性掃描記憶體分配方案,來做到產物效能和暫存器分配程式碼的時間複雜度的平衡。

為了實現的簡單性,hyengine 使用了一種非主流的極簡方案,基於程式碼訪問次數的線性掃描記憶體分配,用人話說就是:給程式碼中出現次數最多的棧偏移分配暫存器。
假設程式碼如下(節選自 hyengine jit 產出程式碼):
0x107384020: ldrx9, [x19] 0x107384024: strx9, [x19, #0x8] 0x107384028: movw9, #0x2 0x10738402c: strx9, [x19, #0x10] 0x107384030: movx9, #0x1 0x107384034: ldrx10, [x19, #0x8] 0x107384038: ldrx11, [x19, #0x10]
對假設程式碼的分配暫存器後代碼如下:
0x107384020: ldr x9, [x19] ; 偏移0沒變0x107384024: mov x20, x9 ; 偏移8變成x200x107384028: mov w9, #0x20x10738402c: mov x21, x9 ; 偏移16變成x210x107384030: mov x9, #0x10x107384034: mov x10, x20 ; 偏移8變成x200x107384038: mov x11, x21 ; 偏移16變成x21
之前的 jit 產物程式碼最佳化後如下(注:做了少量指令融合):
0x102db4000: stp x28, x27, [sp, #-0x60]!0x102db4004: stp x26, x25, [sp, #0x10]0x102db4008: stp x24, x23, [sp, #0x20]0x102db400c: stp x22, x21, [sp, #0x30]0x102db4010: stp x20, x19, [sp, #0x40]0x102db4014: stp x29, x30, [sp, #0x50]0x102db4018: add x29, sp, #0x50 ; =0x500x102db401c: mov x19, x00x102db4020: ldr x9, [x19]0x102db4024: mov x20, x90x102db4028: mov x21, #0x20x102db402c: mov x9, #0x10x102db4030: cmp w20, w210x102db4034: csel x9, x9, xzr, lo0x102db4038: mov x20, x90x102db403c: cmp x9, #0x0 ; =0x00x102db4040: b.eq 0x102db40540x102db4044: ldr x9, [x19]0x102db4048: mov x20, x90x102db404c: str x20, [x19]0x102db4050: b 0x102db40ac0x102db4054: ldr x9, [x19]0x102db4058: mov x21, x90x102db405c: mov x22, #0x20x102db4060: sub w9, w21, w220x102db4064: mov x21, x90x102db4068: add x0, x19, #0x10 ; =0x100x102db406c: str x21, [x19, #0x10]0x102db4070: bl 0x102db40700x102db4074: ldr x21, [x19, #0x10]0x102db4078: ldr x9, [x19]0x102db407c: mov x22, x90x102db4080: mov x23, #0x10x102db4084: sub w9, w22, w230x102db4088: mov x22, x90x102db408c: add x0, x19, #0x18 ; =0x180x102db4090: str x22, [x19, #0x18]0x102db4094: bl 0x102db40940x102db4098: ldr x22, [x19, #0x18]0x102db409c: add w9, w21, w220x102db40a0: mov x21, x90x102db40a4: str x21, [x19]0x102db40a8: nop 0x102db40ac: mov x0, #0x00x102db40b0: ldp x29, x30, [sp, #0x50]0x102db40b4: ldp x20, x19, [sp, #0x40]0x102db40b8: ldp x22, x21, [sp, #0x30]0x102db40bc: ldp x24, x23, [sp, #0x20]0x102db40c0: ldp x26, x25, [sp, #0x10]0x102db40c4: ldp x28, x27, [sp], #0x600x102db40c8: ret
最佳化後的程式碼量從 63 條減少到 51 條,且記憶體訪問數量明顯減少,耗時也減少到 1361ms,耗時減少到 llvm 的約 4.42 倍。

3)關鍵最佳化之暫存器引數傳遞

在暫存器分配最佳化的最後一條中提到,在方法呼叫時需要把暫存器的值拷回棧,額外增加了記憶體訪問的開銷。其相關彙編程式碼為:
0x102db4068: add x0, x19, #0x10 ; =0x100x102db406c: str x21, [x19, #0x10]0x102db4070: bl 0x102db40700x102db4074: ldr x21, [x19, #0x10]

而 arm64 的呼叫約定中,引數傳遞是透過暫存器來做的,這樣每次方法呼叫可以減少兩次記憶體訪問。

這裡把 wasm 的棧作為放入

x0, 第一個引數x22直接放入x1,方法呼叫後的返回值x0直接放入x22,最佳化後代碼如下:

0x1057e405c: add x0, x19, #0x10 ; =0x10 0x1057e4060: mov x1, x220x1057e4064: bl 0x1057e40640x1057e4068: mov x22, x0

注:這裡因為給棧偏移 0 也分配了暫存器,所以暫存器的編號比最佳化前的程式碼多 1 。

同時將方法頭部取引數的程式碼從:

0x102db4020: ldr x9, [x19]0x102db4024: mov x20, x9
最佳化為:
0x1057e4020: mov x20, x1

這裡又減少了一次記憶體訪問和一條指令。

最佳化後最終完整的程式碼如下:

0x1057e4000: stp x28, x27, [sp, #-0x60]!0x1057e4004: stp x26, x25, [sp, #0x10]0x1057e4008: stp x24, x23, [sp, #0x20]0x1057e400c: stp x22, x21, [sp, #0x30]0x1057e4010: stp x20, x19, [sp, #0x40]0x1057e4014: stp x29, x30, [sp, #0x50]0x1057e4018: add x29, sp, #0x50 ; =0x50 0x1057e401c: mov x19, x00x1057e4020: mov x20, x10x1057e4024: mov x21, x200x1057e4028: mov x22, #0x20x1057e402c: mov x9, #0x10x1057e4030: cmp w21, w220x1057e4034: csel x9, x9, xzr, lo0x1057e4038: mov x21, x90x1057e403c: cmp x9, #0x0 ; =0x0 0x1057e4040: b.eq 0x1057e404c0x1057e4044: mov x21, x200x1057e4048: b 0x1057e409c0x1057e404c: mov x22, x200x1057e4050: mov x23, #0x20x1057e4054: sub w9, w22, w230x1057e4058: mov x22, x90x1057e405c: add x0, x19, #0x10 ; =0x10 0x1057e4060: mov x1, x220x1057e4064: bl 0x1057e40640x1057e4068: mov x22, x00x1057e406c: mov x23, x200x1057e4070: mov x24, #0x10x1057e4074: sub w9, w23, w240x1057e4078: mov x23, x90x1057e407c: add x0, x19, #0x18 ; =0x18 0x1057e4080: mov x1, x230x1057e4084: bl 0x1057e40840x1057e4088: mov x23, x00x1057e408c: add w9, w22, w230x1057e4090: mov x22, x90x1057e4094: mov x20, x220x1057e4098: nop 0x1057e409c: mov x0, x200x1057e40a0: ldp x29, x30, [sp, #0x50]0x1057e40a4: ldp x20, x19, [sp, #0x40]0x1057e40a8: ldp x22, x21, [sp, #0x30]0x1057e40ac: ldp x24, x23, [sp, #0x20]0x1057e40b0: ldp x26, x25, [sp, #0x10]0x1057e40b4: ldp x28, x27, [sp], #0x600x1057e40b8: ret
最佳化後的程式碼量從 51 條減少到 47 條,耗時也減少到 687ms,耗時減少到 llvm 的約 2.23 倍。雖然程式碼量只減少了 4 條,但耗時顯著減少了約 50%。

注:這個最佳化僅對方法體比較短且呼叫頻繁的方法有顯著跳過,方法體比較長的程式碼效果不明顯。

4)關鍵最佳化之特徵匹配

特徵匹配就是在程式碼中遍歷預設的程式碼特徵,對符合特徵的程式碼做相應的最佳化。

比如上面程式碼中的:

0x1057e404c: mov x22, x200x1057e4050: mov x23, #0x20x1057e4054: sub w9, w22, w230x1057e4058: mov x22, x9
可以被最佳化為:
0x104934038: subw22, w20, #0x2; =0x2
4 條指令變 1 條。

5)最佳化結果

經過上述多種最佳化後,程式碼變為:
0x104934000: stp x24, x23, [sp, #-0x40]!0x104934004: stp x22, x21, [sp, #0x10]0x104934008: stp x20, x19, [sp, #0x20]0x10493400c: stp x29, x30, [sp, #0x30]0x104934010: add x29, sp, #0x30 ; =0x30 0x104934014: mov x19, x00x104934018: mov x20, x10x10493401c: mov x9, x200x104934020: cmp w9, #0x2 ; =0x2 0x104934024: b.hs 0x1049340380x104934028: mov x9, x200x10493402c: mov x21, x90x104934030: mov x20, x90x104934034: b 0x1049340680x104934038: sub w22, w20, #0x2 ; =0x2 0x10493403c: add x0, x19, #0x10 ; =0x10 0x104934040: mov x1, x220x104934044: bl 0x1049340000x104934048: mov x22, x00x10493404c: sub w23, w20, #0x1 ; =0x1 0x104934050: add x0, x19, #0x18 ; =0x18 0x104934054: mov x1, x230x104934058: bl 0x1049340000x10493405c: add w9, w22, w00x104934060: mov x22, x90x104934064: mov x20, x90x104934068: mov x0, x200x10493406c: ldp x29, x30, [sp, #0x30]0x104934070: ldp x20, x19, [sp, #0x20]0x104934074: ldp x22, x21, [sp, #0x10]0x104934078: ldp x24, x23, [sp], #0x400x10493407c: ret
最佳化後的程式碼量從 63 條減少到 32 條,耗時從 1716ms 減少到 493ms ,耗時減少到 llvm 的約 1.6 倍。

quickjs 編譯

注:js 的主要耗時在 runtime,所以目前 jit 只佔 js 整體效能最佳化的約 20%,後續將引入更多 jit 最佳化細節。

quickjs 的編譯流程和 wasm 類似,只是對 opcode 的實現上會稍微複雜一些,以OP_object為例:
// *sp++ = JS_NewObject(ctx);// if (unlikely(JS_IsException(sp[-1])))// goto exception;case OP_object: { MOV_FUNCTION_ADDRESS_TO_REG(R8, JS_NewObject); MOV_X_X(NEXT_INSTRUCTION, R0, CTX_REG); BLR_X(NEXT_INSTRUCTION, R8); STR_X_X_I(NEXT_INSTRUCTION, R0, R26, SP_OFFSET(0)); CHECK_EXCEPTION(R0, R9);break;}
這裡首先透過MOV_FUNCTION_ADDRESS_TO_REG宏把要呼叫的JS_NewObject方法地址放入R8暫存器:
#define MOV_FUNCTION_ADDRESS_TO_REG(reg, func) \{ \ uintptr_t func##Address = (uintptr_t)func; \MOVZ_X_I_S_I(NEXT_INSTRUCTION, reg, IMM16(func##Address), LSL, 0); \if (IMM16(func##Address >> 16) != 0) { \MOVK_X_I_S_I(NEXT_INSTRUCTION, reg, IMM16(func##Address >> 16), \LSL, 16); \ } else{ \NOP(NEXT_INSTRUCTION); \ } \if (IMM16(func##Address >> 32) != 0) { \MOVK_X_I_S_I(NEXT_INSTRUCTION, reg, IMM16(func##Address >> 32), \LSL, 32); \ } else{ \NOP(NEXT_INSTRUCTION); \ } \if (IMM16(func##Address >> 48) != 0) { \MOVK_X_I_S_I(NEXT_INSTRUCTION, reg, IMM16(func##Address >> 48), \LSL, 48); \ } else{ \NOP(NEXT_INSTRUCTION); \ } \ }
然後將CTX_REG(裡面存的 ctx 地址)放入R0作為第一個引數,並呼叫JS_NewObject,然後結果存入 js 棧的SP_OFFSET(0)位置。然後透過CHECK_EXCEPTION判斷結果是否存在異常:
#define EXCEPTION(tmp) \LDR_X_X_I(NEXT_INSTRUCTION,tmp, CTX_REG, HYJS_BUILTIN_OFFSET(0)); \ MOV_X_I(NEXT_INSTRUCTION, R0, SP_OFFSET(0)); \ BLR_X(NEXT_INSTRUCTION, tmp);#define CHECK_EXCEPTION(reg, tmp) \MOV_X_I(NEXT_INSTRUCTION,tmp, ((uint64_t)JS_TAG_EXCEPTION<<56)); \ CMP_X_X_S_I(NEXT_INSTRUCTION, reg, tmp, LSL, 0); \ B_C_L(NEXT_INSTRUCTION, NE, 4 * sizeof(uint32_t)); \ EXCEPTION(tmp)
就這一個 opcode 生成的 arm64 機器碼就多達 13 條!而且這還不算多的。

同樣是 fibonacci 的實現,wasm 的 jit 產物程式碼只有 32 條,而 quickjs 的有 467 條!!!又想起了被彙編所支配的恐懼。

注:這麼指令源於對 builtin 的呼叫、引用計數、型別判斷。後面 vm 最佳化將引用計數幹掉後代碼量減少到 420 條。

2  引擎(vm)部分

因為 wasm 本身是強型別的位元組碼,runtime 本身提供的能力較少,效能瓶頸也主要在程式碼的解釋執行,所以 vm 部分的基本沒有做最佳化。而 quickjs 的位元組碼作為弱型別的位元組碼,其主要功能需要依賴 runtime 來實現,同時由於語言本身接管了記憶體管理,由此帶來的 gc 也開銷也比較明顯。

在之前對某業務js程式碼的效能分析後發現,超過 50% 的效能開銷在記憶體分配及 gc 上,為此引擎部分將主要介紹對 quickjs 的記憶體分配和 gc 最佳化,部分 runtime 的 builtin 的快路徑、inline cache 目前最佳化佔比不高,僅做少量介紹。

記憶體分配器 hymalloc

為了實現 hyengine 對 quickjs 效能最佳化,同時兼顧 gc 最佳化所需要的對記憶體的管理權,需要設計一套更快速(無鎖,非執行緒安全)的記憶體分配器。同時需要考慮面向其他引擎可能需要的定製,來做到 hymalloc 的儘量通用。

1)實現簡介

hymalloc 將記憶體分為 19 個區(region),18 個 small region/1 個 large region。small region主要用來存放規則記憶體,每個區的大小分從為 116 至 1916 bytes;large region 用於存放大於 9*16 bytes 的記憶體。

每個區可包含多個池(pool),每個池裡面可包含多個目標大小的條目(item)。large region 比較特殊,每個 pool 裡只有 1 個條目。在向系統申請記憶體時,按 pool 來做申請,之後再將 pool 拆分成對應的 item。

每個 small region 初始化有一個池,池的大小可配置,預設為 1024 個 item;large region 預設是空的。

區/塊/池的示意圖如下:

這裡對最關鍵的兩個資料結構做下簡單介紹:
// hymalloc itemstructHYMItem {union { HYMRegion* region; // set to region when allocated HYMItem* next; // set to next free item when freed };size_t flags;uint8_t ptr[0];};// hymalloc poolstructHYMPool { HYMRegion *region; HYMPool *next;size_t item_size;};
其中 HYMItem 是前面提到的 item 的資料結構,這裡的 item 的大小不固定,資料結構本身更像是 item header描述,其中 flags 目前作為 gc 的特別標記存在,ptr 用於取 item 的實際可用部分記憶體的地址(透過&item->ptr獲取)。union 中的 region/next 是一個用來省記憶體的設計,在 item 被分配出去之前,next 的值指向 region 的下一個空閒 item;在 item 被分配出去之後,region

被設定為 item 所屬的 region 地址。

region 的空閒 item 連結串列示意圖如下:

在記憶體分配時,取連結串列的首個 item 作為分配結果,連結串列如果為空,則向系統申請一個新的 pool 並把 pool 的item 放入連結串列,分配示意圖如下:
分配程式碼如下:
static void* _HYMallocFixedSize(HYMRegion *region, size_t size) {// allocate new pool, if no free item existsif (region->free_item_list == NULL) {// notice: large region's item size is 0, use 'size' instead size_t item_size = region->item_size ? region->item_size : size; int ret = _HYMAllocPool(region, region->pool_initial_item_count, item_size);if (!ret) {returnNULL; } }// get free list item head, and set region to item's region HYMItem *item = region->free_item_list; region->free_item_list = item->next; item->region = region; item->flags = 0;return &item->ptr;}
在記憶體釋放時,將 item 插入所屬 region 的空閒連結串列的頭部即可:
voidHYMFree(void *ptr){ HYMItem *item = (HYMItem *)((uint8_t *)ptr - HYM_ITEM_SIZE_OVERHEAD);// set item as head of region's free item list HYMRegion *region = item->region; HYMItem *first_item_in_region = region->free_item_list; region->free_item_list = item; item->next = first_item_in_region;}
上述實現在簡單的記憶體分配/釋放測試 case 中,在 macbook m1 裝置上比系統提供的 malloc/free 快約4倍。

2)記憶體 compact + update

為了減少記憶體佔用,hymalloc 實現了部分記憶體 compact ,可以清理完全未使用的 small region中的 pool 和 large region 的所有 pool。但目前沒有實現 update 功能,無法做到真正的將不同 pool 之間的 item 相互複製,來做到更多記憶體的節省。

但從客戶端的使用場景來看,執行程式碼的記憶體用量本身不高,compact + update 完整組合的實現複雜度較高,價效比不足。後續根據實際業務的使用情況,再評估實現完整 compact + update 的必要性。

3)hymalloc 的侷限性

為了提升分配和釋放效能,hymalloc 的每個 item 都有 header,需要額外佔用記憶體空間,這會導致一定的記憶體浪費。

而且雖然 hymalloc 提供了 compact 方法來釋放空閒的記憶體,但由於按照 pool 來批次申請記憶體,只要 pool 中有一個 item 被使用,那麼這個 pool 就不會被釋放,導致記憶體不能被完全高效的釋放。

另外,考慮到記憶體被複用的機率,large region 的記憶體會預設按 256bytes 對齊來申請,同樣可能存在浪費。

上述問題可以透過設定更小的 pool 的預設 item 數量,及更小的對齊尺寸,犧牲少量效能,來減少記憶體浪費。

後續可以引入更合理的資料結構,以及更完善的 compact + update 機制,來減少記憶體浪費。

垃圾回收器 hygc

quickjs 的原本的gc基於引用計數 + mark sweep,設計和實現本身比較簡潔高效,但未實現分代、多執行緒、compact、閒時 gc、複製 gc,使得 gc 在整體執行耗時中的佔比較高,同時也存在記憶體碎片化帶來的潛在效能降低。另外由於引用計數的存在,jit 生成的程式碼中會存在大量的引用計數操作的指令,使得程式碼體積較大。

為了實現 hyengine 對 quickjs 效能最佳化,減少 gc 在整體耗時種的佔比,減少 gc 可能導致的長時間執行停止。參考 v8 等其他先進引擎的 gc 設計思路,實現一套適用於移動端業務的,輕量級、高效能、實現簡單的 gc。

注:本實現僅僅針對於 quickjs,後續可能會衍生出通用的 gc 實現。

注:為了保障業務體驗不出現卡頓,需要將 gc 的暫停時間控制在 30ms 內。

1)常用垃圾回收實現

常用的垃圾回收主要有 3 大類:
  • 引用計數
    • 給每個物件加一個引用數量,多一個引用數量 +1,少一個引用數量 -1,如果引用數量為 0 則釋放。
    • 弊端:無法解決迴圈引用問題。
  • mark sweep
    • 遍歷物件,標記物件是否有引用,如果沒有請用則清理掉。
  • 複製 gc
    • 遍歷物件,標記物件是否有引用,把有引用的物件複製一份新的,丟棄所有老的記憶體。
基於這三大類會有一些衍生,來實現多執行緒等支援,比如:
  • 三色標記 gc
    • 遍歷物件,標記物件是否有引用,狀態比單純的有引用(黑色)和無引用(白色)多一箇中間狀態標記中/不確定(灰色),可支援多執行緒。
為了儘可能減少 gc 暫停時間並減少 js 執行耗時,hygc 採用多執行緒三色 gc 方案。在業務 case 測試中,發現本身記憶體使用量並不大,故沒有引入分代支援。

2)hygc 的業務策略

hygc 計劃將策略可以暴露給使用者,用於滿足不同使用場景的效能需求,提供:無 gc、閒時 gc、多執行緒 gc 三種選項,應對不同場景對記憶體和效能的不同訴求。業務根據實際需求選擇 gc 策略,建議對 gc 策略設定開關,避免所選的 gc 策略可能導致非預期的結果。
  • 無 gc
    • 執行期不觸發 gc 操作。
    • 待程式碼完全執行完畢銷燬 runtime 時做一次 full gc 整體釋放記憶體。
  • 閒時 gc
    • 執行期不觸發 gc 操作,執行結束後在非同步執行緒做 gc。
    • 程式碼完全執行完畢銷燬 runtime 時做一次 full gc 整體釋放記憶體。
  • 預設 gc
    • 執行期會觸發 gc。
    • 程式碼完全執行完畢銷燬 runtime 時做一次 full gc 整體釋放記憶體。
我們的某個業務case就可以設定無 gc 或閒時 gc,因為程式碼執行期間沒有記憶體能被回收,gc 是在浪費時間。

3)hygc 的實現方案

quickjs 原本採用引用計數 + mark sweep 結合的 gc 方案,在 gc 最佳化時被移除,並替換為新的多執行緒三色標記gc 方案。hygc 的實現複用了部分原本 quickjs 的程式碼,做到儘可能簡單的實現所需功能。

hygc 的三色標記流程(單執行緒版本):

首先,收集根物件的主要操作是掃描 js 執行緒的棧,並將執行緒棧上的 js 物件和 js 呼叫棧關聯的物件收集起來,作為三色標記的根物件。然後,從根物件作為標記入口,依次遞迴標記子物件。遍歷 gc_obj_list(quickjs 的所有需要 gc 的物件都在這個雙向連結串列上),將沒有被標記到的物件放入 tmp_obj_list。最後,釋放 tmp_obj_list 中的物件。

單執行緒的 gc 會在 gc 過程中完全暫停 js 的執行,存在潛在的業務卡頓風險(僅僅是潛在,由於實際業務的記憶體使用量較小,暫並未出現由 gc 導致的卡頓),並且會讓js的執行時間相對較長。為此 hygc 引入了多執行緒的三色標記,其流程如下:
在多執行緒版本中,存在 js 和 gc 兩個執行緒,js 執行緒完成根物件收集及老物件轉移到非同步 gc 連結串列,然後 js 繼續執行。gc 執行緒會先將老物件的三色標記全設為 0,然後開始標記存活物件,然後對垃圾物件進行收集。這裡將垃圾物件的釋放拆分成了 2 個階段,一個是可以在 gc 執行緒執行的垃圾物件相關屬性修改及置空,另一個是需要在 js 執行緒做的記憶體釋放,這麼做的原因是 hymalloc 不是執行緒安全的。這樣 js 執行緒中的 gc 操作就只剩下相對不耗時的根物件收集、老物件轉移、記憶體釋放三個操作。

注:令人悲傷的是,由於 mark 和垃圾回收仍然只在單獨一個執行緒完成,這裡只用到了兩種顏色做標記,灰色實際上沒用到。後續最佳化讓 hygc 實現和 quickjs 原本的 gc 能夠共存,讓 gc 的遷移風險更低。

4)hygc 的侷限性

hygc 的非同步執行緒在做垃圾回收時,僅僅會對老物件做 gc,在完成老物件轉移後的新物件將不會參與 gc,可能會造成記憶體使用峰值的提升,提升程度與 gc 執行緒的執行耗時相關。

此問題後續也將根據實際情況,判斷是否進行方案最佳化來解決。

其他最佳化舉例

1)global 物件的 inline cache

quickjs 的 global 物件的操作被單獨編譯為了OP_get_var/OP_put_var等 op ,而這兩個 op 的實現格外的慢,為此我們對 global object 訪問加上了 inline cache。對 js 的物件屬性訪問可以簡化理解為在遍歷陣列來找到想要的屬性,inline cache 的目的就是快取住某段程式碼訪問的屬性所在的陣列中的偏移,這樣下次取就直接用偏移來取了,不用再做重複的屬性陣列遍歷。

global inline cache 的資料結構如下:
typedefstruct { JSAtom prop; // property atomint offset; // cached property offsetvoid *obj; // global_obj or global_var_obj} HYJSGlobalIC;
這裡的第 4 行的void *obj比較特殊,原因在於 quickjs 的 global 可能存在 context 物件的 global_objglobal_var_obj

中,具體存在哪個裡面需要一併放入 cache 中。

具體程式碼實現如下:

case OP_get_var: { // 73 JSAtom atom = get_u32(buf + i + 1);uint32_t cache_index = hyjs_GetGlobalICOffset(ctx, atom); JSObject obj; JSShape shape; LDR_X_X_I(NEXT_INSTRUCTION, R8, CTX_REG, (int32_t)((uintptr_t)&ctx->global_ic - (uintptr_t)ctx)); ADD_X_X_I(NEXT_INSTRUCTION, R8, R8, cache_index * sizeof(HYJSGlobalIC)); LDP_X_X_X_I(NEXT_INSTRUCTION, R0, R9, R8, 0); CBZ_X_L(NEXT_INSTRUCTION, R9, 12 * sizeof(uint32_t)); // check cache exsits LSR_X_X_I(NEXT_INSTRUCTION, R1, R0, 32); // get offset LDR_X_X_I(NEXT_INSTRUCTION, R2, R9, (int32_t)((uintptr_t)&obj.shape - (uintptr_t)&obj)); // get shape ADD_X_X_I(NEXT_INSTRUCTION, R2, R2, (int32_t)((uintptr_t)&shape.prop - (uintptr_t)&shape)); // get prop LDR_X_X_W_E_I(NEXT_INSTRUCTION, R3, R2, R1, UXTW, 3); // get prop LSR_X_X_I(NEXT_INSTRUCTION, R3, R3, 32); CMP_W_W_S_I(NEXT_INSTRUCTION, R0, R3, LSL, 0); B_C_L(NEXT_INSTRUCTION, NE, 5 * sizeof(uint32_t)); LDR_X_X_I(NEXT_INSTRUCTION, R2, R9, (int32_t)((uintptr_t)&obj.prop - (uintptr_t)&obj)); // get prop LSL_W_W_I(NEXT_INSTRUCTION, R1, R1, 4); // R1 * sizeof(JSProperty) LDR_X_X_W_E_I(NEXT_INSTRUCTION, R0, R2, R1, UXTW, 0); // get value B_L(NEXT_INSTRUCTION, 17 * sizeof(uint32_t)); MOV_FUNCTION_ADDRESS_TO_REG(R8, HYJS_GetGlobalVar); MOV_X_X(NEXT_INSTRUCTION, R0, CTX_REG); MOV_IMM32_TO_REG(R1, atom); MOV_X_I(NEXT_INSTRUCTION, R2, opcode - OP_get_var_undef); MOV_X_I(NEXT_INSTRUCTION, R3, cache_index); BLR_X(NEXT_INSTRUCTION, R8); CHECK_EXCEPTION(R0, R9); STR_X_X_I(NEXT_INSTRUCTION, R0, R26, SP_OFFSET(0)); i += 4;break;}
首先是第5行的hyjs_GetGlobalICOffset,這個方法會為當前 opcode 分配一個 inline cache 的 cache_index,這個 cache_index 會在第 31 行設定為HYJS_GetGlobalVar方法呼叫的第 4 個引數。程式碼的第 9 行到第 19 行,會根據 cache_index 取 cache,並根據 cache 中的 offset,取 global 物件對應偏移裡存的 prop(也就是屬性 id,資料型別是 atom),和當前需要取的物件的屬性的 atom 比較,確認 cache 是否仍然有效。如果 cache 有效則透過第 20-22 行程式碼直接取物件屬性陣列,如果無效則走到第 26 行的慢路徑,遍歷屬性陣列,並更新 inline cache。

2)builtin 的快路徑最佳化

快路徑最佳化是將程式碼中的某些執行機率更高的部分,單獨提出來,來避免冗餘程式碼的執行拖慢效能。

Array.indexOf 的實現為例:
staticJSValue hyjs_array_indexOf(JSContext *ctx, JSValueConst func_obj,JSValueConstobj,intargc, JSValueConst *argv, int flags){......res = -1;if(len > 0) {......//fast pathif(JS_VALUE_GET_TAG(element) == JS_TAG_INT) {for(; n < count; n++) {if(JS_VALUE_GET_PTR(arrp[n]) == JS_VALUE_GET_PTR(element)) {res = n;gotodone;}}gotoproperty_path;}//slow pathfor(; n < count; n++) {if(js_strict_eq2(ctx, JS_DupValue(ctx, argv[0]),JS_DupValue(ctx,arrp[n]), JS_EQ_STRICT)) {res = n;gotodone;e}}......}done:returnJS_NewInt64(ctx, res);exception:returnJS_EXCEPTION;}
原本的實現是從第 23 行開始的慢路徑,這裡需要呼叫js_strict_eq2方法來判斷陣列 index 是否相等,這個比較方法會相對比較重。而實際上 index 絕大多數情況都是 int 型別,所以提出來第 12 行的快路徑,如果 index 本身是 int 型別,那麼直接做 int 型別資料的比較,就會比呼叫 js_strict_eq2 來比較要快。

四  最佳化結果

效能測試裝置基於 m1(arm64) 晶片的 macbook ,wasm 業務效能測試基於 huawei mate 8 手機;測試結果選擇方法為每個 case 跑 5 次,取排第 3 位的結果;測試 case 選擇為斐波那契數列、benchmark、業務 case 三種,以評估不同場景下最佳化帶來的效能變化。

1  wasm 效能

測試用例
測試裝置
原版 wasm3 效能
最佳化後效能(jit)
fibonacci(40)
macbook m1
3598ms
493ms
coremark(1w cycles)
macbook m1
4.22ms
1.02ms
某業務 case
huawei mate 8
67ms
45ms
注:在業務 case 中得出的時間是單幀渲染的整體耗時,包括 wasm 執行和渲染耗時兩部分。

注:coremark hyengine jit 耗時是 llvm 編譯版本的約 3 倍,原因在於對計算指令最佳化不足,後續可在最佳化器中對更多計算指令進行最佳化。

注:上述測試編譯最佳化選項為 O3。

2  js效能

測試用例
測試裝置
原版 quickjs 效能
最佳化後效能(jitless)
最佳化後效能(jit)
fibonacci(40)
macbook m1
9.03s
8.34s
2.95s
microbench
macbook m1
2.19s
2.01s
1.88s
某業務 case 1
macbook m1
5.95s
2.61s
2.21s
某業務 case 2
macbook m1
2.39s
1.15s
0.98s
注:microbench 的部分單項在 gc 最佳化上有負向的最佳化,使得整體最佳化的提升並不明顯,但改單項對業務影響不大。

注:從業務 case 上可以看出,vm 最佳化所帶來的提升遠大於目前 jit 帶來的提升,原因在於 jit 目前引入的最佳化方式較少,仍有大量的最佳化空間。另外 case 1 在 v8 上,jit 比 jitless 帶來的提升也只有 30% 左右。在 jit 的實現中,單項的最佳化單來可能帶來的提升只有 1% 不到,需要堆幾十上百個不同的最佳化,來讓效能做到比如 30% 的提升,後續會更具效能需求及開發成本來做均衡選擇。

注:上述測試編譯最佳化選項為 Os。

五  後續計劃

後續計劃主要分為 2 個方向:效能最佳化、多語言支援,其中效能最佳化將會持續進行。

效能最佳化點包括:

  • 編譯器最佳化,引入自有位元組碼支援。
  • 最佳化器最佳化,引入更多最佳化pass。
  • 自有 runtime,熱點方法彙編實現。

六  參考內容

  • wasm3: https://github.com/wasm3/wasm3
  • quickjs: https://bellard.org/quickjs/
  • v8: https://chromium.googlesource.com/v8/v8.git
  • javascriptcore: https://opensource.apple.com/tarballs/JavaScriptCore/
  • golang/arch: https://github.com/golang/arch
  • libmalloc: https://opensource.apple.com/tarballs/libmalloc/
  • Trash talk: the Orinoco garbage collector: https://v8.dev/blog/trash-talk
  • JavaScript engine fundamentals: Shapes and Inline Caches:https://mathiasbynens.be/notes/shapes-ics
  • cs143: https://web.stanford.edu/class/cs143/
  • C in ASM(ARM64):https://www.zhihu.com/column/c_142064221
七  團隊介紹

終端體驗平臺團隊立足於移動中臺定位,致力於無線端到端前沿技術探索,打造集團先進且行業領先的移動基礎設施及配套服務,為阿里巴巴超過 1100+ 年度活躍 App 提供研發支撐,是阿里集團移動技術生態的基石團隊之一,也是大淘寶核心技術團隊及雙 11 主戰部隊。



熱烈歡迎志同道合之士加入我們,聯絡郵箱:zhibing.lwh#alibaba-inc.com(傳送郵件時,請把#替換成@)


Apsara Stack 技術百科 | 一雲多芯
「Apsara Stack 技術百科」是阿里雲混合雲出品的知識科普專欄,與您共同探討 Apsara Stack 2.0 核心技術,讓混合雲「更好懂」、「更有趣」。
【本期話題】新建雲平臺和已有云平臺如何實現一雲多芯?不同晶片的多雲協作和一雲多芯的區別在哪裡?在安全和穩定的基礎上如何將“多芯”的差異轉變為“一雲”的標準化雲服務?本期混合君帶你全面瞭解什麼是「一雲多芯」!點選閱讀原文檢視詳情。

相關文章