【翻译】用JavaScript解析JavaScript虚拟机 - 内联缓存篇

18 阅读18分钟

原文链接:mrale.ph/blog/2012/0…

作者:me@mrale.ph | @mraleph

我格外痴迷一类虚拟机 —— 它们的实现语言,恰好就是(或是该语言的一个子集)其自身要执行的语言。如果我身处学术圈,或是能再多一些空闲时间,我一定会着手开发一个用 JavaScript 编写的 JavaScript 虚拟机。 当然,这个项目对于 JavaScript 领域来说并不算新颖,因为蒙特利尔大学的研究团队早已凭借 Tachyon 实现了类似的成果。但即便如此,我还是有一些自己的想法,想要亲自去探索实践。

不过,我还有另一个与 (元)循环虚拟机 密切相关的梦想。我希望能帮助 JavaScript 开发者理解 JS 引擎的工作原理。我认为,在我们这一行,弄懂自己正在使用的工具是至关重要的事。人们越不把 JS 虚拟机看作一个将 JavaScript 源代码转换成一串二进制代码的神秘黑盒,就越好。

需要说明的是,渴望拆解技术的底层逻辑、帮助大家写出性能更优的代码的人,从来不止我一个。世界各地有许多开发者都在为之努力。但在我看来,当下存在一个问题,阻碍了开发者们高效吸收这些知识 ——我们正用错误的形式来传递这些知识。而我自己,也难辞其咎:

  • 有时,我会把自己掌握的关于 V8 引擎的知识,包装成一条条生硬晦涩的 “要这样做,不要那样做” 的建议清单。这种传递方式的弊端在于,它完全没有解释背后的原理。这些建议很可能会被奉为圭臬、当作金科玉律来奉行,也很可能在无人察觉的情况下悄然过时。
  • 有时,在试图解释虚拟机内部工作机制时,我们又选错了抽象层面。我曾乐观地设想,当人们看到满屏汇编代码的幻灯片时,或许会因此萌生学习汇编语言的想法,并在日后重新研读这一页内容;但我也担心,很多时候这些幻灯片只会被一带而过,最后被大家当作脱离实际的无用之物抛之脑后。

我已经思考这些问题许久,最终决定,用 JavaScript 来讲解 JavaScript 虚拟机或许是个值得尝试的方向。我在 2012 年 WebRebels 大会上做的题为《深入理解 V8 引擎》的分享,正是源于这个想法【视频】【幻灯片】。而在这篇文章里,我想重新梳理当年在奥斯陆所讲的内容,这一次,不会有任何口语表达上的阻碍(我总觉得,自己的书面表达可比口头表述要条理清晰得多 ☺)。

用 JavaScript 实现动态语言

试想你要在 JavaScript 中,为一门语义与 JavaScript 高度相似,但对象模型却简洁得多的语言实现一个虚拟机:这门语言不会使用 JS 对象,而是采用表结构,将任意类型的键映射至对应的值。

为了便于理解,我们不妨以 Lua 语言为例 —— 它与 JavaScript 其实既有诸多相似之处,也存在显著的差异。我最常用的那个 “创建点坐标数组,再计算向量和” 的示例,用这门语言写出来大概是这个样子:

function MakePoint(x, y)
  local point = {}
  point.x = x
  point.y = y
  return point
end

function MakeArrayOfPoints(N)
  local array = {}
  local m = -1
  for i = 0, N do
    m = m * -1
    array[i] = MakePoint(m * i, m * -i)
  end
  array.n = N
  return array
end

function SumArrayOfPoints(array)
  local sum = MakePoint(0, 0)
  for i = 0, array.n do
    sum.x = sum.x + array[i].x
    sum.y = sum.y + array[i].y
  end
  return sum
end

function CheckResult(sum)
  local x = sum.x
  local y = sum.y
  if x ~= 50000 or y ~= -50000 then
    error("failed: x = " .. x .. ", y = " .. y)
  end
end

local N = 100000
local array = MakeArrayOfPoints(N)
local start_ms = os.clock() * 1000;
for i = 0, 5 do
  local sum = SumArrayOfPoints(array)
  CheckResult(sum)
