准确测量堆增长

huangapple go评论80阅读模式
英文:

Measure heap growth accurately

问题

我正在尝试测量在调用函数之前和之后堆分配对象数量的演变。我使用runtime.GC()强制进行垃圾回收,并使用runtime.ReadMemStats来测量调用前后的堆对象数量。

我的问题是,有时候我会看到意外的堆增长,而且每次运行结果都不同。

下面是一个简单的示例,我总是期望看到堆对象数量增长为零。

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

我所尝试的做法是否不切实际?我假设运行时会执行一些分配/释放堆内存的操作。我能否告诉它停止执行以进行测量?(这是为了检查内存泄漏而进行的测试,而不是用于生产代码)

英文:

I am trying to measure the evolution of the number of heap-allocated objects before and after I call a function. I am forcing runtime.GC() and using runtime.ReadMemStats to measure the number of heap objects I have before and after.

The problem I have is that I sometimes see unexpected heap growth. And it is different after each run.

A simple example below, where I would always expect to see a zero heap-objects growth.

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))
}

Sample output:

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

Is what I'm trying to do unpractical? I assume the runtime is doing things that allocate/free heap-memory. Can I tell it to stop to make my measurements? (this is for a test checking for memory leaks, not production code)

答案1

得分: 2

你无法预测垃圾回收和读取所有内存统计数据所需的后台操作。调用这些操作来计算内存分配和使用情况并不是一种可靠的方法。

幸运的是,Go的测试框架可以监控和计算内存使用情况。

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

假设我们想要测量这个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字节

请注意,每个操作报告的分配次数是一个整数值(它是整数除法的结果),因此如果被基准测试的函数只偶尔分配内存,可能不会在整数结果中报告。有关详细信息,请参阅这里

就像这个例子中一样:

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次)。

英文:

You can't predict what garbage collection and reading all the memory stats require in the background. Calling those to calculate memory allocations and usage is not a reliable way.

Luckily for us, Go's testing framework can monitor and calculate memory usage.

So what you should do is write a benchmark function and let the testing framework do its job to report memory allocations and usage.

Let's assume we want to measure this foo() function:

var x []int64

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

All it does is allocate a slice of the given size, and it does this with the given number of times (allocs).

Let's write benchmarking functions for different scenarios:

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

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

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

Running the benchmark with go test -bench . -benchmem, the output is:

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

As you can see, the allocations per function call is the same what we pass as the allocs argument. The allocated memory is the expected allocs * size * 8 bytes.

Note that the reported allocations per op is an integer value (it's the result of an integer division), so if the benchmarked function only occasionally allocates, it might not be reported in the integer result. For details, see https://stackoverflow.com/questions/56255211/output-from-benchmem/56256892#56256892.

Like in this example:

var x []int64

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

This bar() function does 1 allocation with 30% probability (and none with 70% probability), which means on average it does 0.3 allocations. Benchmarking it:

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

Output is:

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

We can see there is 24 bytes allocation (0.3 * 10 * 8 bytes), which is correct, but the reported allocations per op is 0.

Luckily for us, we can also benchmark a function from our main app using the testing.Benchmark() function. It returns a testing.BenchmarkResult including all details about memory usage. We have access to the total number of allocations and to the number of iterations, so we can calculate allocations per op using floating point numbers:

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

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

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

This will output:

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

We can see the expected ~0.3 allocations per op.

Now if we go ahead and benchmark your measure_nothing() function:

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

We get this output:

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

As you can see, running the garbage collector twice and reading memory stats twice occasionally needs allocation (~1 out of 10 calls: 0.12 times on average).

huangapple
  • 本文由 发表于 2021年12月9日 14:05:24
  • 转载请务必保留本文链接:https://go.coder-hub.com/70285380.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定