V8 中的 Maps(隐藏类)

让我们看看 V8 如何构建它的隐藏类。主要数据结构是

由于许多 Map 对象只有一个到另一个对象的过渡(即,它们是“过渡”映射,仅在通往其他事物的过程中使用),因此 V8 并不总是为其创建完整的 TransitionArray。相反,它只会直接链接到这个“下一个”Map。该系统必须在 MapDescriptorArray 中进行一些探索,以确定与过渡相关的名称。

这是一个非常丰富的主题。它也可能发生变化,但是,如果您理解本文中的概念,未来的变化应该是逐步可理解的。

为什么要有隐藏类? #

当然,V8 可以没有隐藏类。它会将每个对象视为一个属性包。但是,一个非常有用的原则会被遗漏:智能设计的原则。V8 推测,您只会创建这么多不同类型的对象。并且每种类型的对象将在最终被视为典型的方式中使用。我说“最终被视为”,因为 JavaScript 语言是一种脚本语言,而不是预编译语言。因此,V8 永远不知道接下来会发生什么。为了利用智能设计(即,假设代码背后存在一个思维),V8 必须观察和等待,让结构感渗透进来。隐藏类机制是实现此目的的主要手段。当然,它预先假设了一种复杂的监听机制,这些机制就是内联缓存 (IC),关于它们已经写了很多。

所以,如果您相信这是一项好的且必要的工作,请跟随我!

一个例子 #

function Peak(name, height, extra) {
this.name = name;
this.height = height;
if (isNaN(extra)) {
this.experience = extra;
} else {
this.prominence = extra;
}
}

m1 = new Peak("Matterhorn", 4478, 1040);
m2 = new Peak("Wendelstein", 1838, "good");

通过这段代码,我们已经从根映射(也称为初始映射)获得了有趣的映射树,该映射附加到函数 Peak

Hidden class example

每个蓝色框都是一个映射,从初始映射开始。这是如果我们设法在不添加任何属性的情况下运行函数 Peak 所返回的对象的映射。后续映射是通过添加映射之间边上给出的名称的属性而产生的。每个映射都包含与该映射的对象关联的属性列表。此外,它描述了每个属性的确切位置。最后,从这些映射中的一个,例如 Map3,它是如果您在 Peak() 中为 extra 参数传递了一个数字时将获得的对象的隐藏类,您可以沿着所有反向链接一直回到初始映射。

让我们用这些额外的信息再次绘制它。注释 (i0),(i1) 表示对象内字段位置 0,1 等

Hidden class example

现在,如果您在创建至少 7 个 Peak 对象之前花时间检查这些映射,您将遇到松弛跟踪,这可能会令人困惑。我有一篇关于它的另一篇文章。只需再创建 7 个对象,它就会完成。此时,您的 Peak 对象将恰好有 3 个对象内属性,并且无法直接在对象中添加更多属性。任何其他属性都将被卸载到对象的属性支持存储中。它只是一个属性值的数组,其索引来自映射(实际上,来自附加到映射的 DescriptorArray)。让我们在新的行上向 m2 添加一个属性,并再次查看映射树

m2.cost = "one arm, one leg";
Hidden class example

我在这里偷偷摸摸地加了一些东西。请注意,所有属性都标有“const”,这意味着从 V8 的角度来看,自构造函数以来没有人更改过它们,因此一旦它们被初始化,它们就可以被视为常量。TurboFan(优化编译器)非常喜欢这一点。假设 m2 被函数引用为一个常量全局变量。那么 m2.cost 的查找可以在编译时完成,因为该字段被标记为常量。我将在本文后面再回到这一点。

请注意,属性“cost”被标记为 const p0,这意味着它是一个常量属性,存储在属性支持存储的索引零处,而不是直接存储在对象中。这是因为我们在对象中没有更多空间。此信息在 %DebugPrint(m2) 中可见

