TypeScript/JavaScript低成本静态编译AOT的探索

概述

TypeScript和JavaScript是目前前端最流行的生态, 它们的共同特点就是表达和抽象能力非常强, 但这也导致了语言VM/Runtime都非常臃肿, 带来了性能上的损失. 作为一种胶水语言, 性能本身不应该是大问题, 但是随着在前端生态的帮助下和跨平台开发的需要, 这2种语言的很多开发者进入到了更广阔的开发场景下, 比如更大型的项目, 服务器应用, IOT设备的应用等等. 借助于jit compiler帮助, 在大多数的使用场景下, 它们的性能都可以得到很大的提升, 但是在一些jit compiler不能使用的场合, 比如ios, 或者一些iot设备, 或者一些需要快速启动(faas或者首屏敏感的应用)的项目, 解释器的性能不能满足实际的需要, 于是aot编译又被提出来, 试图在jit compiler和解释器之间的光谱上找到一个合适的位置, 帮助整个生态适应更大的场景.

这个想法, 最早来源于年初的一个火花, 一开始是考虑的是JS Aot, 后来出于性能榨取最多, 和成本的考虑, 在TS上做可以更容易, 性能更好. 后面会提到JS AOT的做法.

本文主要是结合现有的技术条件和资源投入, 探索一条低成本, 快速的TS aot, 甚至JS aot的方案. 开始会做一些理论上的介绍和讨论, 后面会给出一些原型验证方面的尝试.

这会是一个系列文章的第一篇.

前言

背景

TypeScript和JavaScript是目前前端使用最广泛的语言, 他们在词法上都符合ECMA规范. 这其中, JS的表达和抽象能力非常强, 而TS仅仅提供了一个前端编译器, 能够把TS编译成JS, 把一些JS在定义和使用上的弊端, 通过前端编译器提前在编译阶段限制住了, 但是我个人理解, 从本质上来说, TS和JS是两种完全不同的语言:

  • 两者词法上都是ECMA规范, TS是JS在词法上的超集, 也就是说一些TS上的写法在JS上是不存在的, 但依然符合ECMA262.

  • JS的类型系统是structural typing, 但是是动态类型检查, 也即是在运行时检查类型

  • TS的类型系统是gradual typing, 可以简单认为是一个静态structural typing加上一部分动态类型检查(any). 所以在大部分类型上, 两者有本质的区别, TS是寄希望于在编译阶段就执行类型检查, 而为了兼容JS而定义出来的any, 会留住运行时检查.

  • JS的抽象模型是建立在原型链基础上, 表达能力非常强(prototype和class based的比较可以参看上世纪的一些OOP研究论文), 而TS不允许动态修改属性的话, 本质上是不需要执行一个原型链的VM, 当然目前没有TS的VM, 编译成JS后, 还是在用JS VM上的原型链. 使用固定layout的后台表示的话, 性能会比原型链模型好不少, 这也是JAVA/C++使用class based模型的原因.

  • TS能够发射成JS, 不是仅仅因为词法相似的关系, 本质上是JS的表达能力要强于TS. TS把JS的大部分类型动态检查部分前提到了编译阶段, 把一些动态性强的功能去掉了, 从语言的灵活性和表达能力上, TS比JS是要弱的.

我们说一种语言, 它的本质是它的类型系统(静态, 动态, structural, nominal)和抽象模型(OOP, class based, prototype based), 它的词法规则这些只是表象, 如果本质不同, 外在再相似也可以认为是两种不同的语言, 基于这个, 像TS/JS/AS都是不同的语言, golang/tinggo也是不同的语言.

静态编译

上面说到TS大部分情况下是一个静态类型系统, 那意味着TS的大部分代码是可以通过静态编译(AOT)的方式变成native的, 即使是动态部分的any, 如果不在乎代码的size的话, 理论上也可以通过在编译阶段插入类型检查和判断来实现.

把一个类似TS这样的高级语言/托管语言, 编译成native, 工作量非常巨大, 需要完善一整套工具链:

  • 我们需要一个前端编译器(parser + type checker + emitter), 把TS代码编译成bytecode/或者其他的中间表示, 举个栗子就是javac.

  • 需要一个完整的编译框架, 把上一步生成的中间表示, 通过层层优化(各种优化pass)和lowering(HIR, MIR, LIR, Assembly), 编译成native, 这当中要根据实际语言的特点, 再定制一些特有的优化, 举个栗子就是java特有的nullcheck消除, boundscheck消除等等.

  • 需要一个语言自己的VM/Runtime, 这个VM上会实现语言的内在表示, 比如灵活layout的原型链或者固定layout的class, call frame的管理, 异步, 闭包, FFI, builtin函数, 以及GC等等. 举个栗子就是JVM.

