在Go语言中,可变参数函数会导致不必要的堆分配。

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

Variadic functions causing unnecessary heap allocations in Go

问题

我目前正在处理一些在Go中对性能敏感的代码。其中有一个非常紧密的内部循环,依次执行以下三个操作:

  1. 获取多个数据的指针。在出现罕见错误的情况下,其中一个或多个指针可能为nil

  2. 检查是否发生了错误,并在发生错误时记录错误。

  3. 使用指针中存储的数据进行处理。

下面是一个具有相同结构的示例程序(尽管指针实际上永远不会为nil):

package main

import (
	"math/rand"
	"fmt"
)

const BigScaryNumber = 1<<25

func DoWork() {
	sum := 0
	for i := 0; i < BigScaryNumber; i++ {
		// 生成指针。
		n1, n2 := rand.Intn(20), rand.Intn(20)
		ptr1, ptr2 := &n1, &n2

		// 检查指针是否为nil。
		if ptr1 == nil || ptr2 == nil {
			fmt.Printf("Pointers %v %v contain a nil.\n", ptr1, ptr2)
			break
		}

		// 使用指针内容进行处理。
		sum += *ptr1 + *ptr2
	}
}

func main() {
	DoWork()
}

当我在我的机器上运行这个程序时,得到以下结果:

$ go build alloc.go && time ./alloc 

real    0m5.466s
user    0m5.458s
sys     0m0.015s

然而,如果我删除打印语句,得到以下结果:

$ go build alloc_no_print.go && time ./alloc_no_print

real    0m4.070s
user    0m4.063s
sys     0m0.008s

由于打印语句实际上从未被调用,我调查了打印语句是否以某种方式导致指针在堆上而不是栈上分配。在原始程序上使用-m标志运行编译器得到以下结果:

$ go build -gcflags=-m alloc.go
# command-line-arguments
./alloc.go:14: moved to heap: n1
./alloc.go:15: &n1 escapes to heap
./alloc.go:14: moved to heap: n2
./alloc.go:15: &n2 escapes to heap
./alloc.go:19: DoWork ... argument does not escape

而在没有打印语句的程序上执行相同操作得到以下结果:

$ go build -gcflags=-m alloc_no_print.go
# command-line-arguments
./alloc_no_print.go:14: DoWork &n1 does not escape
./alloc_no_print.go:14: DoWork &n2 does not escape

这证实即使是未使用的fmt.Printf()也会导致堆分配,这对性能有着非常实际的影响。我可以通过用一个什么都不做且以*int作为参数的可变参数函数替换fmt.Printf()来获得相同的行为:

func VarArgsError(ptrs ...*int) {
    panic("An error has occurred.")
}

我认为这种行为是因为Go在将指针放入切片时会在堆上分配它们(尽管我不确定这是否是逃逸分析例程的实际行为,我不明白它如何能够安全地做其他操作)。

这个问题有两个目的:首先,我想知道我对情况的分析是否正确,因为我并不真正理解Go的逃逸分析是如何工作的。其次,我希望得到关于如何保持原始程序行为而不引起不必要分配的建议。我最好的猜测是在将它们传递给打印语句之前,在指针周围包装一个Copy()函数:

fmt.Printf("Pointers %v %v contain a nil.", Copy(ptr1), Copy(ptr2))

其中Copy()的定义如下:

func Copy(ptr *int) *int {
    if ptr == nil {
        return nil
    } else {
        n := *ptr
        return &n
    }
}

虽然这给我带来了与没有打印语句的情况相同的性能,但它很奇怪,也不是我想要为每种变量类型重写并包装在所有错误日志代码周围的东西。

英文:

I'm currently working on some performance sensitive code in Go. At one point I have a particularly tight inner loop which does three things in succession:

  1. Obtain several pointers to data. In the event of a rare error, one or more of these pointers might be nil.

  2. Check whether this error has occurred, and log an error if it has.

  3. Do work with the data stored in the pointers.

Shown below is a toy program with the same structure (although the pointers can never actually be nil).

package main

import (
	&quot;math/rand&quot;
	&quot;fmt&quot;
)

const BigScaryNumber = 1&lt;&lt;25

func DoWork() {
	sum := 0
	for i := 0; i &lt; BigScaryNumber; i++ {
		// Generate pointers.
		n1, n2 := rand.Intn(20), rand.Intn(20)
		ptr1, ptr2 := &amp;n1, &amp;n2

		// Check if pointers are nil.
		if ptr1 == nil || ptr2 == nil {
			fmt.Printf(&quot;Pointers %v %v contain a nil.\n&quot;, ptr1, ptr2)
			break
		}

		// Do work with pointer contents.
		sum += *ptr1 + *ptr2
	}
}

func main() {
	DoWork()
}

When I run this on my machine, I get the following:

