欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  资讯频道

详解Firefox 3.5的新JavaScript引擎-TraceMonkey

程序员文章站 2022-03-03 12:01:24
...

本文作者为David Mandelin,──Mozilla JavaScript团队工作人员。

 

Firefox 3.5拥有一个全新的JavaScript引擎,叫做TraceMonkey,在该引擎上跑JS应用要比Firefox 3快到3-4倍,从而为现有的网络应用加速。这篇文章大致的描述一下在TraceMonkey中包括的重要部件,以及他们是如何加速JavaScript的。同样,在了解这些之后,你也就能知道什么样的网络应用通过TraceMonkey能够获得最大的提速,以及怎么样来写您的程序获得更大的性能提高。

为什么提速JS很难:动态类型

类似于JavaScript和Python这样的高级动态语言通常使得编程效率非常高,但是同类似Java或者C这样的静态语言相比,他们要慢一些。按照经验来说,一个JS程序通常要比同等的Java程序慢10倍。

像JS这样的动态语言比像Java或者C这样的静态语言慢主要有两个原因。第一个原因是在动态语言中,通常不太容易在执行之前知道值的类型信息。因此,语言本身必须使用一个通用的格式存储所有的值并且使用通用的操作来处理他们。

相反,在Java中,程序员声明变量和方法的类型,编译器可以在运行之前就知道这些值的类型。编译器可以使用特定的格式和操作来处理这些值,而这个过程要比通用的格式和操作快很多。下面,我会把这类操作叫做类型特定处理。

动态语言运行慢的第二个原因是,动态脚本语言通常实现为解释器,也就是由解释器执行;而静态语言通常被编译为原生代码。解释器可以很容易的构造,但是他们需要额外的运行时间来处理跟踪他们的内部状态。像Java这样的语言会被编译成机器语言,基本上是不需要内部运行状态跟踪的。

让我们用一张图片来描述的更具体。这里有一个简单的加法的分解动作示意图,我们来计算一下:a + b,这里ab均为整数。首先,我们忽略最右边的柱状图,集中在Firefox 3的JavaScript解释器和Java JIT执行效率的比较上。每个纵列都表示在某种语言上这个加法运算要做的分解动作。时间从上到下进行,每个不同颜色的盒子高度基本表示进行该分解动作需要的时间。

详解Firefox 3.5的新JavaScript引擎-TraceMonkey

在中间,Java简单的运行一条机器指令:“加”命令,运行时间为T(一个处理器周期)。因为Java编译器预先知道运算值为标准的机器整数类型,她可以直接使用标准的整数加法机器指令。完了。

在左边,SpiderMonkey(FF3中的JS解释器)需要大概40个T。棕色部分的盒子部分为整个解释器的开销:解释器需要读取加操作,然后跳到解释器的通用加操作代码部分。橘色的盒子部分表示由于解释器不知道运算值的类型造成的额外工作。解释器需要解开ab的通用描述格式,判断出他们的类型,选择特定的加法运算,把值转换为正确的类型,加法进行完之后,还需要把结果转换为通用的描述格式。

上面的图表显示,使用解释执行要比编译器慢一些,而使用不带任何类型信息的解释执行要慢很多。如果我们希望JS能够被运行的快一些,根据Amdahl定律,我们需要做点关于类型的事情。

通过跟踪获得类型

在TraceMonkey中,我们的目标是没有蛀牙,不对,我们的目标是编译出类型特定的代码。为了实现这个目标,TraceMonkey需要知道变量的类型。但是JavaScript本身是没有类型的,而我们前面也说过JS引擎基本上是无法在运行前知道类型的,但是我们还要在运行之前编译出本地代码,貌似无路可走了。

让我们换个角度来看这个问题。如果我们让程序在解释器中先跑一段,那么引擎就可以直接觉察到数值的类型了不是。然后,引擎可以使用这些类型来编译产生更快的类型特定的代码。最后,引擎就可以开始运行这些类型特定的代码,于是就运行的更快了~

这个想法还有几个关键的细节。首先,当程序运行时,即便有很多的if语句和其他的程序分支,他始终在其中一个分支上。所以,引擎不太有机会觉察到方法中所有数值的类型──引擎通过路径来观察数值,我们称之为轨迹 Traces。因此,标准的编译器是对整个方法或者过程进行编译,而TraceMonkey是针对轨迹进行编译。运行期轨迹编译有个好处是在轨迹上的函数调用是内联inline的,这使得轨迹上的函数调用非常的快。