可行性

上一节中的正规方法, 路线长, 成本高, 非一般小的团队能够完成, 可行性低. 那么是不是可以通过降低预期, 来达成一个折衷, 快速, 低成本, 可行性高的方案.

把上一节中的需要再次来分解一下:

  • 前端编译器, TS的类型系统非常复杂, 可以说是目前类型系统研究和工程化的一个样板, 除了TS本身的语法解析, 还要包括类型表达式的解析和推导, 重新去做一个TS的前端编译器难度非常大, 工作量非常高, 所以能够直接利用现有的TS编译器TSC是最佳办法. TSC本身是用TS写的, 层次结构清晰, 包括了parser, type checker, emitter(JS), 另外还提供了TSC api可以调用TSC的大部分功能(目前这套API还不是非常完善), 也即是说, 可以用TSC API独立于TSC本身做一个前端编译器, 大部分的工作TSC已经完成了, 只需要额外添加的部分即可.

  • TSVM, 前面说到高级语言/托管语言通常都需要一个VM来执行, TS本身没有自己的VM, 而是发射成JS后, 由JS VM来执行, JS VM上的能力足够表达TS语言的需要, 也就是说, 如果不在乎性能的话, 可以用一个现成的JS VM来作为TS的VM, 当然, 只要是一个表达能力比TS强的VM, 理论上都能胜任这个工作. 而出于性能考虑, 是可以在这个JS VM上做一些修改来得到一个定制的TS VM. 选用JS VM还有另一个好处就是, 可以在这个TS VM上执行(evaluate)JS代码, 同时提供了动态化能力.

  • 编译框架, 为了达到更好的优化效果, 一个完整的编译框架和流水线是需要的, 但是取决于优化带来的实际好处, 这一部分可大可小, 丰俭随意. 但是既然讨论的是可行性和低成本, 那么很大一部分工作是希望可以规避掉的. 另外, 也为了对接上面的现成TSVM, 也不可能直接发射到汇编. 所以, 如果有一个使用native语言, 比如C/C++, 实现的JS VM, 编译框架直接在优化后, 最终发射成C代码来调用VM的功能, 那么可以带来以下的好处/坏处:

  • 能够对接上现有的JS VM.

  • 规避了发射到汇编的开销.

  • 利用了现有工具链(gcc, clang)的优化

  • 优化不够彻底, 调用JS VM的操作通常颗粒度不够细, 导致优化不够细.

综合以上, 能够推导出:

  • 利用TSC作为前端编译器

  • 一个C/C++实现的JS VM. quickjs是个理想选择, c写的, 体积小, 支持ES2020, 可修改性强.

  • 编译框架, 可大可小, 最终发射出C代码去调用VM的能力来表达TS.

用一张图来表示就是:

AST tree到C code这里有两条路径是因为, 需要优化比较多的话, 可以把AST tree发射到IR, 然后再按照常规的静态分析做一遍遍的优化, 最终发射到低级的c code算子上. 反之, 下面那条路是一条更快速的singlepass的路, 直接在AST tree上一遍发射出c code, 缺点是只有一遍遍历的机会, 所以发射出的代码会比较冗余, 也没有更多优化(顶多在basic block内的优化), 但这条路比较快能完成, 投入也比较小, 性能会有一点提高(原因后面会讲到), 而且可以给上面那条路趟坑, 定义出合适的IR和定制出合适的TS VM.

性能

这个方案, 可控的优化比传统方案会少很多, 一旦进入到C code后, 后面的优化基本只能靠C compiler来完成了, 也即传统编译器的后端外包给了C compiler. 所以, 理论上, C code部分的算子越低级, 那么留给前面的优化可以做的更多.

前端和中端的优化取决于IR中包含语义信息的多少, 所以大多数和TS本身有关系的优化都需要在更高的层级上去做, 发射成C code这个方案并不会干扰到这些优化的执行. 比如说, 想要做一些和alias相关的优化, 那么alias分析最好在更高级的IR上进行, 才能分析的更准确.

如果出于成本的极致考虑, 或者只是做个原型验证, 想要花最少的成本, 不做IR, 不做静态分析, 去实现AOT呢, 性能比JS解释器有没有提高? 我认为是有的, 主要可以来源于以下几点:

  • partial evaluation带来的性能好处, 解释器缺乏代码上下文的信息, 而AOT的时候至少会有method内, 或者block内的信息, 编译出的C code比解释器的C code要更优.

  • TS静态类型带来的好处, TS把绝大部分的类型检查提前到了compile time, 那么在VM里可以把不发射这些检查的代码或者删掉.

  • TS静态类型系统, 不允许类型的随意转换, 降低了VM的开销, 甚至box/unbox的开销.

  • 传统JS解释器通常都是基于stack based, 效率比较低. 发射成register based表示, 可以删除掉部分冗余.

  • 解释器里function call的开销也比较大, 在发射成C code后, 还有优化的余地.

  • 原型链的开销比较大, 如果不考虑兼容JS的话, 可以把原型链的实现改成固定layout的内存表示, 性能会有提升.

  • 其他一些code inline, branch predicate的好处.

