我们如何将 Chrome 开发者工具的堆栈轨迹速度提升 10 倍

Benedikt Meurer
Benedikt Meurer

Web 开发者已经习惯了在调试代码时几乎不会对性能产生影响。不过,这种预期并非普遍适用。C++ 开发者永远不会指望其应用的调试版本能达到生产性能,并且在 Chrome 的早期版本,只需打开开发者工具即可显著影响页面的性能。

多年来,我们一直投资于 DevToolsV8 的调试功能,因此现在不会再感觉性能下降。不过,我们永远无法将 DevTools 的性能开销降至零。设置断点、单步调试代码、收集堆栈轨迹、捕获性能轨迹等操作都会在不同程度上影响执行速度。毕竟,观察结果会改变事情

不过,与任何调试程序一样,DevTools 的开销应该是合理的。最近,我们发现有大量报告指出,在某些情况下,DevTools 会导致应用运行速度变慢,甚至无法使用。下面是报告 chromium:1069425 的对比图,展示了仅仅打开开发者工具所产生的性能开销。

如视频所示,速度下降幅度达到了 5-10 倍,这显然是不可接受的。第一步是了解所有时间都去了哪里,以及开发者工具打开时速度大幅下降的原因。在 Chrome 呈现程序上使用 Linux perf 后,我们发现了总呈现程序执行时间的以下分布:

Chrome 呈现程序执行时间

虽然我们原本以某种方式看到与收集堆栈轨迹有关的内容,但我们无法预料到总执行时间中大约有 90% 会花在对堆栈帧进行符号化处理。这里所说的符号化是指从原始堆栈帧中解析函数名称和具体源代码位置(脚本中的行号和列号)的操作。

方法名称推理

更令人惊讶的是,几乎所有时间都会用到 V8 中的 JSStackFrame::GetMethodName() 函数,尽管我们从之前的调查中了解到,JSStackFrame::GetMethodName() 在性能问题领域并不陌生。此函数会尝试为被视为方法调用的帧(表示 obj.func() 而非 func() 形式的函数调用的帧)计算方法的名称。快速查看代码后发现,它通过对对象及其原型链执行完全遍历来发挥作用,并查找

  1. valuefunc 闭包的数据属性,或者
  2. 访问器属性,其中 getset 等于 func 闭包。

虽然这听起来并不便宜,但听起来也不是能解释这一可怕的放缓的原因。因此,我们开始深入研究 chromium:1069425 中报告的示例,发现异步任务以及源自 classes.js(一个 10MiB JavaScript 文件)的日志消息都收集了堆栈轨迹。仔细研究后发现,这基本上是 Java 运行时以及编译为 JavaScript 的应用代码。堆栈轨迹包含多个帧,其中包含对对象 A 调用的方法,因此我们认为不妨了解一下我们所处理的对象类型。

对象的堆栈轨迹

显然,Java 到 JavaScript 编译器生成了一个包含 82,203 个函数的单个对象,这显然开始变得有趣了。接下来,我们回到了 V8 的 JSStackFrame::GetMethodName(),以了解是否有可以轻松改进的地方。

  1. 其工作原理是,首先将函数的 "name" 作为对象上的属性进行查找,如果找到,则检查属性值是否与函数匹配。
  2. 如果函数没有名称或对象没有匹配的属性,则会回退到通过遍历对象及其原型的所有属性来进行反向查找。

在我们的示例中,所有函数都是匿名的,并且具有空的 "name" 属性。

A.SDV = function() {
   // ...
};

第一个发现是,反向查找被分为两个步骤(针对对象本身及其原型链中的每个对象执行):

  1. 提取所有可枚举属性的名称,以及
  2. 对每个名称执行通用属性查找,测试生成的属性值是否与我们要查找的闭包匹配。

这似乎是一个非常简单的改进,因为提取名称需要遍历所有属性。我们可以一次完成所有操作,直接检查属性值,而不是执行两次传递(名称提取为 O(N),测试为 O(N log(N)))。这使得整个函数的速度提高了约 2-10 倍

第二项发现更有趣。虽然这些函数在技术上是匿名函数,但 V8 引擎仍为它们记录了我们称为推断名称的名称。对于以 obj.foo = function() {...} 形式显示在赋值右侧的函数字面量,V8 解析器会将 "obj.foo" 记忆为函数字面量的推断名称。在我们的示例中,这意味着,虽然我们没有可以直接查找的正确名称,但我们确实有足够接近的名称:在上述 A.SDV = function() {...} 示例中,我们将 "A.SDV" 作为推断名称,并且可以通过查找最后一个点来从推断名称派生属性名称,然后在对象上查找属性 "SDV"。在几乎所有情况下,这都非常有效,将昂贵的完整遍历替换为单次属性查找。这两项改进已通过此 CL 发布,并显著减少了 chromium:1069425 中报告的示例的速度下降。

Error.stack

我们本可以就此结束。但情况有点不寻常,因为开发者工具从不使用堆栈帧的方法名称。事实上,C++ API 中的 v8::StackFrame甚至未公开用于获取方法名称的方法。所以,我们一开始最终会调用 JSStackFrame::GetMethodName(),这似乎是错误的。相反,我们只在 JavaScript 堆栈轨迹 API 中使用(并公开)方法名称。为了理解这种用法,请考虑以下简单示例 error-methodname.js

function foo() {
    console.log((new Error).stack);
}

var object = {bar: foo};
object.bar();

这里有一个函数 foo,它安装在 object 上,名称为 "bar"。在 Chromium 中运行此代码段会生成以下输出:

Error
    at Object.foo [as bar] (error-methodname.js:2)
    at error-methodname.js:6

在这里,我们可以看到方法名称查找的运作方式:最顶部的堆栈帧显示为通过名为 bar 的方法对 Object 实例调用函数 foo。因此,非标准 error.stack 属性会大量使用 JSStackFrame::GetMethodName(),事实上,我们的性能测试也表明,我们的更改使速度显著加快。

提高了 StackTrace 微基准的速度

但回到 Chrome 开发者工具的主题,即使不使用 error.stack 也会计算方法名称,这一事实似乎不正确。下面介绍了一些历史背景,对我们很有帮助:传统上,V8 有两个不同的机制来收集和表示上述两个不同 API(C++ v8::StackFrame API 和 JavaScript 堆栈轨迹 API)的堆栈轨迹。采用两种(大致)相同的方法都很容易出错,并且往往会导致不一致和 bug,因此在 2018 年底,我们启动了一个项目,旨在解决堆栈轨迹捕获的单一瓶颈。

该项目取得了巨大成功,大大减少了与堆栈轨迹收集相关的问题数量。通过非标准 error.stack 属性提供的大多数信息也进行了延迟计算,并且仅在确实需要时才进行计算,但作为重构的一部分,我们将相同的技巧应用于 v8::StackFrame 对象。有关堆栈帧的所有信息都是在首次对堆栈帧调用任何方法时计算的。

这通常可以提高性能,但遗憾的是,这与这些 C++ API 对象在 Chromium 和开发者工具中的使用方式截然相反。具体而言,由于我们引入了新的 v8::internal::StackFrameInfo 类,该类包含通过 v8::StackFrameerror.stack 公开的有关堆栈帧的所有信息,因此我们始终会计算这两个 API 提供的信息的超集,这意味着,对于 v8::StackFrame 的用法(尤其是对于 DevTools),只要请求有关堆栈帧的任何信息,我们也会计算方法名称。事实证明,DevTools 始终会立即请求源代码和脚本信息。

基于这一认知,我们能够重构并大幅简化堆栈帧的表示方式,并进一步简化堆栈帧表示。这极大地提升了 DevTools 和其他 Chromium 用例的性能,因为这些用例只需要一小部分堆栈帧信息(本质上只是行号和列偏移形式的脚本名称和源代码位置),并为进一步提升性能打开了大门。

函数名称

完成上述重构后,符号化开销(在 v8_inspector::V8Debugger::symbolize 中花费的时间)已缩减到总执行时间的 15% 左右,我们可以更清楚地了解 V8 在为 DevTools 使用堆栈帧进行符号化(收集和符号化)时花费的时间。

符号化费用

最突出的一点是计算行号和列号的累计费用。这里耗时较长的部分实际上是计算脚本中的字符偏移量(基于我们从 V8 获取的字节码偏移量),事实证明,由于我们进行了上述重构,我们执行了两次此操作,一次是在计算行号时,另一次是在计算列号时。在 v8::internal::StackFrameInfo 实例上缓存源位置有助于快速解决此问题,并从所有配置文件中彻底消除 v8::internal::StackFrameInfo::GetColumnNumber

更有趣的发现是,我们所研究的所有配置文件中的 v8::StackFrame::GetFunctionName 都出奇的高。深入研究后,我们发现计算要在 DevTools 的堆栈帧中显示的函数名称是一件不必要的开销,

  1. 首先查找非标准 "displayName" 属性,如果该属性产生了字符串值的数据属性,我们会使用该属性,
  2. 否则,回退到查找标准 "name" 属性,并再次检查是否会产生值为字符串的数据属性,
  3. 最终会回退到由 V8 解析器推断并存储在函数字面量上的内部调试名称。

"displayName" 属性是在 JavaScript 中添加的,旨在解决 Function 实例上的 "name" 属性是只读且不可配置的问题,但从未标准化,也没有广泛使用,因为浏览器开发者工具添加了函数名称推理功能,在 99.9% 的情况下都能胜任此工作。此外,ES2015 使 Function 实例上的 "name" 属性可配置,完全不需要使用特殊的 "displayName" 属性。由于对 "displayName" 进行负查找非常耗费资源且并非真正必要(ES2015 发布已超过 5 年),因此我们决定从 V8(和 DevTools)中移除对非标准 fn.displayName 属性的支持

由于不再需要对 "displayName" 进行负查找,因此 v8::StackFrame::GetFunctionName 的开销减少了一半。另一半则用于通用 "name" 属性查找。幸运的是,我们已经制定了一些逻辑来避免对(未经处理的)Function 实例进行耗时的 "name" 属性查找,这些逻辑是我们之前在 V8 中引入的,目的是加快 Function.prototype.bind() 本身的速度。我们移植了必要的检查,这让我们能够一开始就跳过耗时的通用查找,结果是 v8::StackFrame::GetFunctionName 不再显示在我们考虑的任何配置文件中。

总结

通过上述改进,我们大幅降低了堆栈轨迹方面的 DevTools 开销。

我们知道仍有各种可能的改进之处,例如,使用 MutationObserver 时的开销仍然明显(如 chromium:1077657 中所报告),但目前,我们已解决主要问题,未来可能会继续改进,以进一步提升调试性能。

下载预览渠道

请考虑将 Chrome Canary开发者版Beta 版用作您的默认开发浏览器。通过这些预览版渠道,您可以使用最新的 DevTools 功能、测试尖端的 Web 平台 API,并帮助您在用户发现问题之前发现网站上的问题!

与 Chrome 开发者工具团队联系

使用以下选项讨论新功能、更新或与开发者工具相关的任何其他内容。