第二,编译类型特定的代码是占用运行时间的。如果一块代码仅仅会运行一次或少量的几次──这在网页代码中比较常见──他可能会占用更多的时间来编译和运行反而可能还不如直接在解释器里面运行来的快了。所以,他应该只去编译热代码(被运行很多次的代码)。在TraceMonkey中,我们通过跟踪循环来安排热代码。TraceMonkey开始在解释器中运行所有代码,并开始为循环记录轨迹中的热代码。

仅跟踪热循环代码结果之一就是:只运行几次的代码并不会在TraceMonkey中得到提速。而这通常不会影响实际运行效果,因为仅运行几次的代码通常很快就完事了,你可能都不会注意到他。另一个结果是那些不热的循环代码基本上不被运行,也不会被编译,从而节省编译时间。

最后,上面我们提到TraceMonkey是通过观察执行过程来判断数值类型,基本上算是事后诸葛亮的做法,可能并不能保证将来的结果──更准缺点说算是事中诸葛亮:下次这部分代码被运行时,数值类型可能变了,或者第500次的时候开始变了也说不定。当我们已经产生好针对数字类型编译的代码之后,值变成字符串类型了,那就糟糕了。所以,TraceMonkey必须在编译代码中加入类型检查。如果没有通过检查,TraceMonkey必须离开当前轨迹,使用新类型重新编译这个轨迹上的代码。这就意味着拥有很多分支或者类型经常改变的代码在TraceMonkey中不一定能得到多少性能上的提高,因为他需要花费运行时间来编译多出来的轨迹或者从这个轨迹跳到那个等等。

TraceMonkey操作起来~

现在,我们通过一些示例来看一下TraceMonkey的实际操作情况:我们这里要完成的工作是把从1到N的整数都加到一个起始值上:

 function addTo(a, n) {
   for (var i = 0; i < n; ++i)
     a = a + i;
   return a;
 }
 
 var t0 = new Date();
 var n = addTo(0, 10000000);
 print(n);
 print(new Date() - t0);

TraceMonkey总是先在解释器中运行。每当程序开始循环迭代的时候,TraceMonkey开始进入监视模式来为循环执行增加计数器。在FF3.5中,当计数器涨到2时,该循环就会被认为属于热代码,于是开始了轨迹跟踪。

这时TraceMonkey继续在解释器中执行,但是会开始在代码运行时记录轨迹。该轨迹就是运行到循环结束的代码,包括使用的类型信息。类型通过实际运行中的数值来确定。在我们的例子中,执行这段JS语句的循环最后变成我们的轨迹:

    a = a + i;    // a is an integer number (0 before, 1 after)
    ++i;          // i is an integer number (1 before, 2 after)
    if (!(i < n)) // n is an integer number (10000000)
      break;

轨迹如果用JavaScript描述的话就是上面这个样子的。但是TraceMonkey需要更多的信息来编译该轨迹。真实的轨迹可能更像下面这个样子:

  trace_1_start:
    ++i;            // i is an integer number (0 before, 1 after)
    temp = a + i;   // a is an integer number (1 before, 2 after)
    if (lastOperationOverflowed())
      exit_trace(OVERFLOWED);
    a = temp;
    if (!(i < n))   // n is an integer number (10000000)
      exit_trace(BRANCHED);
    goto trace_1_start;

这段轨迹描述了一个循环,也会被编译为一个循环,于是我们直接使用goto语句来描述这种结构。同样,整数加法可能会溢出,需要额外的处理(例如,转换为浮点数加法来处理)从而跳出该轨迹。所以,轨迹必须包括溢出检查。最后,在到达最后的循环条件时,也要跳出轨迹。exit代码告诉TraceMonkey从轨迹中跳出,TraceMonkey就可以决定之后执行什么样的操作,例如重新做加法(溢出了?)还是循环结束了。需要注意的是,轨迹是使用特定的内部格式来记录的,不会报漏给程序员──上面的伪代码只是为了说明整个过程。

在记录之后,轨迹就可以被编译成类型特定的机器代码了。整个编译是由一个很小的JIT编译器(名字叫做nanojit)完成,结果被存储在内存中,准备好被CPU执行。

在解释器下一次进入循环头部时,TraceMonkey会开始执行编译好的轨迹。程序开始快速的执行。

在第65537次迭代中,整数加法将会溢出(2147450880 + 65537 = 2147516417,结果超过了2^31-1 = 2147483647,最大的32位整数。)在这个时候,TraceMonkey从轨迹中跳出并带有OVERFLOWED的标记。TraceMonkey看到这个标记后,会返回到解释模式并重新做加法运算。因为解释器里面采用完全通用的方式来处理任何事情,溢出在这里会得到处理并返回正常的结果。TraceMonkey会开始监控这个跳出点,如果溢出跳出点变成热点的话,一个全新的轨迹会从这里开始。

