proposal: runtime/pprof: add goroutine stack memory usage profile

col17t5w  于 10个月前  发布在  Go
关注(0)|答案(7)|浏览(84)

提案详情

概述

我提议实现一个新的性能剖析类型,该类型允许按帧分解 goroutine 栈空间使用情况。

  • 没有新的 API。作为 goroutine 性能剖析的新样本类型添加
  • 每个堆栈跟踪的值是叶帧的空间之和
  • 通过虚拟 runtime._FreeStack 叶节点表示可用的栈空间
  • 总和应该接近或等于 /memory/classes/heap/stacks:bytes
  • 这里有一个粗略的原型 CL:https://go-review.googlesource.com/c/go/+/574795(不包括测试,共 200 LoC)

鉴于上述情况,也许这个小而简单,可以跳过正式提案过程。但是,由于设计中包含一些有争议的选择,最好提前进行一些讨论和达成共识。

动机

我提出这个的主要动机来自于调试部署 PGO 到生产环境时出现的显著栈空间增长的情况(#65532),我能够通过在用户空间实现的一个hacky栈空间剖析器来根本原因(repo)。此外,我认为这种性能剖析类型对于其他场景也很有用,例如高 CPU 使用率(morestack)(#18138)。

实现

我提议通过将每个 goroutine 性能剖析中的堆栈跟踪与其帧大小进行查找来实现此性能剖析类型(❶ 显示单个堆栈跟踪的帧大小)。然后,每个堆栈跟踪被拆分为一个根级别的前缀(从根开始)的堆栈跟踪,这些堆栈跟踪被分配为其叶帧的帧大小作为值(❷)。这将产生一个火焰图,其中每个帧的“自身值”对应于其帧大小,其总值对应于其帧大小加上其子节点的帧大小(❸)。
这些值然后乘以捕获给定堆栈跟踪的 goroutines 数量,得到栈空间使用的总和。
最后但同样重要的是,添加一个 runtime._FreeStack 叶节点以捕获帧之间使用的栈空间与 goroutine 的总栈大小之间的差异。此外,还使用 runtime._FreeStack 在根级别显示当前未使用的 goroutine 栈内存量。这些虚拟帧的灵感来自于产生一个总和为 /memory/classes/heap/stacks:bytes 的性能剖析,并让用户能够推理潜在的 morestack 问题。

原型

我已经在这里上传了一个原型的粗糙 CL:https://go-review.googlesource.com/c/go/+/574795(不包括测试,共 200 LoC)。
使用此原型,我们可以查看具有以下 goroutines 的程序的实际堆栈分析:

  • 一个具有一千字节帧的 goroutine
  • 一个具有两千字节帧的 goroutine
  • 两个具有三千字节帧的 goroutines

代码片段

  1. func launchGoroutinesWithKnownStacks() func() {
  2. c1 := make(chan struct{})
  3. c2 := make(chan struct{})
  4. c3 := make(chan struct{})
  5. c4 := make(chan struct{})
  6. go oneThousand(c1)
  7. go twoThousand(c2)
  8. go threeThousand(c3)
  9. go threeThousand(c4)
  10. <-c1
  11. <-c2
  12. <-c3
  13. <-c4
  14. // hacky way to ensure all goroutines reach the same <-ch statement
  15. // TODO(fg) make caller retry in the rare case this could go wrong
  16. time.Sleep(10 * time.Millisecond)
  17. return func() {
  18. c1 <- struct{}{}
  19. c2 <- struct{}{}
  20. c3 <- struct{}{}
  21. c4 <- struct{}{}
  22. }
  23. }
  24. //go:noinline
  25. func oneThousand(ch chan struct{}) [1000]byte {
  26. var a [1000]byte
  27. ch <- struct{}{}
  28. <-ch
  29. return a
  30. }
  31. //go:noinline
  32. func twoThousand(ch chan struct{}) [2000]byte {
  33. var a [2000]byte
  34. ch <- struct{}{}
  35. <-ch
  36. return a
  37. }
  38. //go:noinline
  39. func threeThousand(ch chan struct{}) [3000]byte {
  40. var a [3000]byte
  41. ch <- struct{}{}
  42. <-ch
  43. return a
  44. }

注意:原型尚未实现提议的根级别的 runtime._FreeStack 帧。

性能

我还没有测量,但我怀疑所有这些都可以在不影响 goroutine 性能剖析开销的情况下完成。

下一步

请告诉我您的想法。cc @prattmic@mknyszek@nsrip-dd@rhysh(这是最近一次运行时诊断同步中讨论过的,参见备注)

o3imoua4

o3imoua41#

我认为整体的想法,感谢提交提案。
从查看示例图像中,我有一个轻微的“本能React”,那就是 _FreeStack 帧的显示有点奇怪。乍一看,它看起来像是 chanrecv1 路径正在“使用”大量的堆空间,但实际上它与堆使用没有真正的关系,只是恰好是 goroutine 停放的地方,也是帧附加的地方。
话虽如此,我立刻就想不出如何更好地显示它。这里的基本问题似乎是通过共同的堆栈帧进行聚合,使事情变得复杂。
我能想到的“修复”方法有:

  1. 强制工具避免堆栈聚合。也就是说,每个 goroutine 都单独显示,并带有伪造的根级别的“goroutine N”帧。在该帧下有两个节点:一个包含实际的堆栈帧,另一个包含 _FreeStack 。我认为这会使单个 goroutine 更容易理解,但代价是使其更难以看到聚合效应(例如,我有128个重复的工作 goroutines,它们在某个框架中总共使用了大量的堆栈)。
  2. 避免每个 goroutine 的 _FreeStack 帧,而是在根级别的 _FreeStack 帧中分组额外的堆空间。这是数据丢失,尽管对我来说还不是完全清楚这些数据有多有用。我可以想象的一个有用的方面是学习在实际增加堆栈大小之前,你还有相当大的余量可以增加堆栈大小。
wswtfjt7

wswtfjt72#

感谢您的反馈 @prattmic。我也将此添加为明天运行时诊断同步的可能议程项目,因为实时解决这些问题可能更容易。

从查看示例图像中,我有一个轻微的“下意识”React,那就是 _FreeStack 帧的显示有点奇怪。乍一看,它似乎 chanrecv1 路径占用了很多堆栈空间,但实际上它与堆栈使用无关,只是 goroutine 停放的地方,以及帧附加的位置。

是的,我同意。这确实令人困惑。
(但我不认为这是关于现实的误导。真正的空闲堆栈内存位于 gopark 下方,gopark 的自大小是正确的。但是的,这里的现实有点不直观,我同意这一点:p)

话虽如此,我立刻就想不出如何更好地显示它。这里的基本问题似乎是通过公共堆栈帧进行聚合使事情变得复杂。
我能想到的“修复”方法有:

  1. 强制工具避免堆栈聚合。也就是说,每个 goroutine 都单独显示在自己的“goroutine N”根级别框架下。在该框架下有两个节点:一个包含实际的堆栈帧,另一个包含 _FreeStack 。我认为这会使单个 goroutine 更容易理解,但代价是使其更难以看到聚合效果(例如,我有128个重复的工作 goroutines,它们在某个框架中总共占用了大量堆栈)。
  2. 避免每个 goroutine 都有 _FreeStack 框架,而是在根级别 _FreeStack 框架中分组额外的堆栈空间。虽然这会丢失一些数据,但对我来说还不是完全清楚这些数据有多有用。我可以想象的一个有用的方面是学习在实际增加堆栈大小时之前,您有相当大的余量可以增加堆栈大小。
    好主意。那么关于版本1的一个变体,即在 goroutine 的根框架下方添加 _FreeStack ,但没有为每个 goroutine 创建一个根节点。以下是这将看起来像什么的截图:

您有什么想法吗?

展开查看全部
e3bfsja2

e3bfsja23#

嗯,我更喜欢这种方式,但顺序对我来说仍然感觉有些倒置(当第一个帧正在使用实际的堆栈空间时,免费堆栈空间是第二个帧,这似乎很奇怪)。但是这给了我另一个想法:
如果我们颠倒“免费堆栈空间”的概念,而是显示总分配的堆栈大小呢?具体来说,每个堆栈在“[总计]”帧结束,其值为总分配的堆栈大小。在火焰图中,你会得到类似这样的东西(原谅ASCII艺术):

  1. ------------------------------------------------
  2. | root |
  3. ------------------------------------------------
  4. | [total] |
  5. ------------------------------------------------
  6. | threeThousand | twoThousand |
  7. --------------------------------------

左下角的空白空间是程序中的总空闲堆栈空间。
这听起来有点像我上面建议的(2),因为我们看不到每个goroutine类型的空闲空间,但我不确定我们必须失去这些信息。从理论上讲,运行 -focus threeThousand 应该只给我们 threeThousand goroutine部分,我希望它能从 threeThousand goroutines中仅显示一个“[总计]”条目。
我不太确定这是否会立即起作用,考虑到我们以略微奇怪的方式爆炸样本。我们可能需要为每个goroutine使用唯一的伪PC( 0x1d00000010x1d0000002等),即使这样,我也不确定 -focus 会表现得如我们所愿。

wecizke3

wecizke34#

我认为如果我看到这样的profile,我很快就想回到过去看看goroutine在运行时确定需要增长堆栈时调用的堆栈。但看起来目前的提议是将信息与goroutine的当前堆栈绑定在一起。
我希望一个CPU profile查看morestack会给出一些提示,但这似乎类似于追踪运行时内部锁竞争所需的工作:你需要在行动中抓住它,并且你需要对最近的行为是否代表历史行为做出假设。
具体来说,考虑一个HTTP服务器,其中:每个请求都处理得很快,有些请求会导致深层次的调用堆栈,所有请求都经常使用空闲的http/1.1 keep-alive连接。听起来目前的提议会在堆栈上显示很多具有net/http.(*conn).serve但没有...ServeHTTP的goroutine,其中一些具有大量的runtime._FreeStack,但没有明确指向哪个部分的http.Handler负责增长。

l2osamch

l2osamch5#

我不确定这个方法是否能直接生效,考虑到我们正在以一种稍微奇怪的方式爆炸样本。
@prattmic 我喜欢这个想法,但我认为 -focus 不会做我们需要它做的事情。即使是伪造的PC。
问题在于节点下方的“空空间”是由仅包含该节点的堆栈跟踪创建的。在上面的示例中,a 下方的“空空间”是由仅包含 a 的堆栈跟踪创建的。

因此,根据下面的焦点定义,我认为 -focus b 将匹配堆栈 a;b;ca;b ,但不匹配 a(这相当于您示例中的 total 节点)。

  1. -focus Restricts to samples going through a node matching regexp

查看其他pprof选项,-show(仅适用于图形视图?)或 -show_from 都不会满足我们的需求。理论上,我们可以实现一个新的 -focus_parents 选项来满足我们的需求。但我仍然不确定这是否能与您的伪造PC概念一起工作。因为默认情况下,火焰图将在函数级别进行聚合,所以任何操作都应该考虑单个pc值🤔。
无论如何,我还意识到其他几个地方堆栈内存可能会隐藏:

  • 系统 Goroutines(排除在 goroutine profile 之外)
  • M 上的 g0gsignal 堆栈

将此作为根级别的 runtime._FreeStack 跟踪可能会误导。因此,我们可能至少需要 runtime._StackFree(用于堆栈池)和 runtime._StackSystem 分别计算这些堆栈使用区域。
当然,我们也可以考虑不处理任何这些内容,只显示当前堆栈上的帧的堆栈内存使用情况。但这将留下一个与 /memory/classes/heap/stacks:bytes 相比非常大的空白区域,除非我们决定将其分成两个或更多指标。然而,这样做的优势是它更接近于 heap_inuse 分析,在那里我们也只显示活动内存使用情况而不显示空闲空间(死对象 + 空闲堆 + 未使用的堆)。这对我在 #65532 中的分析已经足够好了。

展开查看全部
bogh5gae

bogh5gae6#

我明白你的观点。我在连续分析中存在隐性偏见,假设用户可以访问许多堆栈分析并能够聚合它们。实际上,这应该允许用户在CPU分析中看到包括 morestack 帧在内的堆栈分析,尽管它们破坏了触发增长的贡献。
你觉得这样够好吗?
我也可以理解专门针对 morestack 的分析的价值,但我认为这只是我次要的使用场景。主要的使用场景是分析与 goroutines 数量变化不相符的堆栈内存使用情况的变化。

tpxzln5u

tpxzln5u7#

CPU分析可以像堆内存分析一样显示触发栈增长的原因。但是对于堆分析,inuse_* 变体可以直接回答哪些是保留的。

我想到了三种内存分析:

  1. 分配了内存的分配者
  2. 保留了内存的保留者
  3. 保留了内存的保留者

对于堆内存,我们可以从 alloc_* 视图中获取第一个,从 inuse_* 视图中获取第二个。第三个来源是从完整的堆转储/核心文件构建支配树,或者类似的方式。

对于栈内存,我们可以直接跳到第三种方式(它不是图,所以计算支配者很容易)。专注于 morestack 的CPU分析将提供第一种视图。但在我看来,第二种方式对于你描述的问题最有帮助,与另一种内存分析(堆分析)的概念更接近。

相关问题