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

語言: CN / TW / HK

作者:知兵

背景簡介

手機淘寶客戶端在歷史上接過多種多樣的指令碼引擎,用於支援的語言包括: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 可能有一定相似度。

實現介紹

編譯(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:5
static inline void ADC_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, x30
assert(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 - 2和spOffset - 1位置的兩條資料,然後相加,再把結果存放到棧上spOffset - 2位置。由於 i32.add 指令會消耗 2 條棧上資料,並生成 1 條棧上資料,最終棧的偏移就要 -1。

上述程式碼生成的機器碼及其對應助記形式如下:

f9400a68: ldr    x8, [x19, #0x10]
f9400e69: ldr    x9, [x19, #0x18]
0b090109: add    w9, w8, w9
f9000a69: 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 | 0x0b   end
  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, x0
    0x107384020: ldr    x9, [x19]
    0x107384024: str    x9, [x19, #0x8]
    0x107384028: mov    w9, #0x2
    0x10738402c: str    x9, [x19, #0x10]
    0x107384030: mov    x9, #0x1
    0x107384034: ldr    x10, [x19, #0x8]
    0x107384038: ldr    x11, [x19, #0x10]
    0x10738403c: cmp    w10, w11
    0x107384040: csel   x9, x9, xzr, lo
    0x107384044: str    x9, [x19, #0x8]
    0x107384048: ldr    x9, [x19, #0x8]
    0x10738404c: cmp    x9, #0x0                  ; =0x0 
    0x107384050: b.eq   0x107384068
    0x107384054: ldr    x9, [x19]
    0x107384058: str    x9, [x19, #0x8]
    0x10738405c: ldr    x9, [x19, #0x8]
    0x107384060: str    x9, [x19]
    0x107384064: b      0x1073840dc
    0x107384068: ldr    x9, [x19]
    0x10738406c: str    x9, [x19, #0x10]
    0x107384070: mov    w9, #0x2
    0x107384074: str    x9, [x19, #0x18]
    0x107384078: ldr    x8, [x19, #0x10]
    0x10738407c: ldr    x9, [x19, #0x18]
    0x107384080: sub    w9, w8, w9
    0x107384084: str    x9, [x19, #0x10]
    0x107384088: add    x0, x19, #0x10            ; =0x10 
    0x10738408c: bl     0x10738408c
    0x107384090: ldr    x9, [x19]
    0x107384094: str    x9, [x19, #0x18]
    0x107384098: mov    w9, #0x1
    0x10738409c: str    x9, [x19, #0x20]
    0x1073840a0: ldr    x8, [x19, #0x18]
    0x1073840a4: ldr    x9, [x19, #0x20]
    0x1073840a8: sub    w9, w8, w9
    0x1073840ac: str    x9, [x19, #0x18]
    0x1073840b0: add    x0, x19, #0x18            ; =0x18 
    0x1073840b4: bl     0x1073840b4
    0x1073840b8: ldr    x8, [x19, #0x10]
    0x1073840bc: ldr    x9, [x19, #0x18]
    0x1073840c0: add    w9, w8, w9
    0x1073840c4: str    x9, [x19, #0x10]
    0x1073840c8: ldr    x9, [x19, #0x10]
    0x1073840cc: str    x9, [x19]
    0x1073840d0: b      0x1073840dc
    0x1073840d4: ldr    x9, [x19, #0x10]
    0x1073840d8: str    x9, [x19]
    0x1073840dc: mov    x0, #0x0
    0x1073840e0: 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], #0x60
    0x1073840f8: 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, #0x10            
    0x10268cfc4 <+12>: mov    x19, x0
    0x10268cfc8 <+16>: cmp    w0, #0x2                  
    0x10268cfcc <+20>: b.hs   0x10268cfd8               
    0x10268cfd0 <+24>: mov    w20, #0x0
    0x10268cfd4 <+28>: b      0x10268cff4               
    0x10268cfd8 <+32>: mov    w20, #0x0
    0x10268cfdc <+36>: sub    w0, w19, #0x1             
    0x10268cfe0 <+40>: bl     0x10268cfb8               
    0x10268cfe4 <+44>: sub    w19, w19, #0x2            
    0x10268cfe8 <+48>: add    w20, w0, w20
    0x10268cfec <+52>: cmp    w19, #0x1                 
    0x10268cff0 <+56>: b.hi   0x10268cfdc               
    0x10268cff4 <+60>: add    w0, w19, w20
    0x10268cff8 <+64>: ldp    x29, x30, [sp, #0x10]
    0x10268cffc <+68>: ldp    x20, x19, [sp], #0x20
    0x10268d000 <+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: sub    w22, 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 5
    0x104934028: mov    x9, x20
    0x10493402c: mov    x21, x9
    0x104934030: mov    x20, x9
    0x104934034: b      0x104934068
    0x104934038: 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: ldr    x9, [x19]
    0x107384024: str    x9, [x19, #0x8]
    0x107384028: mov    w9, #0x2
    0x10738402c: str    x9, [x19, #0x10]
    0x107384030: mov    x9, #0x1
    0x107384034: ldr    x10, [x19, #0x8]
    0x107384038: ldr    x11, [x19, #0x10]

對假設程式碼的分配暫存器後代碼如下:

    0x107384020: ldr    x9, [x19]        ; 偏移0沒變
    0x107384024: mov    x20, x9          ; 偏移8變成x20
    0x107384028: mov    w9, #0x2
    0x10738402c: mov    x21, x9          ; 偏移16變成x21
    0x107384030: mov    x9, #0x1
    0x107384034: mov    x10, x20         ; 偏移8變成x20
    0x107384038: 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            ; =0x50 
    0x102db401c: mov    x19, x0
    0x102db4020: ldr    x9, [x19]
    0x102db4024: mov    x20, x9
    0x102db4028: mov    x21, #0x2
    0x102db402c: mov    x9, #0x1
    0x102db4030: cmp    w20, w21
    0x102db4034: csel   x9, x9, xzr, lo
    0x102db4038: mov    x20, x9
    0x102db403c: cmp    x9, #0x0                  ; =0x0 
    0x102db4040: b.eq   0x102db4054
    0x102db4044: ldr    x9, [x19]
    0x102db4048: mov    x20, x9
    0x102db404c: str    x20, [x19]
    0x102db4050: b      0x102db40ac
    0x102db4054: ldr    x9, [x19]
    0x102db4058: mov    x21, x9
    0x102db405c: mov    x22, #0x2
    0x102db4060: sub    w9, w21, w22
    0x102db4064: mov    x21, x9
    0x102db4068: add    x0, x19, #0x10            ; =0x10 
    0x102db406c: str    x21, [x19, #0x10]
    0x102db4070: bl     0x102db4070
    0x102db4074: ldr    x21, [x19, #0x10]
    0x102db4078: ldr    x9, [x19]
    0x102db407c: mov    x22, x9
    0x102db4080: mov    x23, #0x1
    0x102db4084: sub    w9, w22, w23
    0x102db4088: mov    x22, x9
    0x102db408c: add    x0, x19, #0x18            ; =0x18 
    0x102db4090: str    x22, [x19, #0x18]
    0x102db4094: bl     0x102db4094
    0x102db4098: ldr    x22, [x19, #0x18]
    0x102db409c: add    w9, w21, w22
    0x102db40a0: mov    x21, x9
    0x102db40a4: str    x21, [x19]
    0x102db40a8: nop    
    0x102db40ac: mov    x0, #0x0
    0x102db40b0: 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], #0x60
    0x102db40c8: ret

優化後的程式碼量從 63 條減少到 51 條,且記憶體訪問數量明顯減少,耗時也減少到 1361ms,耗時減少到 llvm 的約 4.42 倍。

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

在暫存器分配優化的最後一條中提到,在方法呼叫時需要把暫存器的值拷回棧,額外增加了記憶體訪問的開銷。其相關彙編程式碼為:

    0x102db4068: add    x0, x19, #0x10            ; =0x10 
    0x102db406c: str    x21, [x19, #0x10]
    0x102db4070: bl     0x102db4070
    0x102db4074: ldr    x21, [x19, #0x10]

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

這裡把 wasm 的棧作為放入x0, 第一個引數x22直接放入x1,方法呼叫後的返回值x0直接放入x22,優化後代碼如下:

    0x1057e405c: add    x0, x19, #0x10            ; =0x10 
    0x1057e4060: mov    x1, x22
    0x1057e4064: bl     0x1057e4064
    0x1057e4068: 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, x0
    0x1057e4020: mov    x20, x1
    0x1057e4024: mov    x21, x20
    0x1057e4028: mov    x22, #0x2
    0x1057e402c: mov    x9, #0x1
    0x1057e4030: cmp    w21, w22
    0x1057e4034: csel   x9, x9, xzr, lo
    0x1057e4038: mov    x21, x9
    0x1057e403c: cmp    x9, #0x0                  ; =0x0 
    0x1057e4040: b.eq   0x1057e404c
    0x1057e4044: mov    x21, x20
    0x1057e4048: b      0x1057e409c
    0x1057e404c: mov    x22, x20
    0x1057e4050: mov    x23, #0x2
    0x1057e4054: sub    w9, w22, w23
    0x1057e4058: mov    x22, x9
    0x1057e405c: add    x0, x19, #0x10            ; =0x10 
    0x1057e4060: mov    x1, x22
    0x1057e4064: bl     0x1057e4064
    0x1057e4068: mov    x22, x0
    0x1057e406c: mov    x23, x20
    0x1057e4070: mov    x24, #0x1
    0x1057e4074: sub    w9, w23, w24
    0x1057e4078: mov    x23, x9
    0x1057e407c: add    x0, x19, #0x18            ; =0x18 
    0x1057e4080: mov    x1, x23
    0x1057e4084: bl     0x1057e4084
    0x1057e4088: mov    x23, x0
    0x1057e408c: add    w9, w22, w23
    0x1057e4090: mov    x22, x9
    0x1057e4094: mov    x20, x22
    0x1057e4098: nop    
    0x1057e409c: mov    x0, x20
    0x1057e40a0: 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], #0x60
    0x1057e40b8: ret

優化後的程式碼量從 51 條減少到 47 條,耗時也減少到 687ms,耗時減少到 llvm 的約 2.23 倍。雖然程式碼量只減少了 4 條,但耗時顯著減少了約 50%。

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

4)關鍵優化之特徵匹配

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

比如上面程式碼中的:

    0x1057e404c: mov    x22, x20
    0x1057e4050: mov    x23, #0x2
    0x1057e4054: sub    w9, w22, w23
    0x1057e4058: mov    x22, x9

可以被優化為:

    0x104934038: sub    w22, 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, x0
    0x104934018: mov    x20, x1
    0x10493401c: mov    x9, x20
    0x104934020: cmp    w9, #0x2                  ; =0x2 
    0x104934024: b.hs   0x104934038
    0x104934028: mov    x9, x20
    0x10493402c: mov    x21, x9
    0x104934030: mov    x20, x9
    0x104934034: b      0x104934068
    0x104934038: sub    w22, w20, #0x2            ; =0x2 
    0x10493403c: add    x0, x19, #0x10            ; =0x10 
    0x104934040: mov    x1, x22
    0x104934044: bl     0x104934000
    0x104934048: mov    x22, x0
    0x10493404c: sub    w23, w20, #0x1            ; =0x1 
    0x104934050: add    x0, x19, #0x18            ; =0x18 
    0x104934054: mov    x1, x23
    0x104934058: bl     0x104934000
    0x10493405c: add    w9, w22, w0
    0x104934060: mov    x22, x9
    0x104934064: mov    x20, x9
    0x104934068: mov    x0, x20
    0x10493406c: ldp    x29, x30, [sp, #0x30]
    0x104934070: ldp    x20, x19, [sp, #0x20]
    0x104934074: ldp    x22, x21, [sp, #0x10]
    0x104934078: ldp    x24, x23, [sp], #0x40
    0x10493407c: ret

優化後的程式碼量從 63 條減少到 32 條,耗時從 1716ms 減少到 493ms ,耗時減少到 llvm 的約 1.6 倍。

5****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 條。

引擎(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 item

struct HYMItem {

    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 pool

struct HYMPool {

    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 exists

    if (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) {

            return NULL;

        }

    }



    // 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 的空閒連結串列的頭部即可:

void HYMFree(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 大類:

  • 引用計數

a、給每個物件加一個引用數量,多一個引用數量 +1,少一個引用數量 -1,如果引用數量為 0 則釋放。

b、弊端:無法解決迴圈引用問題。

  • mark sweep

a、遍歷物件,標記物件是否有引用,如果沒有請用則清理掉。

  • 拷貝 gc

a、遍歷物件,標記物件是否有引用,把有引用的物件拷貝一份新的,丟棄所有老的記憶體。

基於這三大類會有一些衍生,來實現多執行緒等支援,比如:

  • 三色標記 gc

a、遍歷物件,標記物件是否有引用,狀態比單純的有引用(黑色)和無引用(白色)多一箇中間狀態標記中/不確定(灰色),可支援多執行緒。

為了儘可能減少 gc 暫停時間並減少 js 執行耗時,hygc 採用多執行緒三色 gc 方案。在業務 case 測試中,發現本身記憶體使用量並不大,故沒有引入分代支援。

2)hygc 的業務策略

hygc 計劃將策略可以暴露給使用者,用於滿足不同使用場景的效能需求,提供:無 gc、閒時 gc、多執行緒 gc 三種選項,應對不同場景對記憶體和效能的不同訴求。業務根據實際需求選擇 gc 策略,建議對 gc 策略設定開關,避免所選的 gc 策略可能導致非預期的結果。

  • 無 gc

a、執行期不觸發 gc 操作。

b、待程式碼完全執行完畢銷燬 runtime 時做一次 full gc 整體釋放記憶體。

  • 閒時 gc

a、執行期不觸發 gc 操作,執行結束後在非同步執行緒做 gc。

b、程式碼完全執行完畢銷燬 runtime 時做一次 full gc 整體釋放記憶體。

  • 預設 gc

a、執行期會觸發 gc。

b、程式碼完全執行完畢銷燬 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 的資料結構如下:

typedef struct {

    JSAtom prop;  // property atom

    int offset;   // cached property offset

    void *obj;    // global_obj or global_var_obj

} HYJSGlobalIC;

這裡的第 4 行的void *obj比較特殊,原因在於 quickjs 的 global 可能存在 context 物件的 global_obj 或 global_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 的實現為例:

static JSValue hyjs_array_indexOf(JSContext *ctx, JSValueConst func_obj,

                                  JSValueConst obj,

                                  int argc, JSValueConst *argv, int flags)

{

    ......



    res = -1;

    if (len > 0) {



        ......



        // fast path

        if (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;

                    goto done;

                }

            }

            goto property_path;

        }



        // slow path

        for (; n < count; n++) {



            if (js_strict_eq2(ctx, JS_DupValue(ctx, argv[0]),

                              JS_DupValue(ctx, arrp[n]), JS_EQ_STRICT)) {

                res = n;

                goto done;e

            }

        }



        ......

    }

 done:

    return JS_NewInt64(ctx, res);



 exception:

    return JS_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 三種,以評估不同場景下優化帶來的效能變化。

wasm 效能

注:在業務 case 中得出的時間是單幀渲染的整體耗時,包括 wasm 執行和渲染耗時兩部分。

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

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

js效能

注:microbench 的部分單項在 gc 優化上有負向的優化,使得整體優化的提升並不明顯,但改單項對業務影響不大。

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

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

後續計劃

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

效能優化點包括:

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

【參考內容】

【團隊介紹】

終端體驗平臺團隊立足於移動中臺定位,致力於無線端到端前沿技術探索,打造集團先進且行業領先的移動基礎設施及配套服務,為阿里巴巴超過 1100+ 年度活躍 App 提供研發支撐,是阿里集團移動技術生態的基石團隊之一,也是大淘寶核心技術團隊及雙 11 主戰部隊。熱烈歡迎志同道合之士加入我們,聯絡郵箱:zhibing.lwh#alibaba-inc.com(傳送郵件時,請把#替換成@)

關注【阿里巴巴移動技術】微信公眾號,每週 3 篇移動技術實踐&乾貨給你思考!