接下来,程序重新经过循环的开始。TraceMonkey会知道在这里他已经有一个编译好的轨迹,但是他也知道目前不能使用这个轨迹,因为该轨迹是针对整数编译的,在运算溢出之后,a被重新包装成浮点数类型。所以,TraceMonkey会记录一个全新的轨迹:

  trace_2_start:
    ++i;            // i is an integer number
    a = a + i;      // a is a floating-point number
    if (!(i < n))   // n is an integer number (10000000)
      exit_trace(BRANCHED);
    goto trace_2_start;

TraceMonkey接下来会编译新的轨迹,在下一次循环迭代中,开始执行编译好的代码。这里,TraceMonkey保证JavaScript以机器代码的方式被执行,即便类型发生变化。最后,轨迹结束并跳出,带有BRANCHED标记。在这个时候,TraceMonkey会再次回到解释器,解释器重新接管直到程序结束。

下面是该程序在我笔记本上的运行时间记录(2.2GHz MacBook Pro):

系统 运行时间(毫秒)
SpiderMonkey (FF3) 990
TraceMonkey (FF3.5) 45
Java (使用 int) 25
Java (使用 double) 74
C (使用 int) 5
C (使用 double) 15

通过使用轨迹跟踪,上面的程序可以获得22倍的速度提升,而且基本上跟Java运行的一样快了!在循环中做简单数学运算的函数通常会通过轨迹跟踪获得很大的速度提升。很多位运算和数学原算,例如bitops-3bit-bits-in-bytectrypto-sha1math-spectral-norm通常能够获得6-22倍的提速。

使用复杂运算的函数,例如对象操控,通常只会获得2-5倍的提速。这符合Amdahl定律:复杂运算需要更长时间。回过头来看看前面的图表,考虑更为复杂的操作,比如需要在绿色实际执行区域内大概需要30个T的运行时间,橘色和棕色的区域还是30个T,这些都加起来,基本上也就是2倍速度的提升。SunSpider benchmark中的string-fasta是属于这类的程序:大部分时间都会被花费在字符串操作上,绿色盒子部分的高度会比较大。

下面是我们的例子程序,你可以在浏览器中尝试一下:

<script type="text/javascript"> &lt;!-- function addTo(a, n) { for (var i = 0; i &lt; n; ++i) a = a + i; return a; } var dts = 0; var dtn = 0; function runExample() { var t0 = new Date; var n = addTo(0, 10000000); var dt = new Date-t0; dts += dt; dtn += 1; var avg = Math.round(dts/dtn); document.getElementById('example_result').innerHTML = n + ''; document.getElementById('example_time').innerHTML = dt + ' ms'; document.getElementById('example_avg').innerHTML = avg + ' ms'; } //--&gt;</script>

运行示例

数值结果:

运行时间:

平均运行时间:

理解和解决性能问题

我们的目标是要让TraceMonkey更快更稳定,可以让您使用自己最想用的方式来书写程序,完全不用去担心性能。如果TraceMonkey并没有提升你的程序运行速度,我们希望你能够提交个Bug给我们让我们来改进引擎。在这部分里,我们会介绍一些工具和技术来解决性能问题──假如您的程序连2倍的提升都没有那基本上算是性能问题了。(当然您需要开启JIT编译选项,通过浏览器中打开about:config,然后把选项javascript.options.jit.content设定为true即为开启)。

第一步,需要正确的理解造成问题的原因。最普遍的原因是轨迹中断,意思是TraceMonkey无法完成记录一个轨迹,只好放弃了。通常的结果是包含中断的循环重新在解释器中执行,所以你在循环中的性能并不会得到任何提高。有时候,在循环中某个路径被轨迹化了,但是在另外一条路径上是中断的,这会使TraceMonkey在解释执行和执行本地代码之间来回切换。这可能会给你的提速打个折扣,或者根本没有提速,或者还拖慢了你的速度:TraceMonkey在模式切换中会花费一些时间,所以快速切换会造成很糟糕的性能。

使用debug版本的浏览器或者使用JS shell程序(我自己做了一个──我们不发布这一类构建版本),你可以让TraceMonkey把关于中断的信息打印出来。需要设定TMFLAGS环境变量,我经常像下面这样做:

