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

Benedikt Meurer
Benedikt Meurer

Web 开发者已经习惯了在调试代码时几乎不会对性能产生影响。不过,这种预期并非普遍适用。C++ 开发者绝不会期望应用的调试 build 能达到生产环境的性能,而在 Chrome 的早期,仅仅打开 DevTools 就会对页面性能产生显著影响。

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

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

如视频所示,速度下降幅度达到了 5-10 倍,这显然是不可接受的。首先,我们需要了解所有时间都花在哪里,以及打开 DevTools 时出现这种严重速度下降的原因。在 Chrome 呈现程序上使用 Linux perf 后,我们发现了总呈现程序执行时间的以下分布:

Chrome 呈现程序执行时间

虽然我们预计会看到与收集堆栈轨迹相关的内容,但我们没想到的是,总执行时间的约 90% 都用于对堆栈帧进行符号化处理。这里所说的符号化是指从原始堆栈帧中解析函数名称和具体源代码位置(脚本中的行号和列号)的操作。

方法名称推理

更令人惊讶的是,几乎所有时间都花在 V8 中的 JSStackFrame::GetMethodName() 函数上,尽管我们从之前的调查中知道 JSStackFrame::GetMethodName() 在性能问题方面并不陌生。此函数会尝试计算被视为方法调用的帧(表示函数调用的帧,形式为 obj.func(),而非 func())的方法名称。快速浏览代码后发现,该函数的工作原理是执行对象及其原型链的完整遍历,并查找

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

虽然这本身听起来并不特别便宜,但也似乎无法解释这种严重的速度下降。因此,我们开始深入研究 chromium:1069425 中报告的示例,发现系统为异步任务以及来自 classes.js(一个 10 MiB 的 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 DevTools 的话题,即使未使用 error.stack,系统也会计算方法名称,这似乎不太正确。下面介绍了一些历史背景,对我们很有帮助:传统上,V8 有两个不同的机制来收集和表示上述两个不同 API(C++ v8::StackFrame API 和 JavaScript 堆栈轨迹 API)的堆栈轨迹。使用两种不同的方法来执行(大致)相同的操作很容易出错,并且经常会导致不一致和 bug,因此我们在 2018 年底启动了一个项目,以确定用于捕获堆栈轨迹的单个瓶颈。

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

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

基于这一认识,我们能够重构并大幅简化堆栈帧表示,并使其更加懒惰,因此 V8 和 Chromium 中的使用现在只需支付计算所需信息的费用。这极大地提升了 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 DevTools 团队联系

您可以使用以下选项讨论与 DevTools 相关的新功能、更新或任何其他内容。