使用JavaScript实现一个“字节码解释器”,并用它重新实现JS科学计算器的后端(后续1)

时间:2021-09-09 17:07:11

字节码设计:第一版(仅仅用于表达式计算)

PushImm 123
Push R2
Pop R0
Mov src, dst #寄存器到寄存器
MovImm imm, reg #加载立即数到寄存器
CallPrimitiveFunction ‘+’ #ABI: 最多2个输入参数,R0,R1,输出结果在R0

核心JS代码(部分,CSDN博客不支持上传附件):

Assembler.prototype = {
  emitInstruction: function(inst) {
    //应用窥孔优化:如果当前指令是Pop,并且前一条指令是Push,且寄存器参数相同,则去除这一对指令
    if (inst.type=="Pop") {
      if (this.code_buffer.length>0) {
        var last_inst = this.code_buffer[this.code_buffer.length-1];
        if (last_inst.type=="Push" && inst.arg==last_inst.arg) {
          this.code_buffer.pop();
          return;
        }
      }
    }
    this.code_buffer.push(inst);
  },


  getResult: function() {
    return this.code_buffer;
  },


  toString: function() {
    function inst2str(inst) {
      return inst.type + " " + inst.arg
         + (!!inst.arg1 ? " "+inst.arg1 : "");
      //当前,由于设计简化的缘故,每个字节码指令最多只有1个参数
    }
    return this.code_buffer.map(function(inst){
      return inst2str(inst);
    }).join("\n");
  }
}

function BytecodeIntercepter() {
this.registers = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]; //0~15 ?
this.local_vars = []; //this is stack, we currently don't support nested call stack;
//this.sp = 0; //stack pointer; next pos to push
//如果我们需要Push/Pop以外的直接访问特定局部变量的话,则需要sp,否则local_vars本身就够了
//猜想:如果局部变量不会被重新赋值,即满足SSA,那么Push/Pop应该就够用了,?
}

BytecodeIntercepter.prototype = {
mapPrimitiveFunctionName2FunctionObject: function(name){
switch(name.toLowerCase()){
case '+': return function(a,b){return a+b};
case '-': return function(a,b){return a-b};
case '*': return function(a,b){return a*b};
case '/': return function(a,b){return a/b};
case 'sin': return function(a){return Math.sin(a)};
case 'cos': return function(a){return Math.cos(a)};
case 'tan': return function(a){return Math.tan(a)};
case 'cot': return function(a){return 1/Math.tan(a)};
case 'sqrt': return function(a){return Math.sqrt(a)};
case 'log': return function(a){return Math.log(a)};
case 'pow': return function(a,b){return Math.pow(a,b)};
default: throw "未识别的原语函数:"+name;
}
},
eval: function (code_buffer) {
for(var i=0; i<code_buffer.length; ++i) {
var inst = code_buffer[i];
switch(inst.type) {
case "PushImm":
this.local_vars.push(inst.arg);
break;
case "Push":
var reg_index = Number(inst.arg.substring(1));
assert(reg_index>=0 && reg_index<16);
this.local_vars.push(this.registers[reg_index]);
break;
case "Pop":
assert(this.local_vars.length>0);
var stack_top_localvar = this.local_vars.pop();
var reg_index = Number(inst.arg.substring(1));
assert(reg_index>=0 && reg_index<16);
this.registers[reg_index] = stack_top_localvar;
break;
case "MovImm":
var imm = inst.arg;
var reg_index = Number(inst.arg1.substring(1));
assert(reg_index>=0 && reg_index<16);
this.registers[reg_index] = imm;
break;
case "Mov":
var reg_index = Number(inst.arg.substring(1));
assert(reg_index>=0 && reg_index<16);
var reg_index1 = Number(inst.arg1.substring(1));
assert(reg_index1>=0 && reg_index1<16);
this.registers[reg_index1] = this.registers[reg_index];
break;
case "CallPrimitiveFunction":
//ABI: 原始函数接受至多2个寄存器输入,对应R0、R1,计算结果放在R0里
var arg1 = this.registers[0];
var arg2 = this.registers[1];//这里利用JS的一个特性简化代码!
var func = this.mapPrimitiveFunctionName2FunctionObject(inst.arg);
var result = func(arg1, arg2);
this.registers[0] = result;
break;
default:
throw "不支持的字节码指令类型:"+inst.type;
}
}
return this.registers[0]; //最后结果在R0里
}
}


编译器+字节码解释器思路的第一个版本实现,之前的测试用例都能通过,但是新的case出错了:

sin(1+2)+cos(3-4)-tan(5*6)

虽然当初我设想可以使用16个通用寄存器,但是真实现起来,才发现只用到R0 R1两个,并且我甚至把+-*/也当原语函数来实现的。

这里的原语函数把访问栈上的局部变量,只使用寄存器,感觉都有点像汇编里的宏或伪指令了。

嗯,是不是如果利用更多的寄存器,就是“lowering”?近来看v8 intercepter项目有类似提交。

另外,编译器与直接AST解释器的一个最大区别可以认为是:对局部变量不再有名字查找索引,而是相对于frame或sp的索引访问。这似乎就是“Context/Slot”的意思。

传统意义上,解释器的输入一般是源代码经过parse后的AST,对LISP语言而言,这个parse也省掉了。

但是假如先把代码/AST编译为bytecode,再对bytecode进行解释执行,这个思路就比较先进。Firefox的JS Monkey,以及QEMU,都使用了这种思路。

在这个层次上,字节码如何设计,可以节省Assembler生成的指令数;以及能否把某些解释执行进一步优化为机器指令的raw方式执行,是性能的关键。

现在,我虽然假设有16个通用寄存器,但实际上只用到R0 R1,导致了大量的Push/Pop操作,极大地浪费了不必要的字节码指令空间数目。

但是关键是先把它做对,然后再考虑进一步优化的问题。

进一步的考虑:
0、支持lowering,即考虑寄存器分配的问题,而不是一律将CallPrimitiveFunction的结果Push保存到栈上;
1、让表达式中支持变量,即允许子表达式赋值,以及后面的引用,子表达式之间用逗号或分号分隔;比如:var a=sin(1+2), cos(a*3)
   之所以可以用逗号分隔子表达式,是科学计算器里二元函数是中缀形式,如:3 pow 5
2、允许用户自定义函数(UDF),这个特性是一大跳跃,带来必须支持嵌套call stack的设想,当然,也许可以在生成字节码的过程中对UDF应用cps转换,这样可能就没有嵌套call stack了?


PS:在LLVM的术语中,lowering指的是用简单指令的组合来实现它不支持的高级指令。这跟我说的把栈上局部变量传参改为寄存器以提高性能不是一回事。

PS2:原来的代码并没有把表达式解析为AST的打算,因此对于优先级和结合律问题,体现在递归下降分析过程中就是先循环还是递归的问题。