[译] v8 slack tracking

995 阅读16分钟

原文链接:v8.dev/blog/slack-…

翻译如有不当,请多多指教

slack tracking给新对象分配初始的空间大小,这个初始空间大小会比它实际使用的更大,以便我们能快速地给对象添加新的属性。一段时间之后,未使用的空间会被回收。

这对于没有静态类型的JavaScript来说是非常有用的,系统无法在初始时就解析出你所需的所有属性,引擎在运行时进行遍历属性。所以当你读到这样一段代码:

function Peak(name, height) {
  this.name = name;
  this.height = height;
}

const m1 = new Peak('Matterhorn', 4478);

你可能会觉得引擎已经掌握了代码正常运行需要的所有东西,毕竟你已经指明了该对象拥有两个属性。然而,V8并不知道接下来会发生什么。m1对象可能会执行另一个函数,添加了10个属性。slack tracking无需通过静态编译来推断总体结构,却能够应对执行过程中发现的各种变化。就像V8里的许多其他机制一样,slack tracking基于以下这些和运行相关的内容:

  • 大多数对象很快会被回收,很少会长时间存在————垃圾回收(分代假设)
  • 程序具有组织结构————我们将shapes或hidden classes(V8中称之为maps)构建到我们在程序中看到的变量中,因为它们将是有用的。顺便一提,Fast Properties in V8是一篇很棒的文章,其中包含有许多maps和访问属性的细节。
  • 程序具有初始化状态,当所有属性都是新添加的,很分辨出哪些属性重要。之后,通过多次执行可以识别出重要的类和函数。我们的反馈机制(feedback regime )和编译器管道(compiler pipeline)就是来自于这种想法。

最后,最重要的是,运行环境必须非常快,否则这些都毫无意义。

现在,v8可以将属性存储在备用的存储空间(译者注:backing store这里译者理解为备用的存储空间),通过主对象进行关联。与将属性直接存储在对象中不同,备用存储空间可以通过复制和替换指针来无限增长。但是,访问属性最快的方法是避免这种间接寻址,直接查看该属性的偏移量。接下来,我们以一个包含两个属性的简单对象为例子,介绍其在v8中的存储分布情况。前三个东西是在每个对象中都有的:map(hidden class),属性后备存储(properties backing store),元素后备存储(elements backing store)。很明显这个对象存储空间无法进行扩大,因为他的存储空间被下一个对象限制住了。

//p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5494d2d459fc4d63a33a2c3ca7092b8d~tplv-k3u1fbpfcp-zoom-1.image

注意:我省略了属性后备存储(properties backing store)的细节,因为它唯一需要注意的是,它的存储空间可能随时被一个更大的存储空间所替换。但它也是一个V8堆上的对象,像其他所有对象一样有一个map指针。

因此不管怎样,通过in-object属性,V8会为每个对象提供额外的空间,这就是slack tracking。

问题1:v8在多久之后会对未使用的空间进行回收呢?v8会分析特定对象的构建次数。实际上在map里有一个计数器,初始值为 7

问题2:V8如何知道对象里要提供多少额外空间?实际上,v8在编译过程中分析出属性的大概数量。这个数量包括了对象原型的属性数,并且延着原型链递归向上进行计算。最后,为了更好的估算结果,又额外增加了8个数量。你可以在这里面看到JSFunction::CalculateExpectedNofProperties():

