原文链接 https://blogg.bekk.no/how-javascript-engines-achieve-great-performance-fb0b36601557

JavaScript is an impressive technology. Not because it’s particularly well-designed (it isn’t). Not because almost every single consumer device with internet access in the world has executed a JavaScript program. Instead, JavaScript is impressive because almost every single feature of the language makes it a nightmare to optimize and yet, it is fast.

JavaScript是一门令人印象深刻的技术。不是因为它优雅的设计(不,它并没有), 也不是因为js的程序可以运行在世界上所有独立的网络设备中。相反,js让人印象深刻是因为至今为止,这门语言每个单一特性拿出来都很难在进一步去优化,它非常快。

Think about it. There is no type information. Every single object can gain and lose properties over the lifetime of the program. There are six(!) different kinds of falsy values, and every number is a 64-bit float. As if that wasn’t enough, JavaScript is expected to execute quickly, so you can’t spend a lot of time analyzing and optimizing it either.
And yet, JavaScript is fast.
How can this be?

仔细想想,js中没有类型信息,在程序的生命周期中,我们可以对js的每一个对象进行属性的添加或删除。这里有六种不同类型的伪值(基础值),它的number都是64位浮点数。js这门语言的设计初衷就是为了能快速运行,事实上它的确也很快。
那么它是怎么做到这么快的呢?

In this article, we’re going to look closer at a few techniques that different JavaScript engines use to achieve good runtime performance. Keep in mind that I’m purposefully leaving a few details out, and simplifying things. It’s not a goal of this article that you learn how things work exactly, but that you understand enough to comprehend the theory behind the experiments we’ll perform later in this series.

在这篇文章里,我们会介绍几种不同的JavaScript引擎是如何实现良好的运行时性能的。注意,我会去掉一些细节信息,将流程进行简化。本文的目标不是为了让你学习它们的实现细节,而是让你能理解其背后的原理。

The execution model

执行模型

When your browser downloads JavaScript, its top priority is to get it running as quickly as possible. It does this by translating the code to bytecode, virtual machine instructions, which is then handed over to an interpreter, or virtual machine, that understands how to execute them.

浏览器下载JavaScript时,第一优先级是使下载的JS脚本尽快运行。通过将代码转换为解释器可以理解的字节码,或是虚拟机可以执行的虚拟机指令,使其能快速运行。

You might question why the browser would convert JavaScript to virtual machine instructions instead of actual machine instructions. It’s a good question. In fact, converting straight to machine instructions is what V8 (Chrome’s JavaScript engine) used to do until recently.

你可能会疑惑为什么浏览器是将JS转换成虚拟机指令,而不直接将转换成实际机器指令?这是个好问题,实际上,V8(Chome的JS引擎)最近已经直接将代码转为机器指令了。

A virtual machine for a specific programming language is usually an easier compilation target because it has a closer relation to the source language. An actual machine has a much more generic instruction set, and so it requires more work to translate the programming language to work well with those instructions. This difficulty means compilation takes longer, which again means it takes longer for the JavaScript to start executing.

一门语言编译成的虚拟指令跟语言本身的源码更为接近,所以编译起来更容易些。而实际指令拥有更通用的指令集,所以需要花费更多的时间在编译上,依旧意味着这种做法会花费更多的编译时间才能让JS运行起来。

As an example, a virtual machine that understands JavaScript is also likely to understand JavaScript objects. Because of this, the virtual instructions required to execute a statement like object.x might be one or two instructions. An actual machine, with no understanding of how JavaScript objects work, will need a lot more instructions to figure out where .x resides in memory and how to get it.

举个例子,虚拟器理解JavaScript是以对象级理解的,因此,执行像Object.x等语句所需的虚拟指令可能是一个或两个指令。而实际机器无需了解JavaScript对象如何工作,需要更多的指令来弄清楚.x驻留在内存中以及如何获得它。(唉,其实我懂它的意思,但是翻译出来好奇怪)

The problem with a virtual machine is that it is, well, virtual. It doesn’t exist. The instructions cannot be executed directly, but must be interpreted at runtime. Interpreting code will always be slower than executing code directly.

虚拟机的问题是它无法直接执行指令,这些指令在运行时需要依靠解释器执行。所以解释代码会比直接执行代码更慢。

There’s a tradeoff here. Faster compilation time versus faster runtime. In many cases, faster compilation is a good tradeoff to make. The user is unlikely to care whether a single button click takes 20 or 40 milliseconds to execute, especially if the button is only pressed once. Compiling the JavaScript quickly, even if the resulting code is slower to execute, will let the user see and interact with the page faster.

编译速度和运行速度,我们需要在二者间做一个权衡。多数场景下,用户不会关心点击按钮时花了20ms还是40ms去执行代码,但JS代码被快速编译,就意味着用户能快速看到页面并可以进行交互,所以我们选择优先提升编译速度。