d8> %DebugPrint(m2);
DebugPrint: 0x2f9488e9: [JS_OBJECT_TYPE]
 - map: 0x219473fd <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x2f94876d <Object map = 0x21947335>
 - elements: 0x419421a1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x2f94aecd <PropertyArray[3]> {
    0x419446f9: [String] in ReadOnlySpace: #name: 0x237125e1
        <String[11]: #Wendelstein> (const data field 0)
    0x23712581: [String] in OldSpace: #height:
        1838 (const data field 1)
    0x23712865: [String] in OldSpace: #experience: 0x237125f9
        <String[4]: #good> (const data field 2)
    0x23714515: [String] in OldSpace: #cost: 0x23714525
        <String[16]: #one arm, one leg>
        (const data field 3) properties[0]
 }
 ...
{name: "Wendelstein", height: 1, experience: "good", cost: "one arm, one leg"}
d8>

您可以看到我们有 4 个属性,全部标记为 const。前 3 个在对象中,最后一个在 properties[0] 中,这意味着属性支持存储的第一个槽。我们可以看看它

d8> %DebugPrintPtr(0x2f94aecd)
DebugPrint: 0x2f94aecd: [PropertyArray]
 - map: 0x41942be9 <Map>
 - length: 3
 - hash: 0
         0: 0x23714525 <String[16]: #one arm, one leg>
       1-2: 0x41942329 <undefined>

这些额外的属性只是为了以防您突然决定添加更多属性。

真实结构 #

此时我们可以做很多不同的事情,但是由于您一定非常喜欢 V8,读到这里,我想尝试绘制我们使用的真实数据结构,即开头提到的 MapDescriptorArrayTransitionArray。现在您已经对隐藏类概念在幕后构建了一些了解,您不妨通过正确的名称和结构将您的思维更紧密地绑定到代码。让我尝试用 V8 的表示来重现最后那个图。首先,我将绘制DescriptorArrays,它们包含给定 Map 的属性列表。这些数组可以共享——关键是 Map 本身知道它允许在 DescriptorArray 中查看多少个属性。由于属性是按添加时间顺序排列的,因此这些数组可以被多个映射共享。看

Hidden class example

请注意,Map1Map2Map3 都指向 DescriptorArray1。每个 Map 中“descriptors”字段旁边的数字表示 DescriptorArray 中属于 Map 的字段数量。因此,Map1 仅了解“name”属性,它只查看 DescriptorArray1 中列出的第一个属性。而 Map2 有两个属性,“name”和“height”。因此,它查看 DescriptorArray1 中的第一个和第二个项目(name 和 height)。这种共享节省了大量空间。

当然,我们不能在有分割的地方共享。如果添加了“experience”属性,则从 Map2 过渡到 Map4,如果添加了“prominence”属性,则过渡到 Map3。您可以看到 Map4 和 Map4 以与 DescriptorArray1 在三个 Map 中共享的方式共享 DescriptorArray2。

我们的“真实”图中唯一缺少的是 TransitionArray,它在此时仍然是隐喻性的。让我们改变一下。我冒昧地删除了反向指针线,这使事情变得更清晰了一些。请记住,从树中的任何 Map,您也可以向上遍历树。

Hidden class example

该图值得研究。问题:如果在“name”之后添加一个新的属性“rating”,而不是继续添加“height”和其他属性,会发生什么?

答案:Map1 将获得一个真正的 TransitionArray,以便跟踪分叉。如果添加了属性 height,我们应该过渡到 Map2。但是,如果添加了属性 rating,我们应该转到一个新的映射 Map6。此映射需要一个新的 DescriptorArray,其中提到了 namerating。此时,对象在对象中还有额外的空槽(三个中只有一个被使用),因此属性 rating 将被分配到这些槽中的一个。

我在 %DebugPrintPtr() 的帮助下检查了我的答案,并绘制了以下内容

Hidden class example

