作者: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)”。
隐藏类的核心思想可归结为两个简单的原则:
- 运行时系统会为每一个对象关联一个隐藏类,这与 Java 虚拟机为每个对象关联
java.lang.Class实例的机制完全一致; - 若对象的布局发生变化,运行时系统会创建或查找一个与新布局匹配的隐藏类,并将其重新关联到该对象上。
隐藏类有一项至关重要的特性:它允许虚拟机通过 “将缓存的隐藏类与目标对象的隐藏类做简单比对”,快速验证关于对象布局的假设是否成立。这正是我们的内联缓存所需要的核心能力。接下来,我们为这套准 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 ⇒ 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)—— 它们会变的!