There are situations that are computationally expensive. Stuff like games, syntax highlighting or calculating the fizzbuzz string of a thousand numbers. In these cases, the combined time of compiling and executing machine instructions is likely to reduce the total execution time. So how does JavaScript handle these kinds of situations?

当然也有一些场景的编译代价是很高的。比如游戏、语法高亮、计算拥有一千个数字的fizzbuzz字符串。此时编译成机器指令执行可以降低总执行时间,那么JavaScript是怎么处理的呢?

Hot code

热代码

Whenever the JavaScript engine detects that a function is hot (that is, executed many times) it hands that function over to an optimizing compiler. This compiler translates the virtual machine instructions into actual machine instructions. What’s more, since the function has already been run several times, the optimizing compiler can make several assumptions based on previous runs. In other words, it can perform speculative optimizations to make even faster code.

What happens if, later on, these speculations turn out to be wrong? The JavaScript engine can simply delete the optimized, but wrong, function, and revert to using the unoptimized version. Once the function has been run several more times, it can attempt to pass it to the optimizing compiler again, this time with even more information that it can use for speculative optimizations.

Now that we know that frequently run functions use information from previous executions during optimization, the next thing to explore is what kind of information this is.

A problem of translation

一个翻译问题

Almost everything in JavaScript is an object. Unfortunately, JavaScript objects are tricky things to teach a machine to deal with. Let’s look at the following code:

  1. function addFive(obj) {
  2. return obj.method() + 5;
  3. }

A function is pretty straightforward to translate to machine instructions, as is returning from a function. But a machine doesn’t know what objects are, so how would you translate accessing the method property of obj?

It would help to know what obj looks like, but in JavaScript we can never really be certain. Any object can have a method property added to, or removed from, it. Even when it does exist, we cannot actually be certain if it is a function, much less what calling it returns.

Let’s attempt to translate the above code to a subset of JavaScript that doesn’t have objects, to get an idea of what translating to machine instructions might be like.

First, we need a way to represent objects. We also need a way to retrieve values from one. Arrays are trivial to support in machine code, so we might go with a representation like this:

  1. // An object like { method: function() {} }
  2. // could be represented as:
  3. // [ [ "method" ], // property names
  4. // [ function() {} ] ] // property values
  5. function lookup(obj, name) {
  6. for (var i = 0; i < obj[0].length; i++) {
  7. if (obj[0][i] === name) return i;
  8. }
  9. return -1;
  10. }

With this, we can attempt to make a naive implementation of addFive :

  1. function addFive(obj) {
  2. var propertyIndex = lookup(obj, "method");
  3. var property = propertyIndex < 0
  4. ? undefined
  5. : obj[1][propertyIndex];
  6. if (typeof(property) !== "function") {
  7. throw NotAFunction(obj, "method");
  8. } var callResult = property(/* this */ obj);
  9. return callResult + 5;
  10. }