另外, 在当前的方案里, 性能和兼容性是相矛盾的, 如果要追求性能, 很可能会牺牲掉语法的兼容性, 对一些造成性能影响的语法做减法, 反之, 如果追求兼容的话, 就需要在JS runtime保留更多的JS特性.

JS AOT

这个想法, 最早是基于JavaScript的, 也是使用quickjs作为后台的VM. 只是前端编译的话, 可以直接用qjs的bytecode, 发射到C code(singlepass) 或者中间IR(更多优化机会). 之所以没有继续在JS上做, 出于以下几个原因:

  • JavaScript是动态类型语言, 类型检查贯穿在这个运行时里, AOT后很难取得太大的好处, 除非还要一个很强的类型推导

  • TS的前端编译器保证了上面一点的问题不会出现(不是很完美目前), 类型推导也很强. aot后的代码可以优化的更好.

  • TS上的完成更快速, 投入比JS小, 性能会更好.

  • 个人对TS的风格的欣赏, 特别是类型表达能力.

原型验证POC

talk is cheap, show me your code/data.

工程介绍

建立了一个工程TSDK, 引入了2个子工程, TSC+(TypeScript TSC)和TSVM(quickjs).

TSC+是基于TypeScript TSC的一个修改版本, 出于快速原型验证的需要, 我没有用TSC API去重新写一个前端compiler, 而是直接在TSC里去修改, 主要改了binder, checker和emitter部分, 大部分修改集中在emitter内, 新加了个emitter去发射C code.

TSVM是基于quickjs的一个VM, 目前的修改还是比较少的, 主要是加了新的算子函数, 修改了一些qjs的逻辑为了适配TS.

当前的进展和发现

截止到本篇文章发布, 在这个工程上完成了一个TS fibonacci case的验证, 完成了TS闭包的基本验证. 这2个验证都是为了看看使用当前方案在code generation上有没有没什么困难, 以及验证下初步的性能, 所以只是在TSC中走原来JS emitter的路来发射C code, 也即只是一个singlepass的emitter, 几乎没有什么优化, 纯粹靠PE和register based这些好处.

完成的原型包括:

  • 数据类型, 只做了number

  • 运算符, 只做了加法, 部分减法

  • 控制语句, 只实现了if/then/else

  • 函数call, 完成了基本的闭包(捕获变量在栈上)

  • 全局变量, let变量, 函数声明.

  • 对象的声明, 对象属性的访问, 修改.

fibonacci

fibonacci

这是一个没有优化过的fibonacci的naive实现, 发射出来的c code:

generated C code

这个直白的c code实现, 比quickjs要快2倍, 这里犯了一个错误, line 119的代码是不需要的, 可以提到runtime初始化的时候做, 这样调整后就是2倍.

然后, 人工手动的对上面这段C代码再优化了下, 看看引入编译框架和VM优化后的潜力, 数据如下:

  • quickjs 31秒

  • 上面这段naive C需要23秒

  • 去掉line 119, 节省8秒

  • 优化line 120, 省3秒, 静态分析能够删掉部分这种代码

  • 优化line 124, 128的JS_call, 可以节省12-13秒, 这个函数是JS call C的, 在纯C的情况下, 有优化的潜力.

  • 经过人工优化后, 只需要2.x秒, 比quickjs快10-13倍, 这是理想值了, 可能是上限.

闭包closure

TS/JS引入了很多函数式编程的特点, 提高了表达和抽象能力, 这当中变量的捕获(自由变量)是实现的关键. 验证的时候加入闭包的实现, 基本按照quickjs闭包的实现来发射C code, 目的是为了最大限度的兼容quickjs, 这样将来也可以在TS里直接调用JS, 否则, 这一段应该也有优化的余地.

quickjs的call frame是分配在stack上, 但是目前的C function和TS函数不是一一对应的, 所以分配在堆上更合理, 但是为了兼容qjs, 还是通过了一些tracky的方法分配在了stack上, 数据结构也基本follow quickjs.

一个闭包的栗子

发射出来的C code:

JSValue js_func_plus(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv)
{
    JSStackFrame * sf = js_update_sf(ctx, argc, argv, 0, 1);
    JS_UPDATE_SF(sf, argc, argv, 0, 1);

    JSValue *jv_z = &sf->var_buf[0];

    JSValue *jv_x = var_refs[0]->pvalue;
    int n_temp_1 = 22;
    *jv_x = JS_MKVAL(JS_TAG_INT, 22);

    *jv_z = *jv_x;


    return *jv_z;
}

JSValue js_func_add(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv)
{
    JSStackFrame * sf = js_update_sf(ctx, argc, argv, 1, 2);
    JS_UPDATE_SF(sf, argc, argv, 1, 2);

    (argv[0]);// num
    JSValue *jv_num = &sf->arg_buf[0];

    JSValue *jv_plus = &sf->var_buf[0];
    *jv_plus = JS_NewCFunction(ctx, js_func_plus, "plus", 0);
    JSClosureVar cv_plus[1];
    cv_plus[0].is_arg = false;
    cv_plus[0].is_local = true;
    cv_plus[0].is_lexical = true;
    cv_plus[0].var_idx = 1;
    cv_plus[0].var_name = atom_x;
    js_closure_jsc(ctx, *jv_plus, var_refs, cv_plus, 1, sf);


    JSValue *jv_x = &sf->var_buf[1];

    int n_temp = 100;
    *jv_x = JS_MKVAL(JS_TAG_INT, 100);


    JSValue jv_temp = JS_Call_jsc(ctx, *jv_plus, JS_UNDEFINED, 0, NULL);

    JSValue jv_temp_1 = jsvalue_add_number_number(ctx, *jv_x, *jv_num);

    JS_FreeValue(ctx, *jv_plus);
    return jv_temp_1;
}

这里js_update_sf/JS_UPDATE_SF, js_closure_jsc, JS_Call_jsc都是和闭包相关的辅助函数, 将来也可以定义成算子函数或者IR来使用, 主要用来分配call frame, 在call frame里分配局部变量和捕获变量的引用, 建立捕获变量引用和变量实际地址的绑定等.

经验

在做完这2个验证后, 发现了一些singplpass方案的局限性:

  • 优化的机会少很多, 只能局限在basic block内

  • TSC emitter为了发射JS code, 是按照从上往下的顺序发射, 但实际上变成C code, 需要把函数定义提前, 这里需要建立一个新的writter, 最好用tsc API重新写一个emitter.

  • 变量捕获, 纯靠emitter的一次遍历无法搞定, 还是需要在emitter前对AST做次遍历, 或者改变emit遍历的顺序, 比如先遍历函数声明, 再遍历非函数statements. 原型验证的时候, 我是在binder和checker内加了代码实现的, 所以用TSC API重写一个编译器的时候, 会更容易一些, 另外, 直接对TSC做侵入式的修改, 也会造成以后版本管理的麻烦.

  • quickjs VM里在C function上的数据结构比较简单, 不足以表达目前像闭包, 甚至其他更多功能的需要, 可能需要引入一个新的数据结构介于bytecodefunction和cfunc之间.

  • TS/JS语法特性还是非常多的, 完全支持完, 即使是singlepass编译也需要很多的工作量, 包括前端发射, TSVM的工作等等.

  • 和传统AOT一样, AOT后的代码会比TS/JS(解释执行)大很多, 对使用场景又有了不一样的限制.

前景和实际

  • 首先, 静态编译的更多适用场景还需要更多的调研, 除了出于性能和jit不能的场景, aot的方法还可以帮助用户快速得到TS/JS代码的native版本, 用于打包, 混淆, 或者提高runtime的性能(很多语言的大部分runtime是用自身语言写的).

  • 其次, 在语法兼容性不能接近完美的情况下, 会对使用开放场景下的第三方库造成不兼容, 这时候需要限制第三方库, 或者引入aot和解释器的混合运行, 也即不支持的库走解释器模式, 因为TSVM是可以支持TS和JS的runtime, 所以理论上这种方式可行.

  • 最初的落地, 最好是一些封闭场景, 封闭生态, 对TS/JS代码能够控制住语法特性的使用.

  • 出于成本和time to market的需要, 选择singlepass这条路的话, 对于性能的优化, 还可以通过在C代码上做人工优化来达到, 但这最好是框架代码, 否则变动太多, 人工开销也不小. 比如说, 框架代码用AOT的方式编译成native和VM链接在一起, 而业务代码还是继续走解释器执行.

  • 一个更大胆的想法是, 如果以flutter或者dartvm这种形式能够成功, 那么一个基于TSVM, 并且AOT化的TypScript加上自己的UI框架和native渲染SDK, 是否也可以进入这个市场.

后续

  • TypeScript一些常见语言特性的静态编译实现

  • 第一个benchmark和动态类型语言AOT的性能来源

作者-wave,欢迎大家关注! 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Web面试那些事儿

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值