无需恳求我停止,我看到这是这种图表的极限!但我认为您可以了解各个部分是如何移动的。想象一下,在添加这个 ersatz 属性 rating 之后,我们继续添加 heightexperiencecost。好吧,我们必须创建映射 Map7Map8Map9。因为我们坚持在已建立的映射链的中间添加此属性,所以我们将复制很多结构。我没有勇气绘制那个图——不过如果你把它发给我,我会把它添加到这个文档中:)。

我使用方便的 DreamPuf 项目轻松地制作了这些图。这是一个指向先前图表的链接 到先前图表。

TurboFan 和 const 属性 #

到目前为止,所有这些字段都在 DescriptorArray 中被标记为 const。让我们玩玩这个。在调试版本上运行以下代码

// run as:
// d8 --allow-natives-syntax --no-lazy-feedback-allocation --code-comments --print-opt-code
function Peak(name, height) {
this.name = name;
this.height = height;
}

let m1 = new Peak("Matterhorn", 4478);
m2 = new Peak("Wendelstein", 1838);

// Make sure slack tracking finishes.
for (let i = 0; i < 7; i++) new Peak("blah", i);

m2.cost = "one arm, one leg";
function foo(a) {
return m2.cost;
}

foo(3);
foo(3);
%OptimizeFunctionOnNextCall(foo);
foo(3);

您将获得优化函数 foo() 的打印输出。代码非常短。您将在函数末尾看到

...
40  mov eax,0x2a812499          ;; object: 0x2a812499 <String[16]: #one arm, one leg>
45  mov esp,ebp
47  pop ebp
48  ret 0x8                     ;; return "one arm, one leg"!

TurboFan,作为一个狡猾的家伙,只是直接插入了 m2.cost 的值。好吧,你喜欢这样吗!

当然,在最后一次调用 foo() 之后,您可以插入这一行

m2.cost = "priceless";

您认为会发生什么?有一点可以肯定,我们不能让 foo() 保持原样。它将返回错误的答案。重新运行程序,但添加标志 --trace-deopt,以便在优化代码从系统中删除时通知您。在打印优化后的 foo() 之后,您将看到这些行

[marking dependent code 0x5c684901 0x21e525b9 <SharedFunctionInfo foo> (opt #0) for deoptimization,
    reason: field-const]
[deoptimize marked code in all contexts]

哇。

I like it a lot

如果您强制重新优化,您将获得代码,这些代码不如以前好,但仍然从我们一直在描述的 Map 结构中受益匪浅。请记住,从我们的图中可以看出,属性 cost
对象属性支持存储中的第一个属性。好吧,它可能失去了 const 标识,但我们仍然有它的地址。基本上,在一个具有映射 Map5 的对象中,我们肯定会验证全局变量 m2 仍然具有,我们只需要——

  1. 加载属性支持存储,以及
  2. 读出第一个数组元素。

让我们看看。在最后一行下方添加此代码

// Force reoptimization of foo().
foo(3);
%OptimizeFunctionOnNextCall(foo);
foo(3);

现在看看生成的代码

...
40  mov ecx,0x42cc8901          ;; object: 0x42cc8901 <Peak map = 0x3d5873ad>
45  mov ecx,[ecx+0x3]           ;; Load the properties backing store
48  mov eax,[ecx+0x7]           ;; Get the first element.
4b  mov esp,ebp
4d  pop ebp
4e  ret 0x8                     ;; return it in register eax!

为什么呢。这正是我们所说的应该发生的事情。也许我们开始知道。

如果变量 m2 更改为不同的类,TurboFan 也足够聪明,可以进行反优化。您可以使用类似以下的无聊内容来观察最新的优化代码再次反优化

m2 = 42;  // heh.

下一步去哪里 #

有很多选项。地图迁移。字典模式(也称为“慢速模式”)。在这个领域有很多东西可以探索,我希望你像我一样享受它——感谢你的阅读!