Of course, this doesn’t work in the case where obj.method() returns a something other than a number, so we need to tweak the implementation a little:

  1. function addFive(obj) {
  2. var propertyIndex = lookup(obj, "method");
  3. var property = propertyIndex < 0
  4. ? undefined
  5. : obj[1][propertyIndex];
  6. if (typeof(property) !== "function") {
  7. throw NotAFunction(obj, "method");
  8. } var callResult = property(/* this */ obj);
  9. if (typeof(callResult) === "string") {
  10. return stringConcat(callResult, "5");
  11. } else if (typeof(callResult !== "number") {
  12. throw NotANumber(callResult);
  13. }
  14. return callResult + 5;
  15. }

This would work, but I hope it’s apparent that this code could skip a few steps (and thus be faster) if we could somehow know ahead of time what the structure of obj is, and what the type of method is.

Hidden classes

隐藏类

All the major JavaScript engines keep track of an object’s shape in some way. In Chrome, this concept is known as hidden classes. It’s what we will call it in this article as well.

Let’s start by looking at the following snippet of code:

  1. var obj = {}; // empty object
  2. obj.x = 1; // shape has now changed to include a `x` property
  3. obj.toString = function() { return "TODO"; }; // shape changes
  4. delete obj.x; // shape changes again

If we were to translate this to machine instructions, how would we keep track of the object’s shape as new properties are added and removed? If we use the previous example’s idea of representing objects as arrays, it might look something like this:

  1. var emptyObj__Class = [
  2. null, // No parent hidden class
  3. [], // Property names
  4. [] // Property types
  5. ];
  6. var obj = [
  7. emptyObj__Class, // Hidden class of `obj`
  8. [] // Property values
  9. ];
  10. var obj_X__Class = [
  11. emptyObj__Class, // Contains same properties as empty object
  12. ["x"], // As well as one property called `x`
  13. ["number"] // Where `x` is a number
  14. ];
  15. obj[0] = obj_X__Class; // Shape changes
  16. obj[1].push(1); // value of `x`
  17. var obj_X_ToString__Class = [
  18. obj_X__Class, // Contains same properties as previous shape
  19. ["toString"], // And one property called `toString`
  20. ["function"] // Where `toString` is a function
  21. ];
  22. obj[0] = obj_X_ToString__Class; // shape change
  23. obj[1].push(function() { return "TODO"; }); // `toString` value
  24. var obj_ToString__Class = [
  25. null, // Starting from scratch when deleting `x`
  26. ["toString"],
  27. ["function"]
  28. ];
  29. obj[0] = obj_ToString__Class;
  30. obj[1] = [obj[1][1]];

If we were to generate virtual machine instructions like this, we now would have a way to track what an object looks like at any given time. However, this on its own doesn’t really help us. We need to store this information somewhere where it would be valuable.

Inline caches

Whenever JavaScript code performs property access on an object, the JavaScript engine stores that object’s hidden class, as well as the result of the lookup (the mapping of property name to index) in a cache. These caches are known as inline caches, and they serve two important purposes:

  • When executing bytecode, they speed up property access if the object involved has a hidden class that is in the cache.
  • During optimization, they contain information about what type of objects that have been involved when accessing an object property, which helps the optimizing compiler generate code specially suited for those types.

Inline caches have a limit on how many hidden classes they store information on. This preserves memory, but also makes sure that performing lookups in the cache is fast. If retrieving an index from the inline cache takes longer than retrieving the index from the hidden class, the cache serves no purpose.
From what I can tell, inline caches will keep track of 4 hidden classes at most, at least in Chrome. After this, the inline cache will be disabled and the information will instead be stored in a global cache. The global cache is also limited in size, and once it has reached its limit, newer entries will overwrite older ones.
To best utilize inline caches, and aid the optimizing compiler, one should try to write functions that only perform property access on objects of a single type. More than that and the performance of the generated code will be sub-optimal.

Inlining

A separate, but significant, kind of optimization is inlining. In short, this optimization replaces a function call with the implementation of the called function. An example:
function map(fn, list) {
var newList = [];
for (var i = 0; i < list.length; i++) {
newList.push(fn(list[i]));
}

return newList;
}

function incrementNumbers(list) {
return map(function(n) { return n + 1; }, list);
}

incrementNumbers([1, 2, 3]); // returns [2, 3, 4]
After inlining, the code might end up looking something like this:
function incrementNumbers(list) {
var newList = [];
var fn = function(n) { return n + 1; };
for (var i = 0; i < list.length; i++) {
newList.push(fn(list[i]));
} return newList;
}

incrementNumbers([1, 2, 3]); // returns [2, 3, 4]
One benefit of this is that a function call has been removed. An even bigger benefit is that the JavaScript engine now has even more insight into what the function actually does. Based on this new version, the JavaScript engine might decide to perform inlining again:
function incrementNumbers(list) {
var newList = [];
for (var i = 0; i < list.length; i++) {
newList.push(list[i] + 1);
}

return newList;
}

incrementNumbers([1, 2, 3]); // returns [2, 3, 4]
Another function call has been removed. What’s more, the optimizer might now speculate that incrementNumbers is only ever called with a list of numbers as an argument. It might also decide to inline the incrementNumbers([1, 2, 3]) call itself, and discover that list.length is 3, which again might lead to:
var list = [1, 2, 3];
var newList = [];
newList.push(list[0] + 1);
newList.push(list[1] + 1);
newList.push(list[2] + 1);
list = newList;
In short, inlining enables optimizations that would not have been possible to perform across function boundaries.
There are limits to what can be inlined, however. Inlining can lead to larger functions due to code duplication, which requires additional memory. The JavaScript engine has a budget on how big a function can get before it skips inlining altogether.
Some function calls are also difficult to inline. Particularly when a function is passed in as an argument.
In addition, functions passed as arguments can be difficult to inline unless it’s always the same function. While that might strike you as a strange thing to do, it might end up being the case because of inlining.

Conclusion

总结

JavaScript engines have many tricks to improve runtime performance, many more than what has been covered here. However, the optimizations described in this article apply to most browsers, and are easy to verify if they’re being applied. Because of this, we’re mainly going to be focusing on these optimizations when we attempt to improve Elm’s runtime performance.
But before we start trying to optimize anything, we need a way of identifying what code can be improved. The tools that give us this information, is the topic of the next article.

JS引擎还做了很多事情去提升运行时性能,还有一些是本文中没有提到的。不过,本文中提到的优化方式适用于大多数浏览器,并且很容易验证它们是否得到了应用。所以,当我们需要去优化运行性能时,可以重点关注这些优化。
在开始优化前,我们需要分析代码中哪些地方是可以优化的,这里有一些工具可以为我们提供信息,具体分析方式会在下一篇文章中介绍。

Further references

I’m not the first to try to explain how JavaScript engines work. Here are a few articles that go more in depth, and which have a different way of explaining similar concepts: