将V8 CPU分析器添加到v8go中
V8是谷歌用C++编写的开源高性能JavaScript和WebAssembly引擎。v8go是一个用Go和C++编写的库,允许用户使用V8隔离物从Go执行JavaScript。使用Cgo绑定允许我们在Go中以本地性能运行JavaScript。
v8go库由Roger Chapman开发,旨在为Go开发者提供一种与V8对接的习惯方法。事实证明,这可能是很棘手的。在过去的几个月里,我一直在为v8go做贡献,以暴露V8的功能。特别是,我一直在增加对V8 CPU分析器的支持。
从一开始,我就希望这个新的API是:
- 易于库中的 Go 用户进行推理
- 最终易于扩展其他剖析器功能
- 与V8的API紧密结合
- 尽可能的有性能。
关于性能的这一点特别有意思。我推测,我的第一个迭代实现的性能不如一个拟议的替代方案。在没有进行基准测试的情况下,我继续重写。第二个实现被合并了,然后我继续我的生活。所以,当我想 "嘿!我应该写一篇关于PR的文章,并对结果进行基准测试 "时,才真正看到了基准测试,并重新考虑了一切。
如果你对API开发、Go/Cgo/C++性能或好的基准测试的重要性感兴趣,这就是一个适合你的故事。
退回起跑线,我的目标是什么?
在v8go中添加V8 CPU剖析器的目的是为了让库的用户能够测量在特定V8上下文中执行的任何JavaScript的性能。除了提供关于正在执行的代码的洞察力,剖析器还返回关于JavaScript引擎本身的信息,包括垃圾回收周期、编译和重新编译,以及代码优化。虽然虚拟机等可以使网络应用程序运行得非常快,但代码仍然应该是高性能的,而且有数据可以帮助我们了解什么时候它不是这样。
如果我们可以使用CPU剖析器,我们可以要求它在我们开始执行任何代码之前开始剖析。剖析器以预先配置的时间间隔对CPU堆栈帧进行采样,直到被告知停止。足够的采样有助于显示热代码路径,无论是在源代码还是在JavaScript引擎中。一旦剖析器停止,就会返回一个CPU配置文件。剖析的形式是一个由节点组成的自上而下的调用树。要走这棵树,你要得到根节点,然后沿着它的子节点一直往下走。
下面是一个我们可以分析的JavaScript代码的例子:
使用v8go,我们首先创建V8隔离、上下文和CPU剖析器。在运行上述代码之前,剖析器被告知要开始剖析。
在代码运行完毕后,停止剖析并返回CPU剖析。这段代码的简化剖析在自上而下的视图中看起来像。
每条线都对应于剖析树中的一个节点。每个节点都有大量的细节,包括:
- 函数的名称(匿名函数为空)。
- 函数所在的脚本的ID
- 函数起源的脚本名称
- 该函数所在行的编号
- 函数所在列的编号
- 该函数所在的脚本是否被标记为跨源共享的脚本
- 目前正在执行该函数的样本数
- 此节点的子节点
- 此节点的父节点
- 以及在v8-profiler.h文件中发现的更多内容。
为了v8go的目的,我们不需要对配置文件应该如何格式化、打印或使用有意见,因为这可能会有变化。有些人甚至会把配置文件变成一个火焰图。更重要的是,我们要关注开发者试图以一种有效的和习惯性的方式来生成一个配置文件的经验。
演变API的实现
鉴于对性能和成语的关注,PR经历了几次不同的迭代。这些迭代可以分为两个不同的回合:第一个回合是懒散地加载配置文件,第二个回合是急切地加载配置文件。让我们从懒惰加载开始。
第一轮:懒惰加载
我最初采取的方法是将v8go与V8的API尽可能地结合起来。这意味着为我们需要的每个V8类和它们各自的函数(即CPUProfiler、CPUProfile和CPUProfileNode)引入一个Go结构。
这是导致剖析器停止剖析并返回CPU配置文件指针的Go代码。
这是相应的C++代码,将Go中的请求翻译成V8的C++。
通过访问Go中的配置文件,我们现在可以得到自上而下的根节点。
根节点行使这段C++代码来访问剖析器指针和其相应的GetTopDownRoot() 方法。
有了自上而下的根节点,我们现在可以遍历树。例如,每次调用获得一个子节点,都是自己的Cgo调用,如图所示。
Cgo调用行使这段C++代码来访问配置文件节点指针及其相应的GetChild() 方法。
这种方法的主要区别在于,要获得任何关于配置文件及其节点的信息,我们必须进行单独的Cgo调用。对于一棵非常大的树,这至少要多调用kN次Cgo,其中k是被查询的属性数量,N是节点的数量。k的值只会随着我们在每个节点上暴露更多的属性而增加。
Go和C是如何相互对话的
在这一点上,我应该更清楚地解释v8go是如何工作的。v8go使用Cgo来弥补Go和V8的C代码之间的差距。Cgo允许Go程序与C语言库互操作:可以从Go调用C语言,反之亦然。
经过一些关于Cgo性能的研究,你会发现Sean Allen在Gophercon 2018的演讲中提出了以下建议。
"将你的CGO调用批量化。你应该知道这一点去做,因为它可以从根本上影响你的设计。此外,一旦你越过边界,尽量在另一边做得更多。因此,对于go => "C",你可以在一个 "C "调用中做尽可能多的事情。同样,对于 "C"=>"去",在一次 "去 "的调用中尽可能地多做。甚至更多,因为开销要高得多"。
同样,你会发现Dave Cheney的优秀作品 "cgo不是go",它解释了使用cgo的意义。
"C对Go的调用惯例或可增长的堆栈一无所知,所以调用下来的C代码必须记录goroutine堆栈的所有细节,切换到C堆栈,并运行C代码,而C代码对它是如何被调用的,或负责程序的更大的Go运行时间一无所知。
...
启示是,在C语言和Go语言世界之间的转换是非同小可的,而且永远不会没有开销"。
当我们谈论 "开销 "时,实际成本可能因机器而异,但另一位贡献者v8go(Dylan Thacker-Smith)运行的一些基准显示,Go到C的调用的开销约为每操作54纳秒(ns/op),C到Go的调用为149ns/op。
考虑到这些信息,对懒惰加载的关注是合理的:当用户需要遍历树时,他们会进行更多的Cgo调用,每次都会产生开销成本。在审查了PR之后,Dylan提出了一个建议:在C代码中构建整个配置文件图,然后将一个指针传回Go,这样Go就可以使用加载了所有信息的Go数据结构重建相同的图,然后再传给用户。这极大地减少了Cgo的调用次数。这就把我们带到了第二回合。
第二回合:急于加载
为了建立一个可视化的档案,用户需要访问档案中的大部分(如果不是全部)节点。我们还知道,为了提高性能,我想限制为此必须进行的C语言调用的数量。因此,我们将获取整个调用图的重任转移到我们的C++函数StopProfiling ,这样我们返回给Go代码的指针就是完全加载所有节点及其属性的调用图。我们的goCPUProfile 和CPUProfileNode 对象将与V8的API相匹配,它们有相同的获取器,但现在在内部,它们只是返回结构体私有字段的值,而不是返回到C++代码。
这就是C++中的StopProfiling 函数现在所做的:一旦剖析器返回剖析,该函数可以从根节点开始遍历图,并构建出C数据结构,这样就可以向Go代码返回剖析的单个指针,该代码可以遍历图以构建相应的Go数据结构。
Go中的相应函数,StopProfiling ,使用Cgo调用上述C函数(CPUProfilerStopProfiling),以获得我们的C结构CPUProfile 的指针。通过遍历树,我们可以建立Go数据结构,这样CPU配置文件就可以从Go端完全访问。
有了这种急切的加载,其余的Go调用获取配置文件和节点数据就像返回结构上的私有字段的值一样简单。
第三回合(也许):懒惰或急于加载
有可能出现一种变化,即上述两种实现方式都可以选择。这意味着允许用户决定在哪里懒散地或急切地加载配置文件中的所有内容。这也是为什么在PR的最终实现中,保留了getters而不是将所有的Node和Profile字段公开的另一个原因。有了getters和私有字段,我们就可以根据用户希望配置文件的加载方式来改变引擎下发生的一切。
速度就是一切,那么哪一个更快?
比较懒惰和急切的加载需要一个测试,执行一些具有相当规模的树的JavaScript程序,这样我们就可以在许多节点上进行大量的Cgo调用。我们将测量通过在C语言中急切地构建树,并将完整的调用图作为指针返回给Go,是否有性能上的提升。
在相当长的一段时间内,我使用先前的JavaScript代码进行了基准测试。从这些测试中,我发现:
- 当懒惰地加载树时,建立树的平均时间是~20微秒。
- 当急于加载树的时候,建立树的平均时间是~25微秒。
可以说,这些结果是出乎意料的。事实证明,理论上的急切方法的行为并没有比懒惰加载更理想,事实上,它恰恰相反。对于这种树的大小,它依赖于更多的Cgo调用。
然而,由于这些结果是出乎意料的,我决定使用Hydrogen启动模板尝试一个更大的树。通过测试,我发现:
- 当懒惰加载树时,构建它的平均时间是~90微秒。
- 当急于加载树时,建立树的平均时间是~60微秒。
这些结果与我们对进行大量Cgo调用的性能影响的理解更加一致。看起来,对于一棵小树来说,遍历三次(两次急于加载信息,一次打印信息)的成本并不比包括大量Cgo调用的单次打印成本低。真正的成本只有在更大的树上才会显示出来,前期图的遍历成本的好处对最终要打印的大树的遍历大有好处。如果我没有尝试不同大小的输入,我就不会看到急于加载的价值最终显示出来。如果我把增长线的各自做法画在一张图上,它看起来会是这样的。
回头看终点线
作为一个长期的Go开发者,在内存管理和性能方面有很多事情是我认为理所当然的。在v8go库的工作迫使我学习Cgo和C++,使我能够理解性能瓶颈可能在哪里,如何围绕它们进行实验,以及如何找到优化它们的方法。特别是将CPU剖析的功能贡献给库,提醒了我:
- 当性能很重要的时候,我应该对代码进行基准测试,而不是只凭自己(或别人)的直觉行事。绝对需要时间来充实一个足够的替代代码路径来做公平的基准测试,但有可能在这个过程中会有一些发现。
- 设计一个基准很重要。如果基准中的变量不能反映一般的使用情况,那么基准就不可能是有用的,甚至可能是混乱的。