众多的引擎会面临共同面临包大小及性能相关的问题,我们是否可以提供一套方案,在能支持业务需求的前提下,用一个引擎来支持尽可能多的语言,能较好的兼顾包大小较小和性能优异。
一 背景简介
手机淘宝客户端在历史上接过多种多样的脚本引擎,用于支持的语言包括: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: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 , , 指令,第一个参数是存放机器码的 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)优化器的主要流程
优化器的主要流程如下:
发表评论 取消回复