TMFLAGS=minimal,abort
dist/MinefieldDebug.app/Contents/MacOS/firefox

minimal选项表示打印出所有轨迹记录开始和成功结束的点。这可以给大家一个很直观的印象,究竟跟踪器在试图做什么。abort选项表示打印出所有被由于不支持结构体造成的中断发生点。(设定TMFLAGS=help会打印出其他支持的TMFLAGS选项然后退出。)

(需要注意TMFLAGS同样可以用来打印debug信息。如果您正在使用debug版本的FF3.5,环境变量需要设定为TRACEMONKEY=abort。)

下面这个例子就不会得到很多的提速:

<script type="text/javascript"> &lt;!-- function runExample2() { var t0 = new Date; var sum = 0; for (var i = 0; i &lt; 100000; ++i) { sum += i; } var prod = 1; for (var i = 1; i &lt; 100000; ++i) { eval("prod *= i"); } var dt = new Date - t0; document.getElementById('example2_time').innerHTML = dt + ' ms'; } //--&gt;</script>

function runExample2() {
  var t0 = new Date;
 
  var sum = 0;
  for (var i = 0; i < 100000; ++i) {
    sum += i;
  }
 
  var prod = 1;
  for (var i = 1; i < 100000; ++i) {
    eval("prod *= i");
  }
  var dt = new Date - t0;
  document.getElementById(example2_time‘).innerHTML = dt + ‘ ms‘;
}
运行例子2
运行时间:

如果我们设定TMFLAGS=minimal,abort,我们会得到下面的报告:

Recording starting from ab.js:5@23
recording completed at  ab.js:5@23 via closeLoop
 
Recording starting from ab.js:5@23
recording completed at  ab.js:5@23 via closeLoop
 
Recording starting from ab.js:10@63
Abort recording of tree ab.js:10@63 at ab.js:11@70: eval.
 
Recording starting from ab.js:10@63
Abort recording of tree ab.js:10@63 at ab.js:11@70: eval.
 
Recording starting from ab.js:10@63
Abort recording of tree ab.js:10@63 at ab.js:11@70: eval.

最开始的两对显示第一次循环,在第5行开始,被跟踪的很好。接下来的几行显示TraceMonkey开始在第10行跟踪循环,但是由于eval每次都失败了。

这个debug输出中包含一条很重要的信息,就是你会看到很多的消息在引用不断增加的内部树状结构等等。这些不是什么问题:他们通常表示在完成跟踪一个循环过程中的延迟时间,因为TraceMonkey在连接内部和外部的循环。事实上,如果继续看这些输入信息,您通常会看到循环到最后会开始进行跟踪。

否则,这些中断基本上都是由不支持的JavaScript结构造成的。针对像+这样的基本操作实现跟踪支持是很容易实现的。我们目前没有足够的事件去为每个最新的JavaScript特性提供可靠的安全的跟踪支持,所以像参数等比较高级的咚咚还无法在FF3.5中被跟踪。其他没有被支持的高级特性还包括getter和setter,with和eval。目前有部分对闭包的支持,不过要取决于他们是怎么被使用的。对程序进行重构避免使用这些结构可以帮助提升性能。

还有两个特别的JavaScript特性不支持被跟踪:

  • 递归。TraceMonkey不会把按照递归方式发生的迭代识别为循环,所以他不会尝试去跟踪他。重构代码,显示的使用for或者while可以提供更好的性能。
  • 读取或者设置DOM属性。(DOM方法调用没有问题。)想避免这样的用法基本上是不可能的,但是把这部分代码重构为把DOM属性访问的代码移到热循环和性能非常重要的区域会好很多。

我们现在正积极的争取实现上述特性的跟踪。例如,对参数的跟踪支持已经包含在Nightly构建版本中了。

下面的例子就重构为避免使用eval。当然,我可以更简单的使用内联方式做乘法。相反我使用了利用eval的函数,因为这是一个比较常用的方式来重构eval。注意eval还是不能被跟踪,但是他只运行一次所以没关系。

<script type="text/javascript"> &lt;!-- function runExample3() { var t0 = new Date; var sum = 0; for (var i = 0; i &lt; 100000; ++i) { sum += i; } var prod = 1; var mul = eval("(function(i) { return prod * i; })"); for (var i = 1; i &lt; 100000; ++i) { prod = mul(i); } var dt = new Date - t0; document.getElementById('example3_time').innerHTML = dt + ' ms'; } //--&gt;</script>