end
local end_ms = os.clock() * 1000;
print(end_ms - start_ms)

需要说明的是,我有个习惯:至少会核对微基准测试(μbenchmark)算出的部分最终结果。这能避免我陷入尴尬境地 —— 比如有人发现,我那些看似开创性的 jsperf 测试用例,到头来不过是我自己写的代码里藏着漏洞。

若你把上述代码放到 Lua 解释器中运行,会得到类似这样的结果:

∮ lua points.lua
150.2

这个思路不错,但无助于理解虚拟机的工作原理。那么我们不妨设想一下:如果用 JavaScript 编写一个类 Lua 虚拟机,代码会是什么样子?之所以称其为 “类”(quasi),是因为我并不打算实现 Lua 的完整语义,而是只聚焦于其 “一切对象皆为表” 这一核心特性。一个简易编译器(naïve compiler)或许会把我们的代码翻译成如下形式的 JavaScript 代码:

function MakePoint(x, y) {
  var point = new Table();
  STORE(point, 'x', x);
  STORE(point, 'y', y);
  return point;
}

function MakeArrayOfPoints(N) {
  var array = new Table();
  var m = -1;
  for (var i = 0; i <= N; i++) {
    m = m * -1;
    STORE(array, i, MakePoint(m * i, m * -i));
  }
  STORE(array, 'n', N);
  return array;
}

function SumArrayOfPoints(array) {
  var sum = MakePoint(0, 0);
  for (var i = 0; i <= LOAD(array, 'n'); i++) {
    STORE(sum, 'x', LOAD(sum, 'x') + LOAD(LOAD(array, i), 'x'));
    STORE(sum, 'y', LOAD(sum, 'y') + LOAD(LOAD(array, i), 'y'));
  }
  return sum;
}

function CheckResult(sum) {
  var x = LOAD(sum, 'x');
  var y = LOAD(sum, 'y');
  if (x !== 50000 || y !== -50000) {
    throw new Error("failed: x = " + x + ", y = " + y);
  }
}

var N = 100000;
var array = MakeArrayOfPoints(N);
var start = LOAD(os, 'clock')() * 1000;
for (var i = 0; i <= 5; i++) {
  var sum = SumArrayOfPoints(array);
  CheckResult(sum);
}
var end = LOAD(os, 'clock')() * 1000;
print(end - start);

不过,若你直接尝试在 d8(V8 引擎的独立交互环境)中运行这段转换后的代码,它会直接报错终止运行。

d8 points.js
points.js:9: ReferenceError: Table is not defined
  var array = new Table();
                  ^
ReferenceError: Table is not defined
    at MakeArrayOfPoints (points.js:9:19)
    at points.js:37:13

报错的原因很简单:我们还缺少运行时系统代码—— 这套代码才是真正负责实现对象模型,以及读写操作语义的核心部分。

这一点看似显而易见,但我想在此强调:一台虚拟机,从外部看仿佛是一个孤立的黑盒,可其内部实则是多个组件协同运作的有机整体,所有组件齐心协力,才能实现最优性能。这些组件包括编译器、运行时例程、对象模型、垃圾回收器等等。好在我们所定义的语言与测试示例都十分简单,因此对应的运行时系统代码仅有几十行而已。

function Table() {
  // Map from ES Harmony is a simple dictionary-style collection.
  this.map = new Map;
}

Table.prototype = {
  load: function (key) { return this.map.get(key); },
  store: function (key, value) { this.map.set(key, value); }
};

function CHECK_TABLE(t) {
  if (!(t instanceof Table)) {
    throw new Error("table expected");
  }
}

function LOAD(t, k) {
  CHECK_TABLE(t);
  return t.load(k);
}

function STORE(t, k, v) {
  CHECK_TABLE(t);
  t.store(k, v);
}

var os = new Table();

STORE(os, 'clock', function () {
  return Date.now() / 1000;
});

需要注意的是,我必须使用鸿蒙映射(Harmony Map)而非常规的 JavaScript 对象,因为该表格理论上可能包含任意类型的键名,而非仅局限于字符串类型的键名。

∮ d8 --harmony quasi-lua-runtime.js points.js
737