int JSFunction::CalculateExpectedNofProperties(Isolate* isolate,
                                               Handle<JSFunction> function) {
  int expected_nof_properties = 0;
  for (PrototypeIterator iter(isolate, function, kStartAtReceiver);
       !iter.IsAtEnd(); iter.Advance()) {
    Handle<JSReceiver> current =
        PrototypeIterator::GetCurrent<JSReceiver>(iter);
    if (!current->IsJSFunction()) break;
    Handle<JSFunction> func = Handle<JSFunction>::cast(current);

    // 为了计算父级构造函数的可用属性,父级构造函数会被进行编译
    Handle<SharedFunctionInfo> shared(func->shared(), isolate);
    IsCompiledScope is_compiled_scope(shared->is_compiled_scope(isolate));
    if (is_compiled_scope.is_compiled() ||
        Compiler::Compile(func, Compiler::CLEAR_EXCEPTION,
                          &is_compiled_scope)) {
      DCHECK(shared->is_compiled());
      int count = shared->expected_nof_properties();
      // 检查估计值是否有效
      if (expected_nof_properties <= JSObject::kMaxInObjectProperties - count) {
        expected_nof_properties += count;
      } else {
        return JSObject::kMaxInObjectProperties;
      }
    } else {
      // 编译出错后,继续进行,以防原型链中的一些内置函数需要 in-object 属性
      continue;
    }
  }
  // 一开始会多分配8个插槽的空间。之后In-object slacking会回收多余的空间
  if (expected_nof_properties > 0) {
    expected_nof_properties += 8;
    if (expected_nof_properties > JSObject::kMaxInObjectProperties) {
      expected_nof_properties = JSObject::kMaxInObjectProperties;
    }
  }
  return expected_nof_properties;
}

让我们看看之前的m1对象:

function Peak(name, height) {
  this.name = name;
  this.height = height;
}

const m1 = new Peak('Matterhorn', 4478);

根据JSFunction::CalculateExpectedNofProperties()Peak()的计算,我们应该有两个in-object属性,然后slack tracking又多分配了8个。我们可以用%DebugPrint()打印m1(这个函数可以打印这个map的组成。 你可以运行d8--allow-natives-syntax来使用它):

> %DebugPrint(m1);
DebugPrint: 0x49fc866d: [JS_OBJECT_TYPE]
 - map: 0x58647385 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x49fc85e9 <Object map = 0x58647335>
 - elements: 0x28c821a1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x28c821a1 <FixedArray[0]> {
    0x28c846f9: [String] in ReadOnlySpace: #name: 0x5e412439 <String[10]: #Matterhorn> (const data field 0)
    0x5e412415: [String] in OldSpace: #height: 4478 (const data field 1)
 }
  0x58647385: [Map]
 - type: JS_OBJECT_TYPE
 - instance size: 52
 - inobject properties: 10
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 8
 - enum length: invalid
 - stable_map
 - back pointer: 0x5864735d <Map(HOLEY_ELEMENTS)>
 - prototype_validity cell: 0x5e4126fd <Cell value= 0>
 - instance descriptors (own) #2: 0x49fc8701 <DescriptorArray[2]>
 - prototype: 0x49fc85e9 <Object map = 0x58647335>
 - constructor: 0x5e4125ed <JSFunction Peak (sfi = 0x5e4124dd)>
 - dependent code: 0x28c8212d <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
 - construction counter: 6

注意这个对象的实例大小(instance size)是52。在v8中对象布局如下(译者注:注意这里的word,这里译者不清楚用什么关键字进行翻译妥当,下文会常常提到):

wordwhat
0map
1指向properties array
2指向elements array
3in-object字段1(指向字符串"Matterhorn"
4in-object字段2(指向整数4478
5没有被使用的in-object字段3
......
12没有被使用的in-object字段10

在32位二进制中指针的大小是4,每个JavaScript对象有3个初始内容(word),10个额外的内容(word)。上表中显示有8个未使用的属性字段。这就是slack tracking,由此,对象显得膨胀,占据大量的字节空间。

那么我们如何让膨胀的空间“瘦”下来?我们使用了map里的构造计数器字段(construction counter)。当该值降为0的时候,slacking tracking结束。然而,如果你构造更多的对象,将不会看到上面的计数器减少。这是为什么呢?

因为上述的map跟Peak对象产生的map不是同一个。上述的map只是一个从初始map向下延伸的map链的一个叶节点map,执行之前Peak对象赋值给这个初始map。

如何找到这个初始的map?其实Peak()函数上有一个指针。它指向初始map中用来控制slack tracking的构造计数器(construction counter):

> %DebugPrint(Peak);
d8> %DebugPrint(Peak)
DebugPrint: 0x31c12561: [Function] in OldSpace
 - map: 0x2a2821f5 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x31c034b5 <JSFunction (sfi = 0x36108421)>
 - elements: 0x28c821a1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - function prototype: 0x37449c89 <Object map = 0x2a287335>
 - initial_map: 0x46f07295 <Map(HOLEY_ELEMENTS)>   // Here's the initial map.
 - shared_info: 0x31c12495 <SharedFunctionInfo Peak>
 - name: 0x31c12405 <String[4]: #Peak>
…

d8> // %DebugPrintPtr 可以打印初始map.
d8> %DebugPrintPtr(0x46f07295)
DebugPrint: 0x46f07295: [Map]
 - type: JS_OBJECT_TYPE
 - instance size: 52
 - inobject properties: 10
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 10
 - enum length: invalid
 - back pointer: 0x28c02329 <undefined>
 - prototype_validity cell: 0x47f0232d <Cell value= 1>
 - instance descriptors (own) #0: 0x28c02135 <DescriptorArray[0]>
 - transitions #1: 0x46f0735d <Map(HOLEY_ELEMENTS)>
     0x28c046f9: [String] in ReadOnlySpace: #name:
         (transition to (const data field, attrs: [WEC]) @ Any) ->
             0x46f0735d <Map(HOLEY_ELEMENTS)>
 - prototype: 0x5cc09c7d <Object map = 0x46f07335>
 - constructor: 0x21e92561 <JSFunction Peak (sfi = 0x21e92495)>
 - dependent code: 0x28c0212d <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
 - construction counter: 5

构造计数器(construction counter)是如何减少到5的?如果你想找到前文提到的带有两个属性的map的初始map,你可以使用%DebugPrintPtr()跟踪它的back pointer,直到你像上述代码中一样找到一个map的back pointer里面是undefined

现在,从初始map生成出一棵map树,并从该点为每个属性添加一个分支。我们将这些分支称为transitions。在上述代码中打印出来的初始map中,可以看到通过name添加了一个新的transition。整个map树如下所示:

//p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3d60f2d05f2f460780740a7abb247a3a~tplv-k3u1fbpfcp-zoom-1.image

​ 坐标(X, Y, Z) 表示 (实例大小(instance size),in-object属性的数量, 未使用属性的数量)。

这些基于属性名称的transitions就是JavaScript中的构建maps的blind mole。初始map被存储在函数Peak中,所以当它被当作一个构造器时,可以使用这个map来设置this(译者注:这里怀疑是笔误,这个this没看明白指明是啥)这个对象。

const m1 = new Peak('Matterhorn', 4478);
const m2 = new Peak('Mont Blanc', 4810);
const m3 = new Peak('Zinalrothorn', 4221);
const m4 = new Peak('Wendelstein', 1838);
const m5 = new Peak('Zugspitze', 2962);
const m6 = new Peak('Watzmann', 2713);
const m7 = new Peak('Eiger', 3970);

这里最有趣的是在创建m7之后,运行%DebugPrint(m1)会得到一个奇妙的新结果:

DebugPrint: 0x5cd08751: [JS_OBJECT_TYPE]
 - map: 0x4b387385 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x5cd086cd <Object map = 0x4b387335>
 - elements: 0x586421a1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x586421a1 <FixedArray[0]> {
    0x586446f9: [String] in ReadOnlySpace: #name:
        0x51112439 <String[10]: #Matterhorn> (const data field 0)
    0x51112415: [String] in OldSpace: #height:
        4478 (const data field 1)
 }
0x4b387385: [Map]
 - type: JS_OBJECT_TYPE
 - instance size: 20
 - inobject properties: 2
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 0
 - enum length: invalid
 - stable_map
 - back pointer: 0x4b38735d <Map(HOLEY_ELEMENTS)>
 - prototype_validity cell: 0x511128dd <Cell value= 0>
 - instance descriptors (own) #2: 0x5cd087e5 <DescriptorArray[2]>
 - prototype: 0x5cd086cd <Object map = 0x4b387335>
 - constructor: 0x511127cd <JSFunction Peak (sfi = 0x511125f5)>
 - dependent code: 0x5864212d <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
 - construction counter: 0

我们的实例大小instance size现在是20,如下所示:

wordwhat
0map
1指向properties array
2指向elements array
3name
4height

这是为什么呢?毕竟,如果这个对象在内存中,并且曾经有10个属性,系统怎么允许放置8个未使用的属性空间?事实上,我们从来没有用任何对象去填满它们,这也许能给我们一些启发。

如果你想知道我为什么担心这些空间被随意放置,你需要了解一些垃圾回收器的背景知识。对象是一个接一个排列的,V8的垃圾回收器通过一次又一次地遍历内存来追踪它们。从内存中的第一个字(word)开始,垃圾回收器期望得到一个指向map的指针,并从中读取实例大小(instance size),然后知道要前进到下一个有效对象的距离有多远。对一些类需要额外计算一些长度,但总的来说背景知识就这么多了。

//p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/84ea9cc2242b44d3b88336f4077dc677~tplv-k3u1fbpfcp-zoom-1.image

上面的图中,红色的盒子是map,然后白色的盒子是填充对象内存空间的字(word)。垃圾回收器可以通过从一个map到另一个map来遍历堆。

所以如果map突然改变了他的实例大小(instance size)会发生什么?现在当GC(垃圾回收器)遍历堆时,它会发现发现一个自己之前没有见过的字(word)。在我们Peak的这个例子中,我们将占用13个字改为只占用5个(“未使用属性”的字被涂成黄色):

//p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0019100f61484c4cb6a750c0f91697aa~tplv-k3u1fbpfcp-zoom-1.image

img

如果我们巧妙地用一个"filter"来初始化那些未使用的属性,我们就可以解决这个问题。这样一旦它们暴露在垃圾回收器地遍历过程中,将会被轻轻地略过。

img

这在Factory::InitializeJSObjectBody():代码中可以看到:

void Factory::InitializeJSObjectBody(Handle<JSObject> obj, Handle<Map> map,
                                     int start_offset) {

  // <lines removed>

  bool in_progress = map->IsInobjectSlackTrackingInProgress();
  Object filler;
  if (in_progress) {
    filler = *one_pointer_filler_map();
  } else {
    filler = *undefined_value();
  }
  obj->InitializeBody(*map, start_offset, *undefined_value(), filler);
  if (in_progress) {
    map->FindRootMap(isolate()).InobjectSlackTrackingStep(isolate());
  }

  // <lines removed>
}

这就是slack tracking。对于你创建的每一个类,它在一段时间内占用多余的内存,但是在第七次实例化后,我们将剩余的内存空间交给垃圾回收器进行处理。没有指针指向那些只有一个字大小的对象,所以当垃圾回收发生时,这些内存空间被释放出来,那些仍然存在的实例对象就可以被压缩以节省空间。

下图反映了这个初始map的slack tracking已经完成。请注意,实例大小(instance size)现在是20(5个字:map, properties array,element array,以及另外两个空位)。slack tracking从初始map开始处理整个map链。也就是说,如果初始map的后代map最终使用了10个初始的额外属性,则初始map将保留这些属性并标记为已使用(译者注:原文写着未使用,但怀疑是原文写错了)。 //p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/92f89b01abaf4edfa5fcb4b1d4cef611~tplv-k3u1fbpfcp-zoom-1.image

坐标(X, Y, Z) 表示 (实例大小(instance size),in-object属性的数量, 未使用属性的数量)。

slack tracking到此为止了,如果这时我们给其中Peak对象添加了一个属性会发生什么?

m1.country = 'Switzerland';

V8不得不使用属性的后备存储(properties backing store)。我们最终得到了以下的对象:

wordwhat
0map
1指向properties backing store
2指向elements(空的array)
3指向字符串"Matterhorn"
44478

其中properties backing store看起来就像这样:

wordwhat
0map
1length(3)
2指向字符串"Switzerland"
3undefined
4undefined
5undefined

这里有一些额外的undefined以便你可以添加更多属性。

可选属性

你可能会在某些情况下添加属性。例如当height为4000或者更大时,你需要另外添加两个附加属性,prominenceisClimbed:

function Peak(name, height, prominence, isClimbed) {
  this.name = name;
  this.height = height;
  if (height >= 4000) {
    this.prominence = prominence;
    this.isClimbed = isClimbed;
  }
}

你可以这样变化一下:

const m1 = new Peak('Wendelstein', 1838);
const m2 = new Peak('Matterhorn', 4478, 1040, true);
const m3 = new Peak('Zugspitze', 2962);
const m4 = new Peak('Mont Blanc', 4810, 4695, true);
const m5 = new Peak('Watzmann', 2713);
const m6 = new Peak('Zinalrothorn', 4221, 490, true);
const m7 = new Peak('Eiger', 3970);

在这种情况下,对象m1m3m5m7有一个map,而对象m2m4m6由于附加属性的存在,在初始map的后代map链中有另一个map。当slack tracking结束,对象中有4个in-object属性而不是之前的2个。因为slack tracking会确保为map链上需要的in-objact的最大数量保留足够的空间。

下面展示了上面代码运行后,maps的情况,此时slack tracking已经完成了: //p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/dd60a9cee3ac42af8c002d8d17e85f30~tplv-k3u1fbpfcp-zoom-1.image

坐标(X, Y, Z) 表示 (实例大小(instance size),in-object属性的数量, 未使用属性的数量)。

怎样优化代码?

让我们在slack tracking完成之前编译一些优化的代码。我们将在slack tracking完成之前使用两个原生命令来强制优化编译:

function foo(a1, a2, a3, a4) {
  return new Peak(a1, a2, a3, a4);
}

%PrepareFunctionForOptimization(foo);
const m1 = foo('Wendelstein', 1838);
const m2 = foo('Matterhorn', 4478, 1040, true);
%OptimizeFunctionOnNextCall(foo);
foo('Zugspitze', 2962);

这就可以编译和运行优化的代码了。我们在TurboFan(优化编译器)中做了一件叫做 Create Lowering 的事情,在这里我们内联对象的内存分配。这意味着我们生成的机器码将发出指令让垃圾回收器提供分配对象的实例大小(instance size),然后初始化这些字段。但是如果slack tracking在之后某个时间点先结束了,则我们的代码就会失效。如何解决这个问题?

没关系,我们只是提前结束了这个maps的slack tracking。除非这个函数对象被创建了上千个,我们才会去编译优化后的函数。如果创建的对象少于7个,那么这个对象就不那么重要(记住,通常我们是在程序运行很长时间之后才开始优化)。

在后台线程上编译

主线程上编译优化后的代码可以通过函数调用更改初始map来避免过早结束slack tracking。但是我们大量的编译都在后台线程进行编译。在这个线程中,操作初始maps是非常危险地,因为主线程上的Javascript可能正在改变它。所以我们会这样做:

  1. 当停止slack tracking, 预估实例大小,并记录这个大小。
  2. 当编译即将结束的时候,我们会返回主线程,如果slacking tracking还没有结束,可以通过主线程进行强制安全退出。
  3. 检查:实例大小是否与我们预测地相同,如果不相同,则丢弃这个对象并稍后再试。

如果你想了解一下相关代码,你可以看看 InitialMapInstanceSizePredictionDependency 类以及它是如何在js-create-lowering.cc中创建内联分配。你可以看到在主线程上调用了PrepareInstall()方法来强制完成slack tracking。然后用install()方法来检查我们对实例大小(instance size)地猜测是否成立。

下面是内联分配的优化代码。首先你将看到与垃圾回收器的通信,检查是否可以按实例大小(instance size)向前移动指针并获取该值(这被称为bump-pointer allocation)。然后,我们开始给新对象添加字段:

43  mov ecx,[ebx+0x5dfa4]
49  lea edi,[ecx+0x1c]
4c  cmp [ebx+0x5dfa8],edi       ;; hey GC, can we have 28 (0x1c) bytes please?
52  jna 0x36ec4a5a  <+0x11a>

58  lea edi,[ecx+0x1c]
5b  mov [ebx+0x5dfa4],edi       ;; okay GC, we took it. KThxbye.
61  add ecx,0x1                 ;; hells yes. ecx is my new object.
64  mov edi,0x46647295          ;; object: 0x46647295 <Map(HOLEY_ELEMENTS)>
69  mov [ecx-0x1],edi           ;; Store the INITIAL MAP.
6c  mov edi,0x56f821a1          ;; object: 0x56f821a1 <FixedArray[0]>
71  mov [ecx+0x3],edi           ;; Store the PROPERTIES backing store (empty)
74  mov [ecx+0x7],edi           ;; Store the ELEMENTS backing store (empty)
77  mov edi,0x56f82329          ;; object: 0x56f82329 <undefined>
7c  mov [ecx+0xb],edi           ;; in-object property 1 <-- undefined
7f  mov [ecx+0xf],edi           ;; in-object property 2 <-- undefined
82  mov [ecx+0x13],edi          ;; in-object property 3 <-- undefined
85  mov [ecx+0x17],edi          ;; in-object property 4 <-- undefined
88  mov edi,[ebp+0xc]           ;; retrieve argument {a1}
8b  test_w edi,0x1
90  jz 0x36ec4a6d  <+0x12d>
96  mov eax,0x4664735d          ;; object: 0x4664735d <Map(HOLEY_ELEMENTS)>
9b  mov [ecx-0x1],eax           ;; push the map forward
9e  mov [ecx+0xb],edi           ;; name = {a1}
a1  mov eax,[ebp+0x10]          ;; retrieve argument {a2}
a4  test al,0x1
a6  jnz 0x36ec4a77  <+0x137>
ac  mov edx,0x46647385          ;; object: 0x46647385 <Map(HOLEY_ELEMENTS)>
b1  mov [ecx-0x1],edx           ;; push the map forward
b4  mov [ecx+0xf],eax           ;; height = {a2}
b7  cmp eax,0x1f40              ;; is height >= 4000?
bc  jng 0x36ec4a32  <+0xf2>
                  -- B8 start --
                  -- B9 start --
c2  mov edx,[ebp+0x14]          ;; retrieve argument {a3}
c5  test_b dl,0x1
c8  jnz 0x36ec4a81  <+0x141>
ce  mov esi,0x466473ad          ;; object: 0x466473ad <Map(HOLEY_ELEMENTS)>
d3  mov [ecx-0x1],esi           ;; push the map forward
d6  mov [ecx+0x13],edx          ;; prominence = {a3}
d9  mov esi,[ebp+0x18]          ;; retrieve argument {a4}
dc  test_w esi,0x1
e1  jz 0x36ec4a8b  <+0x14b>
e7  mov edi,0x466473d5          ;; object: 0x466473d5 <Map(HOLEY_ELEMENTS)>
ec  mov [ecx-0x1],edi           ;; push the map forward to the leaf map
ef  mov [ecx+0x17],esi          ;; isClimbed = {a4}
                  -- B10 start (deconstruct frame) --
f2  mov eax,ecx                 ;; get ready to return this great Peak object!
…

顺便一提,要看到所有这些东西,您应该有一个调试版本并传入一些flag。我把代码放到一个文件中,然后调用:

./d8 --allow-natives-syntax --trace-opt --code-comments --print-opt-code mycode.js

希望这是一次有趣的探索。我要特别感谢Igor Sheludko 和 Maya Armyanova(非常耐心!)review这篇文章。