从贺老微博引出的“遍历器(Iterators)加速那些奥秘”

我关注的贺老—贺师俊前辈@johnhax 最近发表个这样一条微博:

原微博

虽然这条微博没有引起大范围的关注和讨论,但是作为新人,我陷入了思考。究竟 V8 引擎做了哪些魔法,达到了极大限度的优化呢?

这篇文章,将会深入浅出分析这些优化背后的奥秘。希望大神给予斧正和引导,同时对读者有所启发和帮助。

我们到底在讨论什么?

ECMAScript 2015 语言标准规格介绍了几种新的数据结构:比如 Maps 和 Sets(当然还有类似 WeakMaps 和 WeakSets等),而这几个新引入的数据结构有一个共性,那就是都可以根据同样新引入的遍历协议(iteration protocol)进行遍历。这就意味着你可以使用 for...of 循环或者扩展运算符进行操作。举一个 Sets 简单的例子:

const s = new Set([1, 2, 3, 4]);
console.log(...s);
// 1 2 3 4
for (const x of s) console.log(x);
// 1
// 2
// 3
// 4

同样对于 Maps:

const m = new Map([[1, "1"], [2, "2"], [3, "3"], [4, "4"]]);
console.log(...m);
// (2) [1, "1"] (2) [2, "2"] (2) [3, "3"] (2) [4, "4"]
console.log(...m.keys());
// 1 2 3 4
console.log(...m.values());
//  1 2 3 4
for (const [x, y] of m) console.log(x, "is", y);
// 1 "is" "1"
// 2 "is" "2"
// 3 "is" "3"
// 4 "is" "4"

通过这两个简单的例子,展示了最基本的用法。感兴趣的读者可以参考 ECMA 规范: Map Iterator ObjectsSet Iterator Objects

然而不幸的是,这些可遍历的数据结构在 V8 引擎中并没有很好的进行优化。或者说,对于这些实现细节优化程度很差。包括 ECMAScript 成员 Brian Terlson 也曾在 Twitter 上抱怨,指出他使用 Sets 来实现一个正则引擎时遇到了恼人的性能问题。

所以,现在是时间来对他们进行优化了!但在着手优化前,我们需要先彻底认清一个问题:这些数据结构处理慢的真实原因究竟是什么?性能瓶颈到底在哪里?
为了弄清这个问题,我们就需要理解底层实现上,迭代器究竟是如何工作的?

深入主题探索究竟

为此,我们先从一个简单的 for...of 循环说起。

ES6 借鉴 C++、Java、C# 和 Python 语言,引入了 for...of 循环,作为遍历所有数据结构的统一方法。

一个最简单的使用:

function sum(iterable) {
  let x = 0;
  for (const y of iterable) x += y;
  return x;
}

将这段代码使用 Babel 进行编译,我们得到:

function sum(iterable) {
  var x = 0;
  var _iteratorNormalCompletion = true;
  var _didIteratorError = false;
  var _iteratorError = undefined;

  try {
    for (var _iterator = iterable[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) {
      var y = _step.value;
      x += y;
    }
  } catch (err) {
    _didIteratorError = true;
    _iteratorError = err;
  } finally {
    try {
      if (!_iteratorNormalCompletion && _iterator.return) {
        _iterator.return();
      }
    } finally {
      if (_didIteratorError) {
        throw _iteratorError;
      }
    }
  }

  return x;
}

需要告诉大家的一个事实是:目前现代 JavaScript 引擎在本质上和 Babel 对 for...of 的去语法糖化处理是相同的,仅仅是在具体一些细节上有差别。

这个事实出处:

All modern JavaScript engines essentially perform the same desugaring that Babel does here to implement for-of (details vary)

上文引自 V8 核心成员,谷歌工程师 Benedikt Meurer。

可是上面经过 Babel 编译后的代码不太好阅读。别急,我删去了烦人的异常处理,只保留了最核心的逻辑供大家参考,以便研究:

function sum(iterable) {
  var x = 0;
  var iterator = iterable[Symbol.iterator]();
  for (;;) {
    var iterResult = iterator.next();
    if (iterResult.done) break;
    x += iterResult.value;
  }
  return x;
}

理解这段代码需要的预备知识需要清楚 for...of 和 Symbol.iterator 方法关系:

一个数据结构只要部署了 Symbol.iterator 属性,就被视为具有 iterator 接口,就可以用 for...of 循环遍历它的成员。也就是说,for...of 循环内部调用的是数据结构的 Symbol.iterator 方法。

for...of 循环可以使用的范围包括数组、Set 和 Map 结构、某些类似数组的对象(比如 arguments 对象、DOM NodeList 对象)、Generator 对象,以及字符串。

我们仔细观察上段段代码,便可以发现迭代器性能优化的关键是:保证在循环中多次重复调用的 iterator.next() 能得到最大限度的优化,同时,最理想的情况是完全避免对 iterResult 的内存分配。能够达到这种目的的几个手段便是使用类似 store-load propagation, escape analysis 和 scalar replacement of aggregates 先进的编译处理技术。

同时,优化后的编译还需要完全消除迭代器本身 —— iterable[Symbol.iterator] 的调用分配,而直接在迭代器 backing-store 上直接操作。

事实上,在数组和字符串迭代器的优化过程中,就是使用了这样的技术和理念。具体的实施文档可以参考这里。

那么具体到 Set 迭代器的性能问题,其实关键原因在与:其实现上混合了 JavaScript 和 C++ 环境。比如,我们看 %SetIteratorPrototype%.next() 的实现:

function SetIteratorNextJS() {
  if (!IS_SET_ITERATOR(this)) {
    throw %make_type_error(kIncompatibleMethodReceiver,
                        'Set Iterator.prototype.next', this);
  }

  var value_array = [UNDEFINED, UNDEFINED];
  var result = %_CreateIterResultObject(value_array, false);
  switch (%SetIteratorNext(this, value_array)) {
    case 0:
      result.value = UNDEFINED;
      result.done = true;
      break;
    case ITERATOR_KIND_VALUES:
      result.value = value_array[0];
      break;
    case ITERATOR_KIND_ENTRIES:
      value_array[1] = value_array[0];
      break;
  }

  return result;
}

这段代码实际上按部就班做了这么几件事情:

  • 定义分配了 value_array 数组,初始化时为两项 undefined;
  • 定义分配了 result 对象,其格式为 {value: value_array, done: false};
  • 调用 C++ %SetIteratorNext runtime 函数,将迭代器的 this 和 value_array 作为参数传递。
  • 根据 C++ %SetIteratorNext runtime 函数返回结果,将 result 正确赋值

这就造成什么样的后果呢?遍历的每一步我们都在不断地分配两个对象:value_array 和 result。不管是 V8 的 TurboFan 还是 Crankshaft (V8的优化编译器) 我们都无法消除这样的操作。更糟糕的是,每一步遍历我们都要在 JavaScript 和 C++ 之间进行切换。下面的图,简要表示了一个简单的 SUM 函数在底层的运行流程:

set-iterator-next-js-20170714.png

在 V8 执行时,总处在两个状态(事实上更多):执行 C++ 代码和执行 JavaScript 当中。这两个状态之间的转换开销巨大。从 JavaScript 到 C++,是依赖所谓的 CEntryStub 完成,CEntryStub 会触发 C++ 当中指定的 Runtime_Something 函数(本例中为 Runtime_SetIteratorNext)。所以,是否可以避免这种转换,以及避免 value_array 和 result 对象的分配又决定了性能的关键。

最新的 %SetIteratorPrototype%.next() 实现正是切中要害,做了这个“关键”的处理。我们想要执行的代码在调用之前就会变得 hot (热处理),TurboFan 进而最终得以热处理优化。借助所谓的 CodeStubAssemblerbaseline implementation,现在已经完全在 JavaScript 层面实现接入。这样我们仅仅只需要调用 C++ 来做垃圾回收(在可用内存耗尽时)以及异常处理的工作。

基于回调函数的遍历

在遍历协议中,JavaScript 同样提供 Set.prototype.forEach 和 Map.prototype.forEach 方法,来接收一个回调函数。这些同样依赖于 C++ 的处理逻辑,这样不仅仅需要我们将 JavaScript 转换为 C++,还要处理回调函数转换为 Javascript,这样的工作模式如下图:

set-iterator-foreach-20170714.png

所以,上面使用 CodeStubAssembler 的方式只能处理简单的非回调函数场景。真正完全意义上的优化,包括 forEach 方法的 TurboFan 化还需要一些待开发的魔法。

优化提升 Benchmark

我们使用下面的 benchmark 代码进行优化程度的评测:

const s = new Set;
const m = new Map;
for (let i = 0; i < 1e7; ++i) {
  m.set(i, i);
  s.add(i);
}

function SetForOf() {
  for (const x of s) {}
}

function SetForOfEntries() {
  for (const x of s.entries()) {}
}

function SetForEach() {
  s.forEach(function(key, key, set) {});
}

function MapForOf() {
  for (const x of m) {}
}

function MapForOfKeys() {
  for (const x of m.keys()) {}
}

function MapForOfValues() {
  for (const x of m.values()) {}
}

function MapForEach() {
  m.forEach(function(val, key, map) {});
}

const TESTS = [
    SetForOf,
    SetForOfEntries,
    SetForEach,
    MapForOf,
    MapForOfKeys,
    MapForOfValues,
    MapForEach
];

// Warmup.
for (const test of TESTS) {
  for (let i = 0; i < 10; ++i) test();
}

// Actual tests.
for (const test of TESTS) {
  console.time(test.name);
  test();
  console.timeEnd(test.name);
}

在 Chrome60 和 Chrome61 版本的对比中,得到下面图标结论:

improvements-20170714.png

可见,虽然大幅提升,但是我们还是得到了一些不太理想的结果。尤其体现在 SetForOfEntries 和 MapForOf 上。但是这将会在更长远的计划上进行处理。

总结

这篇文章只是在大面上介绍了遍历器性能所面临的瓶颈和现有的解决方案。通过贺老的微博,对一个问题进行探究,最终找到 V8 核心成员 Benedikt Meurer 的 Faster Collection Iterators一文,进行参考并翻译。想要完全透彻理解原文章中内容,还需要扎实的计算机基础、v8
引擎工作方式以及编译原理方面的知识储备。

因我才疏学浅,入行也不到两年,更不算计算机科班出身,拾人牙慧的同时难免理解有偏差和疏漏。更多 V8 引擎方面的知识,建议大家更多关注@justjavac,编译方面的知识关注 Vue 作者@尤雨溪 以及他在 JS Conf 上的演讲内容: 前端工程中的编译时优化。毕竟,我们关注前端大 V、网红的根本并不是为了看热闹、看撕 b,而是希望在前辈身上学习到更多知识、得到更多启发。

最后,随着 ECMAScript 的飞速发展,让每一名前端开发者可能都会感觉到处在不断地学习当中,甚至有些疲于奔命。但是在学习新的特性和语法之外,理解更深层的内容才是学习进阶的关键。

我同样不知道什么是海,

赤脚站在沙滩上,

急切地等待着黎明的到来。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,236评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,867评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,715评论 0 340
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,899评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,895评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,733评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,085评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,722评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,025评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,696评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,816评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,447评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,057评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,254评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,204评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,561评论 2 343

推荐阅读更多精彩内容