目前我们转换后的代码虽能正常运行,但运行速度却不尽如人意 —— 究其原因,是每次数据的加载(load)和存储(store)操作在访问到目标值之前,都需要跨越这一层层的抽象层级。为此,我们不妨借鉴如今大多数 JavaScript 虚拟机(VM)都会采用的核心优化手段:内联缓存(Inline Caching)。即便是基于 Java 编写的 JS 虚拟机,最终也会用上这项技术 —— 因为 invokedynamic(动态调用)本质上就是一种在字节码层面暴露的结构化内联缓存。而内联缓存(在 V8 引擎的源码中通常缩写为 IC)其实是一项相当古老的技术,大约 30 年前就为 Smalltalk 虚拟机研发出来了。

可靠的机制,行为始终如一

内联缓存的核心思想十分简单:若我们对对象及其属性的假设成立,就希望构建一条旁路(bypass)或快速路径(fast path),让程序无需进入运行时系统,就能快速加载对象的属性。但对于用动态类型、迟绑定(late binding)以及eval等特殊特性的语言编写的程序而言,要针对对象布局制定任何有实际意义的假设都极为困难。因此,我们转而让加载 / 存储操作自行 “观察并学习”:一旦这些操作接触到某个对象,便会自适应调整,使得后续对结构相似对象的加载操作速度更快。从某种意义上说,我们是将此前访问过的对象的布局信息,直接缓存到加载 / 存储操作本身中 —— 这也是 “内联缓存” 得名的原因。实际上,只要能为带动态行为的操作梳理出有意义的快速路径,内联缓存几乎可应用于所有这类操作:算术运算符、自由函数调用、方法调用等。部分内联缓存还能缓存多条快速路径,即具备 “多态” 特性。

当我们开始思考如何将内联缓存应用到上述转换后的代码时,很快就会发现:必须修改现有的对象模型。基于 Map 根本无法实现快速加载 —— 因为我们始终需要通过get方法完成操作。【补充说明:若我们能直接访问 Map 底层的原始哈希表(hashtable),那么即便不调整新的对象布局,也可通过缓存桶索引(bucket index)让内联缓存生效。】

发现隐藏结构

为提升运行效率,那些被当作结构化数据使用的 table 应当更接近 C 语言的结构体(struct):即一系列位于固定偏移量(offset)处的命名字段。被当作数组使用的 table 亦是如此:我们希望数值型属性以类数组的方式存储。但显然,并非所有 table 都适用于这种存储形式:有些 table 确实是作为通用表格使用的 —— 要么包含非字符串、非数值类型的键名,要么包含大量字符串命名的属性,且这些属性会随 table 的修改而动态增减。遗憾的是,我们无法执行任何高开销的类型推断,只能在程序运行、创建并修改 table 的过程中,逐一发现每个 table 背后的结构。所幸,有一项成熟的技术恰好能实现这一点☺—— 这项技术就是 “隐藏类(Hidden Classes)”。

隐藏类的核心思想可归结为两个简单的原则:

  1. 运行时系统会为每一个对象关联一个隐藏类,这与 Java 虚拟机为每个对象关联java.lang.Class实例的机制完全一致;
  2. 若对象的布局发生变化,运行时系统会创建或查找一个与新布局匹配的隐藏类,并将其重新关联到该对象上。

隐藏类有一项至关重要的特性:它允许虚拟机通过 “将缓存的隐藏类与目标对象的隐藏类做简单比对”,快速验证关于对象布局的假设是否成立。这正是我们的内联缓存所需要的核心能力。接下来,我们为这套准 Lua(quasi-Lua)运行时实现一套简易的隐藏类系统。每个隐藏类本质上都是一组属性描述符(property descriptor)的集合,其中每个描述符要么是一个实际属性,要么是一个 “转移(transition)”—— 即从某个不包含特定属性的类,指向包含该属性的类的引用。

function Transition(klass) {
  this.klass = klass;
}

function Property(index) {
  this.index = index;
}

function Klass(kind) {
  // Classes are "fast" if they are C-struct like and "slow" is they are Map-like.
  this.kind = kind;
  this.descriptors = new Map;
  this.keys = [];
}

