您现在的位置是:首页 > 科技前沿
hyengine - 面向移动端的高性能通用编译/解释引擎
智慧创新站
2025-05-27【科技前沿】164人已围观
简介一背景简介手机淘宝客户端在历史上接过多种多样的脚本引擎,用于支持的语言包括:js/python/wasm/lua,其中js引擎接过的就有:javascriptcore/duktape/v8/quickjs等多个。众多的引擎会面临共同面临包大小及性能相关的问题,我们是否可以提供一套方案,在能支持业务需...
手机淘宝客户端在历史上接过多种多样的脚本引擎,用于支持的语言包括: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-bitvariant//Syntax:ADCWd,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_trd,int8_trn,int8_trm){uint32_tcode=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实现如下:
defineIS_MOV_X_X(ins)\(IMM11(ins21)==IMM11(HY_INS_TEMPLATE_MOV_X_X21)\IMM11(ins5)==IMM11(HY_INS_TEMPLATE_MOV_X_X5))
这条指令就可以在优化器中判断某条指令是不是movxd,xm,进而可以通过如下代码取出xd中d的具体数值:
defineRN(ins)IMM5(ins5)-0x60]!//stpx26,x25,[sp,0x20]//stpx22,x21,[sp,0x40]//stpx29,x30,[sp,0x50STP_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_ti=wasm;iwasm;i+=opcodeSize){uint32_tindex=(uint32_t)(i-wasm)/sizeof(u8);uint8_topcode=*i;switch(opcode){caseOP_UNREACHABLE:{BRK_I(alloc+codeOffset++,0);break;}caseOP_NOP:{NOP(alloc+codeOffset++);break;}caseOP_REF_NULL:caseOP_REF_IS_NULL:caseOP_REF_FUNC:default:break;}if(spOffsetmaxSpOffset){maxSpOffset=spOffset;}}//return0(m3Err_none)MOV_X_I(alloc+codeOffset++,R0,0);//epilogue//ldpx29,x30,[sp,0x40]//ldpx22,x21,[sp,0x20]//ldpx26,x25,[sp,0x60//retLDP_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++);returnm3Err_none;}上述代码会先生成方法的prologue,然后for循环遍历wasm字节码,生产对应的arm64机器码,最后加上方法的epilogue。
字节码生成机器码以wasm的为例:
caseOP_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位置。由于指令会消耗2条栈上数据,并生成1条栈上数据,最终栈的偏移就要-1。
上述代码生成的机器码及其对应助记形式如下:
f9400a68:ldrx8,[x19,0x18]0b090109:addw9,w8,w9f9000a69:strx9,[x19,-0x60]!0x107384004:stpx26,x25,[sp,0x20]0x10738400c:stpx22,x21,[sp,0x40]0x107384014:stpx29,x30,[sp,0x50;=0x500x10738401c:movx19,x00x107384020:ldrx9,[x19]0x107384024:strx9,[x19,0x20x10738402c:strx9,[x19,0x10x107384034:ldrx10,[x19,0x10]0x10738403c:cmpw10,w110x107384040:cselx9,x9,xzr,lo0x107384044:strx9,[x19,0x8]0x10738404c:cmpx9,0x8]0x10738405c:ldrx9,[x19,0x10]0x107384070:movw9,0x18]0x107384078:ldrx8,[x19,0x18]0x107384080:subw9,w8,w90x107384084:strx9,[x19,0x10;=0x100x10738408c:bl0x10738408c0x107384090:ldrx9,[x19]0x107384094:strx9,[x19,0x10x10738409c:strx9,[x19,0x18]0x1073840a4:ldrx9,[x19,0x18]0x1073840b0:addx0,x19,0x10]0x1073840bc:ldrx9,[x19,0x10]0x1073840c8:ldrx9,[x19,0x10]0x1073840d8:strx9,[x19]0x1073840dc:movx0,0x50]0x1073840e4:ldpx20,x19,[sp,0x30]0x1073840ec:ldpx24,x23,[sp,0x10]0x1073840f4:ldpx28,x27,[sp],0x8]0x10738404c:cmpx9,0x8]|0x10738405c:ldrx9,[x19,0x10]
这里会根据代码中的第四行的指令及其跳转的目标地址第14行作拆分,代码为拆为了3个块。原本第11行的b指令也要做一次拆分,但前面的已经拆过了,就不再拆了。
接下对会对拆分成块后的代码跑一堆优化的pass,跑完后的结果如下:
;---codeblock0---0x104934020:cmpw9,0x2;=0x2
在跑完一堆pass后代码完全变了样(关键优化的实现请看下一节内容),但可以看出codeblock1的代码从5条指令变成了4条,之前的被优化为了跳转的目标地址的偏移也少1,从6变为5。
最后把块重新合并成为新的方法体指令:
0x104934020:cmpw9,0x2;=0x2
2)关键优化之寄存器分配
3.7倍代码量的速度慢5.57倍的一个主要原因在于,我们生产的代码中数据完全存放在栈中,栈在内存上,各种ldr/str指令对内存的访问,就算数据在cpu的l1cache上,也比对寄存器的访问慢4倍。为此,如果我们将数据尽量放在寄存器,减少对内存的访问,就可以进一步提升性能。
寄存器分配有一些较为成熟的方案,常用的包括:基于liverange的线性扫描内存分配,基于liveinternal的线性扫描内存分配,基于图染色的内存分配等。在常见jit实现,会采用基于liveinternal的线性扫描内存分配方案,来做到产物性能和寄存器分配代码的时间复杂度的平衡。
为了实现的简单性,hyengine使用了一种非主流的极简方案,基于代码访问次数的线性扫描内存分配,用人话说就是:给代码中出现次数最多的栈偏移分配寄存器。
假设代码如下(节选自hyenginejit产出代码):
0x107384020:ldrx9,[x19]0x107384024:strx9,[x19,0x20x10738402c:strx9,[x19,0x10x107384034:ldrx10,[x19,0x10]
对假设代码的分配寄存器后代码如下:
0x107384020:ldrx9,[x19];偏移0没变0x107384024:movx20,x9;偏移8变成x200x107384028:movw9,0x10x107384034:movx10,x20;偏移8变成x200x107384038:movx11,x21;偏移16变成x21
之前的jit产物代码优化后如下(注:做了少量指令融合):
0x102db4000:stpx28,x27,[sp,0x10]0x102db4008:stpx24,x23,[sp,0x30]0x102db4010:stpx20,x19,[sp,0x50]0x102db4018:addx29,sp,0x20x102db402c:movx9,0x0;=0x00x102db4040::ldrx9,[x19]0x102db4048:movx20,x90x102db404c:strx20,[x19]0x102db4050:b0x102db40ac0x102db4054:ldrx9,[x19]0x102db4058:movx21,x90x102db405c:movx22,0x10;=0x100x102db406c:strx21,[x19,0x10]0x102db4078:ldrx9,[x19]0x102db407c:movx22,x90x102db4080:movx23,0x18;=0x180x102db4090:strx22,[x19,0x18]0x102db409c:addw9,w21,w220x102db40a0:movx21,x90x102db40a4:strx21,[x19]0x102db40a8:nop0x102db40ac:movx0,0x50]0x102db40b4:ldpx20,x19,[sp,0x30]0x102db40bc:ldpx24,x23,[sp,0x10]0x102db40c4:ldpx28,x27,[sp],0x10;=0x100x102db406c:strx21,[x19,0x10]
而arm64的调用约定中,参数传递是通过寄存器来做的,这样每次方法调用可以减少两次内存访问。
这里把wasm的栈作为放入x0,第一个参数x22直接放入x1,方法调用后的返回值x0直接放入x22,优化后代码如下:
0x1057e405c:addx0,x19,-0x60]!0x1057e4004:stpx26,x25,[sp,0x20]0x1057e400c:stpx22,x21,[sp,0x40]0x1057e4014:stpx29,x30,[sp,0x50;=0x500x1057e401c:movx19,x00x1057e4020:movx20,x10x1057e4024:movx21,x200x1057e4028:movx22,0x10x1057e4030:cmpw21,w220x1057e4034:cselx9,x9,xzr,lo0x1057e4038:movx21,x90x1057e403c:cmpx9,0x20x1057e4054:subw9,w22,w230x1057e4058:movx22,x90x1057e405c:addx0,x19,0x10x1057e4074:subw9,w23,w240x1057e4078:movx23,x90x1057e407c:addx0,x19,0x50]0x1057e40a4:ldpx20,x19,[sp,0x30]0x1057e40ac:ldpx24,x23,[sp,0x10]0x1057e40b4:ldpx28,x27,[sp],0x20x1057e4054:subw9,w22,w230x1057e4058:movx22,x9
可以被优化为:
0x104934038:subw22,w20,-0x40]!0x104934004:stpx22,x21,[sp,0x20]0x10493400c:stpx29,x30,[sp,0x30;=0x300x104934014:movx19,x00x104934018:movx20,x10x10493401c:movx9,x200x104934020:cmpw9,0x2;=0x20x10493403c:addx0,x19,0x1;=0x10x104934050:addx0,x19,0x30]0x104934070:ldpx20,x19,[sp,0x10]0x104934078:ldpx24,x23,[sp],defineMOV_FUNCTION_ADDRESS_TO_REG(reg,func)\{\uintptr_tfuncAddress=(uintptr_t)func;\MOVZ_X_I_S_I(NEXT_INSTRUCTION,reg,IMM16(funcAddress),LSL,0);\if(IMM16(funcAddress16)!=0){\MOVK_X_I_S_I(NEXT_INSTRUCTION,reg,IMM16(funcAddress16),\LSL,16);\}else{\NOP(NEXT_INSTRUCTION);\}\if(IMM16(funcAddress32)!=0){\MOVK_X_I_S_I(NEXT_INSTRUCTION,reg,IMM16(funcAddress32),\LSL,32);\}else{\NOP(NEXT_INSTRUCTION);\}\if(IMM16(funcAddress48)!=0){\MOVK_X_I_S_I(NEXT_INSTRUCTION,reg,IMM16(funcAddress48),\LSL,48);\}else{\NOP(NEXT_INSTRUCTION);\}\}然后将CTX_REG(里面存的ctx地址)放入R0作为第一个参数,并调用JS_NewObject,然后结果存入js栈的SP_OFFSET(0)位置。然后通过CHECK_EXCEPTION判断结果是否存在异常:
defineCHECK_EXCEPTION(reg,tmp)\MOV_X_I(NEXT_INSTRUCTION,tmp,((uint64_t)JS_TAG_EXCEPTION56));\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的快路径、inlinecache目前优化占比不高,仅做少量介绍。
内存分配器hymalloc
为了实现hyengine对quickjs性能优化,同时兼顾gc优化所需要的对内存的管理权,需要设计一套更快速(无锁,非线程安全)的内存分配器。同时需要考虑面向其他引擎可能需要的定制,来做到hymalloc的尽量通用。
1)实现简介
hymalloc将内存分为19个区(region),18个smallregion/1个largeregion。smallregion主要用来存放规则内存,每个区的大小分从为116至1916bytes;largeregion用于存放大于9*16bytes的内存。
每个区可包含多个池(pool),每个池里面可包含多个目标大小的条目(item)。largeregion比较特殊,每个pool里只有1个条目。在向系统申请内存时,按pool来做申请,之后再将pool拆分成对应的item。
每个smallregion初始化有一个池,池的大小可配置,默认为1024个item;largeregion默认是空的。
区/块/池的示意图如下:
这里对最关键的两个数据结构做下简单介绍:
//hymallocitemstructHYMItem{union{HYMRegion*region;//settoregionwhenallocatedHYMItem*next;//settonextfreeitemwhenfreed};size_tflags;uint8_tptr[0];};//hymallocpoolstructHYMPool{HYMRegion*region;HYMPool*next;size_titem_size;};其中HYMItem是前面提到的item的数据结构,这里的item的大小不固定,数据结构本身更像是itemheader描述,其中flags目前作为gc的特别标记存在,ptr用于取item的实际可用部分内存的地址(通过item-ptr获取)。union中的region/next是一个用来省内存的设计,在item被分配出去之前,next的值指向region的下一个空闲item;在item被分配出去之后,region被设定为item所属的region地址。
region的空闲item链表示意图如下:
在内存分配时,取链表的首个item作为分配结果,链表如果为空,则向系统申请一个新的pool并把pool的item放入链表,分配示意图如下:
分配代码如下:
staticvoid*_HYMallocFixedSize(HYMRegion*region,size_tsize){//allocatenewpool,ifnofreeitemexistsif(region-free_item_list==NULL){//notice:largeregion'sitemsizeis0,use'size'insteadsize_titem_size=region-item_size?region-item_size:size;intret=_HYMAllocPool(region,region-pool_initial_item_count,item_size);if(!ret){returnNULL;}}//getfreelistitemhead,andsetregiontoitem'sregionHYMItem*item=region-free_item_list;region-free_item_list=item-next;item-region=region;item-flags=0;returnitem-ptr;}在内存释放时,将item插入所属region的空闲链表的头部即可:
voidHYMFree(void*ptr){HYMItem*item=(HYMItem*)((uint8_t*)ptr-HYM_ITEM_SIZE_OVERHEAD);//setitemasheadofregion'sfreeitemlistHYMRegion*region=item-region;HYMItem*first_item_in_region=region-free_item_list;region-free_item_list=item;item-next=first_item_in_region;}上述实现在简单的内存分配/释放测试case中,在macbookm1设备上比系统提供的malloc/free快约4倍。
2)内存compact+update
为了减少内存占用,hymalloc实现了部分内存compact,可以清理完全未使用的smallregion中的pool和largeregion的所有pool。但目前没有实现update功能,无法做到真正的将不同pool之间的item相互拷贝,来做到更多内存的节省。
但从客户端的使用场景来看,运行代码的内存用量本身不高,compact+update完整组合的实现复杂度较高,性价比不足。后续根据实际业务的使用情况,再评估实现完整compact+update的必要性。
3)hymalloc的局限性
为了提升分配和释放性能,hymalloc的每个item都有header,需要额外占用内存空间,这会导致一定的内存浪费。
而且虽然hymalloc提供了compact方法来释放空闲的内存,但由于按照pool来批量申请内存,只要pool中有一个item被使用,那么这个pool就不会被释放,导致内存不能被完全高效的释放。
另外,考虑到内存被复用的概率,largeregion的内存会默认按256bytes对齐来申请,同样可能存在浪费。
上述问题可以通过设定更小的pool的默认item数量,及更小的对齐尺寸,牺牲少量性能,来减少内存浪费。
后续可以引入更合理的数据结构,以及更完善的compact+update机制,来减少内存浪费。
垃圾回收器hygc
quickjs的原本的gc基于引用计数+marksweep,设计和实现本身比较简洁高效,但未实现分代、多线程、compact、闲时gc、拷贝gc,使得gc在整体执行耗时中的占比较高,同时也存在内存碎片化带来的潜在性能降低。另外由于引用计数的存在,jit生成的代码中会存在大量的引用计数操作的指令,使得代码体积较大。
为了实现hyengine对quickjs性能优化,减少gc在整体耗时种的占比,减少gc可能导致的长时间运行停止。参考v8等其他先进引擎的gc设计思路,实现一套适用于移动端业务的,轻量级、高性能、实现简单的gc。
注:本实现仅仅针对于quickjs,后续可能会衍生出通用的gc实现。
注:为了保障业务体验不出现卡顿,需要将gc的暂停时间控制在30ms内。
1)常用垃圾回收实现
常用的垃圾回收主要有3大类:
引用计数给每个对象加一个引用数量,多一个引用数量+1,少一个引用数量-1,如果引用数量为0则释放。弊端:无法解决循环引用问题。
marksweep遍历对象,标记对象是否有引用,如果没有请用则清理掉。
拷贝gc遍历对象,标记对象是否有引用,把有引用的对象拷贝一份新的,丢弃所有老的内存。
基于这三大类会有一些衍生,来实现多线程等支持,比如:
三色标记gc遍历对象,标记对象是否有引用,状态比单纯的有引用(黑色)和无引用(白色)多一个中间状态标记中/不确定(灰色),可支持多线程。
为了尽可能减少gc暂停时间并减少js执行耗时,hygc采用多线程三色gc方案。在业务case测试中,发现本身内存使用量并不大,故没有引入分代支持。
2)hygc的业务策略
hygc计划将策略可以暴露给用户,用于满足不同使用场景的性能需求,提供:无gc、闲时gc、多线程gc三种选项,应对不同场景对内存和性能的不同诉求。业务根据实际需求选择gc策略,建议对gc策略设置开关,避免所选的gc策略可能导致非预期的结果。
无gc运行期不触发gc操作。待代码完全运行完毕销毁runtime时做一次fullgc整体释放内存。
闲时gc运行期不触发gc操作,运行结束后在异步线程做gc。代码完全运行完毕销毁runtime时做一次fullgc整体释放内存。
默认gc运行期会触发gc。代码完全运行完毕销毁runtime时做一次fullgc整体释放内存。
我们的某个业务case就可以设定无gc或闲时gc,因为代码运行期间没有内存能被回收,gc是在浪费时间。
3)hygc的实现方案
quickjs原本采用引用计数+marksweep结合的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对象的inlinecache
quickjs的global对象的操作被单独编译为了OP_get_var/OP_put_var等op,而这两个op的实现格外的慢,为此我们对globalobject访问加上了inlinecache。对js的对象属性访问可以简化理解为在遍历数组来找到想要的属性,inlinecache的目的就是缓存住某段代码访问的属性所在的数组中的偏移,这样下次取就直接用偏移来取了,不用再做重复的属性数组遍历。
globalinlinecache的数据结构如下:
typedefstruct{JSAtomprop;//propertyatomintoffset;//cachedpropertyoffsetvoid*obj;//global_objorglobal_var_obj}HYJSGlobalIC;这里的第4行的void*obj比较特殊,原因在于quickjs的global可能存在context对象的global_obj或global_var_obj中,具体存在哪个里面需要一并放入cache中。
具体代码实现如下:
caseOP_get_var:{//73JSAtomatom=get_u32(buf+i+1);uint32_tcache_index=hyjs_GetGlobalICOffset(ctx,atom);JSObjectobj;JSShapeshape;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));//checkcacheexsitsLSR_X_X_I(NEXT_INSTRUCTION,R1,R0,32);//getoffsetLDR_X_X_I(NEXT_INSTRUCTION,R2,R9,(int32_t)((uintptr_t)(uintptr_t)obj));//getshapeADD_X_X_I(NEXT_INSTRUCTION,R2,R2,(int32_t)((uintptr_t)(uintptr_t)shape));//getpropLDR_X_X_W_E_I(NEXT_INSTRUCTION,R3,R2,R1,UXTW,3);//getpropLSR_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)(uintptr_t)obj));//getpropLSL_W_W_I(NEXT_INSTRUCTION,R1,R1,4);//R1*sizeof(JSProperty)LDR_X_X_W_E_I(NEXT_INSTRUCTION,R0,R2,R1,UXTW,0);//getvalueB_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分配一个inlinecache的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行的慢路径,遍历属性数组,并更新inlinecache。
2)builtin的快路径优化
快路径优化是将代码中的某些执行概率更高的部分,单独提出来,来避免冗余代码的执行拖慢性能。
以的实现为例:
staticJSValuehyjs_array_indexOf(JSContext*ctx,JSValueConstfunc_obj,JSValueConstobj,intargc,JSValueConst*argv,intflags){res=-1;if(len0){//fastpathif(JS_VALUE_GET_TAG(element)==JS_TAG_INT){for(;ncount;n++){if(JS_VALUE_GET_PTR(arrp[n])==JS_VALUE_GET_PTR(element)){res=n;gotodone;}}gotoproperty_path;}//slowpathfor(;ncount;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业务性能测试基于huaweimate8手机;测试结果选择方法为每个case跑5次,取排第3位的结果;测试case选择为斐波那契数列、benchmark、业务case三种,以评估不同场景下优化带来的性能变化。
1wasm性能
注:在业务case中得出的时间是单帧渲染的整体耗时,包括wasm执行和渲染耗时两部分。
注:coremarkhyenginejit耗时是llvm编译版本的约3倍,原因在于对计算指令优化不足,后续可在优化器中对更多计算指令进行优化。
注:上述测试编译优化选项为O3。
2js性能
注:microbench的部分单项在gc优化上有负向的优化,使得整体优化的提升并不明显,但改单项对业务影响不大。
注:从业务case上可以看出,vm优化所带来的提升远大于目前jit带来的提升,原因在于jit目前引入的优化方式较少,仍有大量的优化空间。另外case1在v8上,jit比jitless带来的提升也只有30%左右。在jit的实现中,单项的优化单来可能带来的提升只有1%不到,需要堆几十上百个不同的优化,来让性能做到比如30%的提升,后续会更具性能需求及开发成本来做均衡选择。
注:上述测试编译优化选项为Os。
后续计划主要分为2个方向:性能优化、多语言支持,其中性能优化将会持续进行。
性能优化点包括:
编译器优化,引入自有字节码支持。
优化器优化,引入更多优化pass。
自有runtime,热点方法汇编实现。
六参考内容wasm3:
quickjs:
v8:
javascriptcore:
golang/arch:
libmalloc:
Trashtalk:theOrinocogarbagecollector:
JavaScriptenginefundamentals:ShapesandInlineCaches:
cs143:
CinASM(ARM64):
作者|知兵
本文为阿里云原创内容,未经允许不得转载。
很赞哦!(2)