Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Variadic functions causing unnecessary heap allocations in Go

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 (
    "math/rand"
    "fmt"
)

const BigScaryNumber = 1<<25

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

        // Check if pointers are nil.
        if ptr1 == nil || ptr2 == nil {
            fmt.Printf("Pointers %v %v contain a nil.\n", 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 && 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 && 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: &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

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 &n1 does not escape
./alloc_no_print.go:14: DoWork &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("An error has occurred.")
}

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("Pointers %v %v contain a nil.", Copy(ptr1), Copy(ptr2))

where Copy() is defined as

func Copy(ptr *int) *int {
    if ptr == nil {
        return nil
    } else {
        n := *ptr
        return &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.

like image 702
mansfield Avatar asked Jan 05 '15 22:01

mansfield


1 Answers

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 < 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("Pointers %v %v contain a nil.\n", n1, n2)
            break
        }

        // Do work with pointer contents.
        sum += *ptr1 + *ptr2
    }
}
like image 70
Chandra Sekar Avatar answered Sep 19 '22 12:09

Chandra Sekar