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

贝内迪克特·默里尔
Benedikt Meurer

网络开发者在调试代码时预计不会受到性能影响。但是,这种期望绝不是普遍的。C++ 开发者永远不会指望其应用的调试版本能达到生产性能,而在 Chrome 的早期年代,仅仅打开开发者工具就极大地影响了页面的性能。

我们多年来在 DevToolsV8 的调试功能方面进行了大量投资,因此,用户已逐渐消除这种性能下降的现象。不过,我们永远无法将开发者工具的性能开销降到零。设置断点、单步调试代码、收集堆栈轨迹、捕获性能轨迹等都会对执行速度产生不同程度的影响。毕竟,观察到会改变它

当然,开发者工具的开销(就像任何调试程序一样)应该在合理范围内。最近,我们发现报告数量显著增加,而在某些情况下,开发者工具会减慢应用的运行速度,以致应用无法再使用。下图对 chromium:1069425 报告进行了并排比较,显示了仅打开开发者工具时出现的性能开销。

您可以从视频中看到,降速大约是 5-10 倍,这是显然无法接受的。第一步是了解一直以来在哪里,以及导致开发者工具打开时速度大幅下降的原因。通过对 Chrome 渲染程序进程使用 Linux 性能,结果显示渲染程序的总体执行时间如下:

Chrome 渲染程序的执行时间

虽然我们原本预计会看到与收集堆栈轨迹相关的信息,但预计大约 90% 的总执行时间会用于对堆栈帧进行符号化解析。这里的符号化是指从原始堆栈帧解析函数名称和具体源位置(脚本中的行号和列号)的行为。

方法名称推断

更令人惊讶的是,尽管我们在之前的调查中知道 JSStackFrame::GetMethodName() 在性能问题方面并不陌生,但几乎所有时间都用于 V8 中的 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 Micro 基准测试速度

还是回到 Chrome 开发者工具的话题,虽然未使用 error.stack 也会计算方法名称,但事实似乎并不合理。这里的一些历史记录可以帮助我们了解:过去,V8 采用两种不同的机制来收集和表示上述两种不同 API(C++ v8::StackFrame API 和 JavaScript 堆栈轨迹 API)的堆栈轨迹。采用两种不同的方法(大致而言)是相同的,很容易出错,并且常常会导致不一致和 bug,因此在 2018 年底,我们启动了一个项目,旨在确定堆栈轨迹捕获的单一瓶颈。

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

这通常会提升性能,但遗憾的是,结果证明,这有点与这些 C++ API 对象在 Chromium 和开发者工具中的使用方式背道而驰。具体而言,由于我们引入了新的 v8::internal::StackFrameInfo 类(用于存储通过 v8::StackFrame 或通过 error.stack 公开的堆栈帧的所有信息),因此我们始终会计算这两个 API 提供的信息的超集,这意味着,对于 v8::StackFrame(尤其是开发者工具)的使用,我们还会在请求任何有关堆栈帧的信息时立即计算方法名称。事实证明,DevTools 总是会立即请求源和脚本信息。

基于这一认识,我们能够重构并大幅简化堆栈帧表示,并使其更具有延迟性,因此在整个 V8 和 Chromium 中使用这种表示方法现在只需为计算所需的信息而付出代价。这极大地提升了开发者工具和其他 Chromium 用例的性能,因为开发者工具和其他 Chromium 用例只需要少量关于堆栈帧的信息(实际上只是以行和列偏移的形式表示脚本名称和来源位置),为更多性能改进开辟了一扇大门。

函数名称

忽略了上述重构之后,符号化的开销(在 v8_inspector::V8Debugger::symbolize 中花费的时间)减少到约为总执行时间的 15%,我们还可以更清楚地看到 V8 在开发者工具中(收集和)符号化处理堆栈帧以供使用的时间花在了何处。

符号化费用

最突出的一点是,计算行号和列号的累计费用。这里开销非常大的部分实际上是计算脚本内的字符偏移量(基于我们从 V8 获取的字节码偏移量),而结果表明,由于上述重构,我们进行了两次重构,一次是在计算行号,另一次是在计算列号。在 v8::internal::StackFrameInfo 实例上缓存源位置有助于快速解决此问题,并将 v8::internal::StackFrameInfo::GetColumnNumber 从所有配置文件中彻底排除。

对我们来说,更有意思的发现是,在我们看过的所有画像中,v8::StackFrame::GetFunctionName 的排名出乎意料地高。深入分析后,我们发现,为开发者工具的堆栈帧中显示的函数名称计算不必要的开销,

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

添加了 "displayName" 属性作为 Function 实例的 "name" 属性在 JavaScript 中只读且不可配置的解决方法,但从未标准化,也没有得到广泛使用,因为浏览器开发者工具添加了函数名称推断功能,在 99.9% 的情况下都能起到作用。在此之后,ES2015 还可以配置 Function 实例上的 "name" 属性,完全无需使用特殊的 "displayName" 属性。由于对 "displayName" 进行否定查找的成本很高,而且没有必要(ES2015 是在五年前发布),因此我们决定从 V8(和开发者工具)中移除对非标准 fn.displayName 属性的支持

由于排除了 "displayName" 的否定查询,因此 v8::StackFrame::GetFunctionName 的一半费用被移除了。另一半用于通用的 "name" 属性查询。幸运的是,我们已经有了一些逻辑,以避免在(未改动的)Function 实例上查询 "name" 属性时成本高昂。我们不久前在 V8 中引入了该逻辑,目的是提高 Function.prototype.bind() 本身的运行速度。我们移植了必要的检查,使我们能够从一开始就跳过开销高昂的通用查询,结果就是 v8::StackFrame::GetFunctionName 不会再出现在我们考虑过的任何配置文件中。

总结

通过上述改进,我们显著降低了开发者工具在堆栈轨迹方面的开销。

我们知道仍有各种可能的改进,例如,使用 MutationObserver 时的开销仍然很明显(如 chromium:1077657 中所述),但目前,我们已解决了主要痛点,日后我们可能会回来进一步简化调试性能。

下载预览渠道

不妨考虑将 Chrome Canary 版开发者版Beta 版用作默认开发浏览器。通过这些预览渠道,您可以使用最新的开发者工具功能,测试先进的网络平台 API,并先于用户发现您网站上的问题!

与 Chrome 开发者工具团队联系

使用以下选项讨论博文中的新功能和变化,或讨论与开发者工具有关的任何其他内容。

  • 请通过 crbug.com 向我们提交建议或反馈。
  • 使用开发者工具中的更多选项   了解详情   > Help > Report a DevTools issues,报告开发者工具问题。
  • 您可以前往 @ChromeDevTools 发 Twitter 微博。
  • 请在 YouTube 视频或“开发者工具提示”YouTube 视频中留言说明“开发者工具的新变化”。