chaussen

JavaScript优化模式(一)——作者Benedikt Meurer

原文链接: benediktmeurer.de

JavaScript优化模式(一)——作者Benedikt Meurer

已经有一段时间没有在博客上发帖了,主要是因为我真的没时间精力坐下来,把想写的东西写出来。一部分原因是Chrome浏览器59版V8引擎的点火装置(Ignition)翻译器和涡轮风扇(TurboFan)编译器启动工作搞得我相当得忙,还好目前看来算是一个巨大的成功。不过还有一部分原因是我抽了些时间陪家人。最后一点,我还去了欧盟JS大会(JSConf EU)Web Rebels活动。写这篇帖子的同时,我还在参加enterJS的活动,磨蹭着为我的发言稿作最后修整。

与此同时,我刚Brian TerlsonAda Rose EdwardsAshely Williams一起吃饭回来。我们讨论了一下JavaScript中有什么良好的优化模式,思考了一下什么样的建议给别人最没风险,还特别谈到想出这些建议有多难,很有意思。我特别提出了一点,理想的性能常常取决于代码运行的背景环境,而这部分通常都是最难的,所以我觉得可能这个信息值得与大家分享一下。这个帖子是我博客上一系列帖子的开始,在这个第一部分里,我要强调一下具体的执行环境对JavaScript代码的性能会有多大的影响。

想一下以下的自制类点(Point),它有一个方法叫距离(distance),能计算两个点之间的曼哈顿距离(Manhatten distance)

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  distance(other) {
    const dx = Math.abs(this.x - other.x);
    const dy = Math.abs(this.y - other.y);
    return dx + dy;
  }
}

另外,再考虑一下下面的测试(test)驱动函数,它创建几个点(Point)实例,并计算点之间的距离(distance)几百万次,把结果相加。好吧,我知道这算一个微基准程序(micro-benchmark),不过等会再说:

function test() {
  const points = [
    new Point(10, 10),
    new Point(1, 1),
    new Point(8, 9)
  ];
  let result = 0;
  for (let i = 0; i < 10000000; ++i) {
    for (const point1 of points) {
      for (const point2 of points) {
        result += point1.distance(point2);
      }
    }
  }
  return result;
}

点(Point)这个类,尤其是它的距离(distance)方法现在就有了合适的基准函数。让我们来运行几次这个测试(test)驱动程序,看看性能如何。用以下HTML代码片断:

<script>
    function test() {
        class Point {
            constructor(x, y) {
                this.x = x;
                this.y = y;
            }

            distance(other) {
                const dx = Math.abs(this.x - other.x);
                const dy = Math.abs(this.y - other.y);
                return dx + dy;
            }
        }

        const points = [
            new Point(10, 10),
            new Point(1, 1),
            new Point(8, 9)
        ];
        let result = 0;
        for (let i = 0; i < 10000000; ++i) {
            for (const point1 of points) {
                for (const point2 of points) {
                    result += point1.distance(point2);
                }
            }
        }
        return result;
    }

    for (let i = 1; i <= 5; ++i) {
        console.time("test " + i);
        test();
        console.timeEnd("test " + i);
    }
</script>

如果是在Chrome浏览器61版(canary)中运行,那么在Chrome的开发者工具终端中就会看到以下输出结果:

test 1: 595.248046875ms
test 2: 765.451904296875ms
test 3: 930.452880859375ms
test 4: 994.2890625ms
test 5: 3894.27392578125ms

每一轮测试的性能结果都有很大区别,可以看到越往后性能越差。性能变差的原因是点(Point)这个类处于测试(test)函数的内部。

<script>
    class Point {
        constructor(x, y) {
            this.x = x;
            this.y = y;
        }

        distance(other) {
            const dx = Math.abs(this.x - other.x);
            const dy = Math.abs(this.y - other.y);
            return dx + dy;
        }
    }

    function test() {
        const points = [
            new Point(10, 10),
            new Point(1, 1),
            new Point(8, 9)
        ];
        let result = 0;
        for (let i = 0; i < 10000000; ++i) {
            for (const point1 of points) {
                for (const point2 of points) {
                    result += point1.distance(point2);
                }
            }
        }
        return result;
    }

    for (let i = 1; i <= 5; ++i) {
        console.time("test " + i);
        test();
        console.timeEnd("test " + i);
    }
</script>

我们把这个片断稍微改一下,将点(Point)类的定义放到测试(test)函数外部,结果就不同了:

test 1: 598.794921875ms
test 2: 599.18115234375ms
test 3: 600.410888671875ms
test 4: 608.98388671875ms
test 5: 605.36376953125ms

现在,性能基本稳定,起伏也在正常范围内。注意,在这两个例子里,点(Point)类的代码和测试(test)驱动函数的逻辑完全相同。唯一不同的是到底点(Point)这个类放在代码中的什么位置。

全局类对本地类

另外值得注意的是这个和新的ES2015版本类(class)的定义句法无关。用旧的ES5版本句法格式来定义点(Point)类也会产生同样的性能结果:

function Point(x, y) {
  this.x = x;
  this.y = y;
}
Point.prototype.distance = function (other) {
  var dx = Math.abs(this.x - other.x);
  var dy = Math.abs(this.y - other.y);
  return dx + dy;
}

点(Point)类处于测试(test)函数内部时,性能会有差异,其根本原因在于类(class)这个字面变量被多次执行。在上面的例子里,类正好被定义了5次。而当类处于测试(test)函数外部时,定义只执行一次。每执行一次类(class)定义,就会生成一个新的原型对象,它带有这个类的所有方法。此外,还产生了一个与类(class)相对应的新构造(constructor)函数,那个原型对象就成了这个构造函数的"原型(prototype)"属性。

全局类对本地类

以这个"原型(prototype)"属性作为原型对象,类的新实例就此生成。但因为V8引擎会追溯每个实例的原型,把这个原型作为对象形态(object shape)隐藏类(hidden class)的一部分。(关于这一点,参见V8引擎下设置原型,有详细解释) 为了优化原型链上属性的访问,生成的原型不同自然意味着生成的对象形态也不同。因而,当类(class)定义执行了多次时,生成的代码就会变得更多态。最终,V8看到有超过4种不同的对象形态,就会放弃多态,进入一种所谓的超态(megamorphic)状态,这就意味着它基本上不再生成高度优化的代码了。

所以从这个练习我们可以知道:相同的代码,只是放在略微不同的位置就可能很轻易地造成6.5倍的性能差异!知道这一点是极其重要的。因为那些常用的基准测试框架和网站如esbench.com在运行代码时,代码很可能是处在另一个环境中,与你的程序环境并不相同。也就是说,那些网站把代码包裹在底层函数中,而底层函数又运行了多次。这样做出来基准性能测试结果就可能有相当高的误导性。

V8引擎:幕后揭秘(三月份版,重点介绍点火装置 + 涡轮风扇的启动和声明型JavaScript) 上一篇)