转移(Transition)机制的存在,是为了让以相同方式创建的对象能够共享隐藏类(Hidden Class) :假设有两个共享同一隐藏类的对象,当你为这两个对象添加相同的属性时,显然不希望它们因此生成不同的隐藏类。

Klass.prototype = {
  // Create hidden class with a new property that does not exist on
  // the current hidden class.
  addProperty: function (key) {
    var klass = this.clone();
    klass.append(key);
    // Connect hidden classes with transition to enable sharing:
    //           this == add property key ==> klass
    this.descriptors.set(key, new Transition(klass));
    return klass;
  },

  hasProperty: function (key) {
    return this.descriptors.has(key);
  },

  getDescriptor: function (key) {
    return this.descriptors.get(key);
  },

  getIndex: function (key) {
    return this.getDescriptor(key).index;
  },

  // Create clone of this hidden class that has same properties
  // at same offsets (but does not have any transitions).
  clone: function () {
    var klass = new Klass(this.kind);
    klass.keys = this.keys.slice(0);
    for (var i = 0; i < this.keys.length; i++) {
      var key = this.keys[i];
      klass.descriptors.set(key, this.descriptors.get(key));
    }
    return klass;
  },

  // Add real property to descriptors.
  append: function (key) {
    this.keys.push(key);
    this.descriptors.set(key, new Property(this.keys.length - 1));
  }
};

现在,我们可以让这些表格(table)具备灵活性,使其能够适配自身的构建方式。

var ROOT_KLASS = new Klass("fast");

function Table() {
  // All tables start from the fast empty root hidden class and form 
  // a single tree. In V8 hidden classes actually form a forest - 
  // there are multiple root classes, e.g. one for each constructor. 
  // This is partially due to the fact that hidden classes in V8 
  // encapsulate constructor specific information, e.g. prototype 
  // poiinter is actually stored in the hidden class and not in the 
  // object itself so classes with different prototypes must have 
  // different hidden classes even if they have the same structure.
  // However having multiple root classes also allows to evolve these
  // trees separately capturing class specific evolution independently.
  this.klass = ROOT_KLASS;
  this.properties = [];  // Array of named properties: 'x','y',...
  this.elements = [];  // Array of indexed properties: 0, 1, ...
  // We will actually cheat a little bit and allow any int32 to go here,
  // we will also allow V8 to select appropriate representation for
  // the array's backing store. There are too many details to cover in
  // a single blog post :-)
}

Table.prototype = {
  load: function (key) {
    if (this.klass.kind === "slow") {
      // Slow class => properties are represented as Map.
      return this.properties.get(key);
    }

    // This is fast table with indexed and named properties only.
    if (typeof key === "number" && (key | 0) === key) {  // Indexed property.
      return this.elements[key];
    } else if (typeof key === "string") {  // Named property.
      var idx = this.findPropertyForRead(key);
      return (idx >= 0) ? this.properties[idx] : void 0;
    }

    // There can be only string&number keys on fast table.
    return void 0;
  },

  store: function (key, value) {
    if (this.klass.kind === "slow") {
      // Slow class => properties are represented as Map.
      this.properties.set(key, value);
      return;
    }

    // This is fast table with indexed and named properties only.
    if (typeof key === "number" && (key | 0) === key) {  // Indexed property.
      this.elements[key] = value;
      return;
    } else if (typeof key === "string") {  // Named property.
      var index = this.findPropertyForWrite(key);
      if (index >= 0) {
        this.properties[index] = value;
        return;
      }
    }

    this.convertToSlow();
    this.store(key, value);
  },

  // Find property or add one if possible, returns property index
  // or -1 if we have too many properties and should switch to slow.
  findPropertyForWrite: function (key) {
    if (!this.klass.hasProperty(key)) {  // Try adding property if it does not exist.
      // To many properties! Achtung! Fast case kaput.
      if (this.klass.keys.length > 20) return -1;

      // Switch class to the one that has this property.
      this.klass = this.klass.addProperty(key);
      return this.klass.getIndex(key);
    }

    var desc = this.klass.getDescriptor(key);
    if (desc instanceof Transition) {
      // Property does not exist yet but we have a transition to the class that has it.
      this.klass = desc.klass;
      return this.klass.getIndex(key);
    }

    // Get index of existing property.
    return desc.index;
  },

  // Find property index if property exists, return -1 otherwise.
  findPropertyForRead: function (key) {
    if (!this.klass.hasProperty(key)) return -1;
    var desc = this.klass.getDescriptor(key);
    if (!(desc instanceof Property)) return -1;  // Here we are not interested in transitions.
    return desc.index;
  },

  // Copy all properties into the Map and switch to slow class.
  convertToSlow: function () {
    var map = new Map;
    for (var i = 0; i < this.klass.keys.length; i++) {
      var key = this.klass.keys[i];
      var val = this.properties[i];
      map.set(key, val);
    }

    Object.keys(this.elements).forEach(function (key) {
      var val = this.elements[key];
      map.set(key | 0, val);  // Funky JS, force key back to int32.
    }, this);

    this.properties = map;
    this.elements = null;
    this.klass = new Klass("slow");
  }
};

【我不会逐行解释这些代码,因为这是带注释的 JavaScript 代码,而非 C++ 或汇编语言 —— 这正是使用 JavaScript 的核心优势所在。不过,如果你有任何不清楚的地方,可以在注释中提出,或者直接给我发邮件咨询。】

如今,我们的运行时系统已经实现了隐藏类(Hidden Classes),这使得我们能够快速检查对象的布局,并通过属性索引实现属性的快速加载。接下来,我们只需完成内联缓存(Inline Caching)本身的实现即可。这需要编译器和运行时系统都补充一些额外功能(还记得我之前提到的虚拟机各组件间的协同工作吗?)。

生成代码的 “拼接式补丁”

实现内联缓存(Inline Caching)的方式有很多种,其中一种常用方案是将其拆分为两部分:生成代码中的可修改调用点(call site),以及可从该调用点调用的一组存根(stub,即小型的原生生成代码片段)。至关重要的一点是,存根本身(或运行时系统)必须能够找到调用它的那个调用点:因为存根仅包含基于特定假设编译的快速路径,若这些假设不适用于存根当前处理的对象,存根就可以发起对调用它的调用点的修改(打补丁),使该调用点能够适配新的场景。我们的纯 JavaScript 内联缓存同样由两部分组成:

  • 每个内联缓存对应一个全局变量,用于模拟可修改的调用指令;
  • 使用闭包(closure)替代存根。

在原生代码实现中,V8 引擎通过检查栈上的返回地址来定位需要打补丁的内联缓存位点。但在纯 JavaScript 环境中,我们无法实现类似操作(arguments.caller的粒度不够精细),因此只能将内联缓存的 ID 显式传递给内联缓存存根。以下是启用内联缓存后的代码示例:

// Initially all ICs are in uninitialized state.
// They are not hitting the cache and always missing into runtime system.
var STORE$0 = NAMED_STORE_MISS;
var STORE$1 = NAMED_STORE_MISS;
var KEYED_STORE$2 = KEYED_STORE_MISS;
var STORE$3 = NAMED_STORE_MISS;
var LOAD$4 = NAMED_LOAD_MISS;
var STORE$5 = NAMED_STORE_MISS;
var LOAD$6 = NAMED_LOAD_MISS;
var LOAD$7 = NAMED_LOAD_MISS;
var KEYED_LOAD$8 = KEYED_LOAD_MISS;
var STORE$9 = NAMED_STORE_MISS;
var LOAD$10 = NAMED_LOAD_MISS;
var LOAD$11 = NAMED_LOAD_MISS;
var KEYED_LOAD$12 = KEYED_LOAD_MISS;
var LOAD$13 = NAMED_LOAD_MISS;
var LOAD$14 = NAMED_LOAD_MISS;

function MakePoint(x, y) {
  var point = new Table();
  STORE$0(point, 'x', x, 0);  // The last number is IC's id: STORE$0 &rArr; id is 0
  STORE$1(point, 'y', y, 1);
  return point;
}

function MakeArrayOfPoints(N) {
  var array = new Table();
  var m = -1;
  for (var i = 0; i <= N; i++) {
    m = m * -1;
    // Now we are also distinguishing between expressions x[p] and x.p.
    // The fist one is called keyed load/store and the second one is called
    // named load/store.
    // The main difference is that named load/stores use a fixed known
    // constant string key and thus can be specialized for a fixed property
    // offset.
    KEYED_STORE$2(array, i, MakePoint(m * i, m * -i), 2);
  }
  STORE$3(array, 'n', N, 3);
  return array;
}

function SumArrayOfPoints(array) {
  var sum = MakePoint(0, 0);
  for (var i = 0; i <= LOAD$4(array, 'n', 4); i++) {
    STORE$5(sum, 'x', LOAD$6(sum, 'x', 6) + LOAD$7(KEYED_LOAD$8(array, i, 8), 'x', 7), 5);
    STORE$9(sum, 'y', LOAD$10(sum, 'y', 10) + LOAD$11(KEYED_LOAD$12(array, i, 12), 'y', 11), 9);
  }
  return sum;
}

function CheckResults(sum) {
  var x = LOAD$13(sum, 'x', 13);
  var y = LOAD$14(sum, 'y', 14);
  if (x !== 50000 || y !== -50000) throw new Error("failed x: " + x + ", y:" + y);
}

上述修改的用意依旧不言自明:每个属性加载 / 存储位点都分配了一个专属的内联缓存(IC)及对应的 ID。只剩最后一小步需要完成:实现缺失存根(MISS stub)和存根 “编译器”—— 后者负责生成专用化的存根。

function NAMED_LOAD_MISS(t, k, ic) {
  var v = LOAD(t, k);
  if (t.klass.kind === "fast") {
    // Create a load stub that is specialized for a fixed class and key k and
    // loads property from a fixed offset.
    var stub = CompileNamedLoadFastProperty(t.klass, k);
    PatchIC("LOAD", ic, stub);
  }
  return v;
}

function NAMED_STORE_MISS(t, k, v, ic) {
  var klass_before = t.klass;
  STORE(t, k, v);
  var klass_after = t.klass;
  if (klass_before.kind === "fast" &&
      klass_after.kind === "fast") {
    // Create a store stub that is specialized for a fixed transition between classes
    // and a fixed key k that stores property into a fixed offset and replaces
    // object's hidden class if necessary.
    var stub = CompileNamedStoreFastProperty(klass_before, klass_after, k);
    PatchIC("STORE", ic, stub);
  }
}

function KEYED_LOAD_MISS(t, k, ic) {
  var v = LOAD(t, k);
  if (t.klass.kind === "fast" && (typeof k === 'number' && (k | 0) === k)) {
    // Create a stub for the fast load from the elements array.
    // Does not actually depend on the class but could if we had more complicated
    // storage system.
    var stub = CompileKeyedLoadFastElement();
    PatchIC("KEYED_LOAD", ic, stub);
  }
  return v;
}

function KEYED_STORE_MISS(t, k, v, ic) {
  STORE(t, k, v);
  if (t.klass.kind === "fast" && (typeof k === 'number' && (k | 0) === k)) {
    // Create a stub for the fast store into the elements array.
    // Does not actually depend on the class but could if we had more complicated
    // storage system.
    var stub = CompileKeyedStoreFastElement();
    PatchIC("KEYED_STORE", ic, stub);
  }
}

function PatchIC(kind, id, stub) {
  this[kind + "$" + id] = stub;  // non-strict JS funkiness: this is global object.
}

function CompileNamedLoadFastProperty(klass, key) {
  // Key is known to be constant (named load). Specialize index.
  var index = klass.getIndex(key);

  function KeyedLoadFastProperty(t, k, ic) {
    if (t.klass !== klass) {
      // Expected klass does not match. Can't use cached index.
      // Fall through to the runtime system.
      return NAMED_LOAD_MISS(t, k, ic);
    }
    return t.properties[index];  // Veni. Vidi. Vici.
  }

  return KeyedLoadFastProperty;
}

function CompileNamedStoreFastProperty(klass_before, klass_after, key) {
  // Key is known to be constant (named load). Specialize index.
  var index = klass_after.getIndex(key);

  if (klass_before !== klass_after) {
    // Transition happens during the store.
    // Compile stub that updates hidden class.
    return function (t, k, v, ic) {
      if (t.klass !== klass_before) {
        // Expected klass does not match. Can't use cached index.
        // Fall through to the runtime system.
        return NAMED_STORE_MISS(t, k, v, ic);
      }
      t.properties[index] = v;  // Fast store.
      t.klass = klass_after;  // T-t-t-transition!
    }
  } else {
    // Write to an existing property. No transition.
    return function (t, k, v, ic) {
      if (t.klass !== klass_before) {
        // Expected klass does not match. Can't use cached index.
        // Fall through to the runtime system.
        return NAMED_STORE_MISS(t, k, v, ic);
      }
      t.properties[index] = v;  // Fast store.
    }
  }
}


function CompileKeyedLoadFastElement() {
  function KeyedLoadFastElement(t, k, ic) {
    if (t.klass.kind !== "fast" || !(typeof k === 'number' && (k | 0) === k)) {
      // If table is slow or key is not a number we can't use fast-path.
      // Fall through to the runtime system, it can handle everything.
      return KEYED_LOAD_MISS(t, k, ic);
    }
    return t.elements[k];
  }

  return KeyedLoadFastElement;
}

function CompileKeyedStoreFastElement() {
  function KeyedStoreFastElement(t, k, v, ic) {
    if (t.klass.kind !== "fast" || !(typeof k === 'number' && (k | 0) === k)) {
      // If table is slow or key is not a number we can't use fast-path.
      // Fall through to the runtime system, it can handle everything.
      return KEYED_STORE_MISS(t, k, v, ic);
    }
    t.elements[k] = v;
  }

  return KeyedStoreFastElement;
}

尽管涉及的代码(及注释)量不小,但结合以上所有说明,其逻辑应当不难理解:内联缓存(IC)负责 “观察” 对象特征,而存根编译器 / 工厂(stub compiler/factory)则生成经过适配的专用化存根【细心的读者甚至会发现,我本可以从一开始就为所有带键存储(keyed store)类型的内联缓存初始化快速加载逻辑,或者一旦内联缓存进入快速执行状态,就会一直保持该状态】。

当我们将所有编写完成的代码整合起来,重新运行这个 “基准测试” 时,会得到十分可观的结果:

∮ 执行命令:d8 --harmony quasi-lua-runtime-ic.js points-ic.js
117

相较于最初的简易实现版本,性能提升了 6 倍之多!

JavaScript 虚拟机的优化之路永无止境

希望你是读完了以上所有内容,才来看这一部分…… 我尝试从 JavaScript 开发者的视角,重新解读如今支撑 JavaScript 引擎运行的一些核心理念。我写的代码越多,就越觉得这像是 “盲人摸象” 的故事 —— 我们只能窥见冰山一角。不妨让你感受一下这背后的复杂度:V8 引擎有 10 种属性描述符类型、5 种元素类型(外加 9 种外部元素类型);包含大部分内联缓存(IC)状态选择逻辑的 ic.cc 文件,代码量超过 2500 行;V8 的内联缓存并非只有 2 种状态(而是包含未初始化、单态、多态、通用等状态,更不用说带键加载 / 存储内联缓存的特殊状态,或是算术运算内联缓存完全不同的状态层级);仅 x86 架构下手写的专用内联缓存存根代码,就超过 5000 行。诸如此类的代码量还在持续增长,因为 V8 一直在学习识别并适配更多的对象布局。而我甚至还没提及对象模型本身(objects.cc 文件多达 1.3 万行)、垃圾回收器,或是优化编译器这些模块。

尽管如此,我确信在可预见的未来,这些核心底层逻辑不会改变;即便真的发生改变,也必将是一声石破天惊的突破性变革 —— 到那时,你一定会有所察觉。因此我认为,通过用 JavaScript(重新)实现这些核心原理来理解它们,是一项极其极其重要的实践。

我希望在明天,或是一周后,你能突然恍然大悟、惊呼 “尤里卡!(Eureka!)”,并能向同事解释清楚:为何在代码某处为对象条件式添加属性,会影响到另一处远隔千里、频繁操作这些对象的热点循环的性能。要知道,这都是因为隐藏类(Hidden Classes)—— 它们会变的!