Функции Variadic, вызывающие ненужные выделения кучи в Go

В настоящее время я работаю над чувствительным к производительности кодом в Go. В какой-то момент у меня особенно тесная внутренняя петля, которая делает три вещи подряд:

  1. Получить несколько указателей на данные. В случае редкой ошибки, один или несколько из этих указателей могут быть nil,

  2. Проверьте, не произошла ли эта ошибка, и запишите ошибку, если она произошла.

  3. Работайте с данными, хранящимися в указателях.

Ниже показана игрушечная программа с той же структурой (хотя указатели никогда не могут быть нулевыми).

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

Когда я запускаю это на своей машине, я получаю следующее:

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

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

Однако, если я удаляю оператор print, я получаю следующее:

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

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

Поскольку оператор print на самом деле никогда не вызывается, я исследовал, не вызывал ли оператор print какие-либо указатели в куче, а не в стеке. Запуск компилятора с -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() вызывает выделения кучи, которые очень сильно влияют на производительность. Я могу получить то же поведение, заменив fmt.Printf() с переменной функцией, которая ничего не делает и принимает *ints как параметры вместо interface{}s:

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

Я думаю, что такое поведение связано с тем, что Go выделяет указатели в куче всякий раз, когда они помещаются в срез (хотя я не уверен, что это реальное поведение подпрограмм escape-анализа, я не понимаю, как он мог бы безопасно делай иначе).

Этот вопрос преследует две цели: во-первых, я хочу знать, верен ли мой анализ ситуации, поскольку я не совсем понимаю, как работает анализ побега Го. И во-вторых, я хотел предложения по поддержанию поведения исходной программы, не вызывая ненужных выделений. Мое лучшее предположение, чтобы обернуть Copy() Обрабатывайте указатели перед передачей их в оператор print:

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

Хотя это дает мне ту же производительность, что и в случае с оператором no print, это странно, и не то, что я хочу переписать для каждого типа переменной, а затем обернуть весь мой код регистрации ошибок.

1 ответ

От Go FAQ,

В текущих компиляторах, если переменная имеет свой адрес, эта переменная является кандидатом для размещения в куче. Однако базовый анализ escape распознает некоторые случаи, когда такие переменные не будут жить после возврата из функции и могут находиться в стеке.

Когда указатели передаются в функцию, я думаю, что она не проходит вторую часть анализа выхода. Например, функция может назначить указатель на глобальную переменную в своем пакете, которая живет дольше, чем текущий стек. Я не думаю, что текущий компилятор делает такой глубокий анализ побега.

Один из способов избежать затрат на выделение - переместить выделение за пределы цикла и переназначить значение выделенной памяти внутри цикла.

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
    }
}
Другие вопросы по тегам