function runExample3() {
  var t0 = new Date;
 
  var sum = 0;
  for (var i = 0; i < 100000; ++i) {
    sum += i;
  }
 
  var prod = 1;
  var mul = eval("(function(i) { return prod * i; })");
 
  for (var i = 1; i < 100000; ++i) {
    prod = mul(i);
  }
  var dt = new Date - t0;
  document.getElementById(‘example3_time’).innerHTML = dt + ‘ ms’;
}
运行例子3
运行时间:

还有一些比较隐蔽的情况可能会影响到跟踪的性能。其中之一被称作跟踪爆炸,当在循环中有很多路径时可能会发生。假设一个循环中连着有10条if语句:那整个循环体有1024条路径,可能会产生1024条轨迹要被记录。这会使用大量的内存,所以TraceMonkey会为每个循环最多记录32条轨迹。如果循环中少于32条热轨迹,他会表现的很好。但是如果每个路径按照相同的概率发生,那么仅有3%的路径会被跟踪,性能会大打折扣。

这类问题可以使用TraceVis来分析,他可以生成TraceMonkey性能的可视化效果。目前,构建系统仅支持为Shell版本开启TraceVis,但是基本的系统还可以运行在浏览器中,目前也有正在进行的工作致力于在浏览器中更方便的开启TraceVis。

这里有篇讨论TraceVis的文章可能是目前对TraceVis最全面的解释,包括图表中各种标识的含义以及如何分析性能问题等。这篇文章同样包括一个详细的分析可以帮助更好的理解TraceMonkey的工作远离。

同类比较

下面我们来简单的同其他JavaScript JIT设计进行一下比较。我可能更多是假设性的比较,因为我不是很了解他们的细节──只是阅读过一些发布信息大致看过几眼代码。

有一个设计备选可能被叫做per-method non-specializing JIT,意思是一个JIT编译器在运行期编译方法并产生通用的代码,跟解释器做的事情是一样的。因此,我们上面图标中的褐色部分就可以被去掉了。这一类JIT不需要花费时间来记录和编译轨迹,但是他也没有做类型特化的工作,所以,橘色的盒子部分还是要有的。这样的引擎可以通过巧妙的设计橘色盒子部分并对其调优,同样可以拥有很惊人的性能提升。但是由于橘色部分完全不能被去掉,所以数值程序最大的性能肯定不会优于类型特定的引擎。

在写这篇文章的时候,Nitro和V8都是轻量的非类型特化的JIT。(据说,V8会通过查看源代码试图猜测很少的几类类型信息,例如猜测在代码a >> 2中,a可能是个整数,从而来做部分类型特定操作。)这样看来,TraceMonkey在数值部分的表现应该更快一些。但是TraceMonkey会受一些使用对象的benchmark影响,因为我们的对象操作和内存管理还没有很好的被优化。

基础JIT一个更远的方案被叫做per-method type-specializing JIT。这一类JIT会根据方法调用中的参数类型来尝试类型特化该方法。同TraceMonkey类似,这需要运行期观察:在方法每次被调用时,需要检查参数的类型,如果之前没有见过这些类型,编译一个全新版本的方法。同样根TraceMonkey类似,这个设计可以很大程度上特化代码,并去掉上面图标中的褐色和橘色盒子部分。

我目前还不知道有谁在为JavaScript开发这类per-method type-specializing JIT,但是如果有人在做的话,我不会吃惊的。

per-method type-specializing JIT同跟踪JIT相比较,一个很大的缺点是基本的per-method JIT仅仅直接观察方法的输入参数。他必须利用算法推导出方法内部变量的类型信息,这一点很难,尤其是如果方法读取对象属性的话。因此,可能会有per-method type-specializing JIT在方法局部使用一些通用操作来完成。per-method设计的好处主要是方法仅需要为输入类型编译一套就好了,所以他不会有跟踪爆炸这样的问题。而且,我认为per-method JIT在处理包含很多路径的方法上性能更好,而跟踪JIT在处理类型非常分明的方法上更快,尤其是方法从对象属性中读取了很多值时。

结论

希望到这里您能够对如何加速JavaScript引擎,TraceMonkey怎么个工作原理,以及如何分析和解决在TraceMonkey上运行JavaScript时可能有的性能问题都有个基本的了解。如果您遇到任何性能问题,请帮忙报个Bug。Bug报告能够帮助我们不断的去调整引擎的性能。最后,我们不断的改进,如果您非常愿意尝鲜的话,不妨使用Nightly构建的TraceMonkey。

 

转载自:颠覆网络35天 ─ TraceMonkey(MJiA

 

相关标签: JavaScript Firefox