$ go build alloc.go &amp;&amp; time ./alloc 

real    0m5.466s
user  	0m5.458s
sys	    0m0.015s

However, if I remove the print statement, I get the following:

$ go build alloc_no_print.go &amp;&amp; time ./alloc_no_print

real    0m4.070s
user    0m4.063s
sys     0m0.008s

Since the print statement is never actually called, I investigated whether the print statement was somehow causing the pointers to be allocated on the heap instead of the stack. Running the compiler with the -m flag on the original program gives:

$ go build -gcflags=-m alloc.go
# command-line-arguments
./alloc.go:14: moved to heap: n1
./alloc.go:15: &amp;n1 escapes to heap
./alloc.go:14: moved to heap: n2
./alloc.go:15: &amp;n2 escapes to heap
./alloc.go:19: DoWork ... argument does not escape

while doing this on a print statement-less program gives

$ go build -gcflags=-m alloc_no_print.go
# command-line-arguments
./alloc_no_print.go:14: DoWork &amp;n1 does not escape
./alloc_no_print.go:14: DoWork &amp;n2 does not escape

confirming that even an unused fmt.Printf() is causing heap allocations which have a very real effect on performance. I can get the same behavior by replacing fmt.Printf() with a variadic function which does nothing and takes *ints as parameters instead of interface{}s:

func VarArgsError(ptrs ...*int) {
    panic(&quot;An error has occurred.&quot;)
}

I think this behavior is because Go allocates pointers on the heap whenever they are placed in a slice (although I'm not sure that this is the actual behavior of the escape analysis routines, I don't see how it would safely be able to do otherwise).

There are two purposes to this question: first, I want to know if my analysis of the situation is correct, since I don't really understand how Go's escape analysis works. And second, I wanted suggestions for maintaining the behavior of the original program without causing unneeded allocations. My best guess is to wrap a Copy() function around the pointers prior to passing them into the print statement:

fmt.Printf(&quot;Pointers %v %v contain a nil.&quot;, Copy(ptr1), Copy(ptr2))

where Copy() is defined as

func Copy(ptr *int) *int {
    if ptr == nil {
        return nil
    } else {
        n := *ptr
        return &amp;n
    }
}

While this gives me the same performance as the no print statement case, it's weird and not the sort of thing I want to rewrite for every variable type and then wrap around all of my error logging code.

答案1

得分: 1

Go FAQ中可以得知:

> 在当前的编译器中,如果一个变量的地址被取出,那么这个变量就有可能被分配到堆上。然而,基本的逃逸分析会识别出一些情况,即这些变量在函数返回后不会再被使用,因此可以放在栈上。

当指针被传递给一个函数时,我认为它不符合逃逸分析的第二部分。例如,该函数可能会将指针赋值给其所在包中的全局变量,而这个全局变量的生命周期比当前的栈要长。我不认为当前的编译器会进行如此深入的逃逸分析。

避免分配的一种方法是将分配移到循环外,并在循环内重新分配内存的值。

func DoWork() {
    sum := 0
    n1, n2 := new(int), new(int)

    for i := 0; i < BigScaryNumber; i++ {
        *n1, *n2 = rand.Intn(20), rand.Intn(20)
        ptr1, ptr2 := n1, n2

        // 检查指针是否为nil。
        if ptr1 == nil || ptr2 == nil {
            fmt.Printf("指针 %v %v 包含nil。\n", n1, n2)
            break
        }

        // 使用指针内容进行操作。
        sum += *ptr1 + *ptr2
    }
}
英文:

From Go FAQ,

> In the current compilers, if a variable has its address taken, that
> variable is a candidate for allocation on the heap. However, a basic
> escape analysis recognizes some cases when such variables will not
> live past the return from the function and can reside on the stack.

When the pointers are passed to a function, I think it fails the second part of escape analysis. For example, the function may assign the pointer to a global variable in its package which lives longer than the current stack. I don't think the current compiler does such deep escape analysis.

One way to avoid the cost of allocation would be to move the allocation outside the loop and reassign the value to allocated memory inside the loop.

func DoWork() {
	sum := 0
	n1, n2 := new(int), new(int)

	for i := 0; i &lt; BigScaryNumber; i++ {
		*n1, *n2 = rand.Intn(20), rand.Intn(20)
		ptr1, ptr2 := n1, n2

		// Check if pointers are nil.
		if ptr1 == nil || ptr2 == nil {
			fmt.Printf(&quot;Pointers %v %v contain a nil.\n&quot;, n1, n2)
			break
		}

		// Do work with pointer contents.
		sum += *ptr1 + *ptr2
	}
}

huangapple
  • 本文由 发表于 2015年1月6日 06:14:09
  • 转载请务必保留本文链接:https://go.coder-hub.com/27788813.html
匿名

发表评论

匿名网友

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

确定