【问题标题】:Measure heap growth accurately准确测量堆增长
【发布时间】:2022-01-14 00:24:06
【问题描述】:

我正在尝试测量在调用函数之前和之后堆分配对象数量的演变。我正在强制 runtime.GC() 并使用 runtime.ReadMemStats 来测量我之前和之后拥有的堆对象的数量。

我遇到的问题是我有时会看到意外的堆增长。而且每次跑完都不一样。

下面是一个简单的例子,我总是希望看到零堆对象增长。

https://go.dev/play/p/FBWfXQHClaG

var mem1_before, mem2_before, mem1_after, mem2_after runtime.MemStats

func measure_nothing(before, after *runtime.MemStats) {
    runtime.GC()
    runtime.ReadMemStats(before)

    runtime.GC()
    runtime.ReadMemStats(after)
}

func main() {
    measure_nothing(&mem1_before, &mem1_after)
    measure_nothing(&mem2_before, &mem2_after)

    log.Printf("HeapObjects diff = %d", int64(mem1_after.HeapObjects-mem1_before.HeapObjects))
    log.Printf("HeapAlloc diff %d", int64(mem1_after.HeapAlloc-mem1_before.HeapAlloc))

    log.Printf("HeapObjects diff = %d", int64(mem2_after.HeapObjects-mem2_before.HeapObjects))
    log.Printf("HeapAlloc diff %d", int64(mem2_after.HeapAlloc-mem2_before.HeapAlloc))
}

样本输出:

2009/11/10 23:00:00 HeapObjects diff = 0
2009/11/10 23:00:00 HeapAlloc diff 0
2009/11/10 23:00:00 HeapObjects diff = 4
2009/11/10 23:00:00 HeapAlloc diff 1864

我正在尝试做的事情不切实际吗?我假设运行时正在做分配/释放堆内存的事情。我可以告诉它停止测量吗? (这是用于检查内存泄漏的测试,而不是生产代码)

【问题讨论】:

  • 你为什么不使用测试框架来衡量这个?基准测试报告分配/操作以及字节/操作,将它们相乘以获得堆差异。

标签: go heap-memory


【解决方案1】:

您无法预测后台垃圾收集和读取所有内存统计信息所需的内容。调用它们来计算内存分配和使用情况不是一种可靠的方法。

幸运的是,Go 的 testing framework 可以监控和计算内存使用情况。

所以你应该做的是编写一个基准函数,让测试框架完成它的工作来报告内存分配和使用情况。

假设我们要测量这个foo() 函数:

var x []int64

func foo(allocs, size int) {
    for i := 0; i < allocs; i++ {
        x = make([]int64, size)
    }
}

它所做的只是分配给定size 的一部分,并以给定的次数 (allocs) 执行此操作。

让我们为不同的场景编写基准测试函数:

func BenchmarkFoo_0_0(b *testing.B) {
    for i := 0; i < b.N; i++ {
        foo(0, 0)
    }
}

func BenchmarkFoo_1_1(b *testing.B) {
    for i := 0; i < b.N; i++ {
        foo(1, 1)
    }
}

func BenchmarkFoo_2_2(b *testing.B) {
    for i := 0; i < b.N; i++ {
        foo(2, 2)
    }
}

使用go test -bench . -benchmem 运行基准测试,输出为:

BenchmarkFoo_0_0-8   1000000000      0.3204 ns/op    0 B/op    0 allocs/op
BenchmarkFoo_1_1-8   67101626       16.58 ns/op      8 B/op    1 allocs/op
BenchmarkFoo_2_2-8   27375050       42.42 ns/op     32 B/op    2 allocs/op

如您所见,每个函数调用的分配与我们作为allocs 参数传递的相同。分配的内存是预期的allocs * size * 8 bytes

请注意,每个操作报告的分配是一个整数值(它是整数除法的结果),因此如果基准函数只是偶尔分配,它可能不会在整数结果中报告。详情请见Output from benchmem

就像这个例子:

var x []int64

func bar() {
    if rand.Float64() < 0.3 {
        x = make([]int64, 10)
    }
}

这个bar() 函数以 30% 的概率 进行 1 次分配(而没有 70% 的概率),这意味着 平均进行 0.3 次分配。对其进行基准测试:

func BenchmarkBar(b *testing.B) {
    for i := 0; i < b.N; i++ {
        bar()
    }
}

输出是:

BenchmarkBar-8   38514928       29.60 ns/op    24 B/op     0 allocs/op

我们可以看到有 24 字节分配(0.3 * 10 * 8 字节),这是正确的,但报告的每个操作的分配是 0。

幸运的是,我们还可以使用 testing.Benchmark() 函数对主应用程序中的函数进行基准测试。它返回一个testing.BenchmarkResult,包括有关内存使用的所有详细信息。我们可以访问分配总数和迭代次数,因此我们可以使用浮点数计算每个操作的分配:

func main() {
    rand.Seed(time.Now().UnixNano())

    tr := testing.Benchmark(BenchmarkBar)
    fmt.Println("Allocs/op", tr.AllocsPerOp())
    fmt.Println("B/op", tr.AllocedBytesPerOp())

    fmt.Println("Precise allocs/op:", float64(tr.MemAllocs)/float64(tr.N))
}

这将输出:

Allocs/op 0
B/op 24
Precise allocs/op: 0.3000516369276302

我们可以看到每个操作的预期分配 ~0.3。

现在,如果我们继续对您的 measure_nothing() 函数进行基准测试:

func BenchmarkNothing(b *testing.B) {
    for i := 0; i < b.N; i++ {
        measure_nothing(&mem1_before, &mem1_after)
    }
}

我们得到这个输出:

Allocs/op 0
B/op 11
Precise allocs/op: 0.12182030338389732

如您所见,运行垃圾收集器两次并读取内存统计信息两次有时需要分配(大约 10 次调用中的 1 次:平均 0.12 次)。

【讨论】:

  • 感谢您的推荐。不幸的是,它并没有真正帮助我。 allocs/op 是四舍五入的分配数。所以它“隐藏”了我想要追踪的虚假堆分配。我尝试玩 testing.AllocsPerRun,它有同样的问题(即使它返回一个浮点数,它也是四舍五入的!)所以我仍然不知道分配来自哪里。试过 pprof(省略小对象),alloctrace=1(不知道如何处理 500M 的堆栈跟踪),但无法跟踪它。
  • 也许我不应该在一次运行中测量这种东西。如果标准的 go 代码在数千次运行中进行此类测量,那可能是有充分理由的。
  • @rtur 我编辑了答案以展示如何捕获虚假分配。
  • 很好,这很有帮助。谢谢。
猜你喜欢
  • 2011-03-23
  • 2021-12-08
  • 2017-03-11
  • 2012-01-03
  • 2011-04-04
  • 2012-10-08
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多