Изучите производительность интерфейсов в Go

Go

вопрос

Использование интерфейсов (interface{}) в Go, похоже, имеет проблемы с производительностью, но так ли это на самом деле, или у нас есть возможности для улучшения, давайте взглянем на один изissue. В примере запускаются три бенчмарка, один — вызов интерфейса, другой — прямой вызов, а позже я добавил утверждение интерфейса и вызвал его.

import (
    "testing"
)

type D interface {
    Append(D)
}

type Strings []string

func (s Strings) Append(d D) {}

func BenchmarkInterface(b *testing.B) {
    s := D(Strings{})
    for i := 0 ; i < b.N ; i += 1 {
        s.Append(Strings{""})
    }
}

func BenchmarkConcrete(b *testing.B) {
    s := Strings{} // only difference is that I'm not casting it to the generic interface
    for i := 0 ; i < b.N ; i += 1 {
        s.Append(Strings{""})
    }
}

func BenchmarkInterfaceTypeAssert(b *testing.B) {
    s := D(Strings{})
    for i := 0 ; i < b.N ; i += 1 {
        s.(Strings).Append(Strings{""})
    }
}

Я использую версиюgo version 1.13, результат выполнения следующий,

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

встроенный встроенный

что встроено

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

  1. Устраняет накладные расходы на сам вызов функции
  2. Позволяет компилятору более эффективно применять другие стратегии оптимизации (такие как свертывание констант, устранение общих подвыражений, перемещение кода, не зависящее от циклов, и лучшее распределение регистров).

Можно использовать пример, чтобы наглядно увидеть роль встраивания

package main

import "testing"

//go:noinline
func max(a, b int) int {
    if a > b {
        return a
    }
    return b
}

var Result int

func BenchmarkMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        r = max(-1, i)
    }
    Result = r
}

сделай это

go test -bench=. -benchmem -run=none

можно увидеть результат

тогда мы разрешаемmaxвстраивание функций, то есть//go:noinlineУдалите эту строку кода и выполните ее снова. можно увидеть

Сравнивая до и после использования inline, мы видим, что производительность значительно улучшилась, начиная с2.31 ns/op -> 0.519 ns/op.

что делает встроенный

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

из-заr = max(-1, i),iначинается с 0, поэтомуi > -1,Такmaxфункциональныйa > bВетвление никогда не происходит. Компилятор может встроить эту часть кода непосредственно в вызывающую программу, и оптимизированный код выглядит следующим образом.

func BenchmarkMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        if -1 > i {
            r = -1
        } else {
            r = i
        }
    }
    Result = r
}

Замените его приведенным выше кодом, и вы увидите, что производительность аналогична при его выполнении.

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

Встроенные ограничения

Не всякая функция может быть встроена, вы можете увидеть следующее предложение из вики golang

Function Inlining

Only short and simple functions are inlined. To be inlined a function must contain less than ~40 expressions and does not contain complex things like loops, labels, closures, panic's, recover's, select's, switch'es, etc.

  • gc: 1.0+
  • gccgo: -O1 and above.

То есть встраивать можно только короткие и простые функции. Чтобы быть встроенными, функции должны содержать менее ~ 40 выражений и не содержать сложных операторов, таких какloop, label, closure, panic, recover, select, switchЖдать.

Конечно, есть подсказки, напримерforвы можете видеть из подсказки, что встраивание не поддерживается.

Встроенный в середине стека в середине стека

Начиная с Go 1.8, компилятор по умолчанию не встраивает функции в середине стека (т. е. вызовы других функций, которые нельзя встроить). встраивание в середине стека было введено в GO1.9 Дэвидом Лазаром.proposal, после нагрузочного тестирования показывает, что такого рода инлайны в стеке могут улучшить производительность на 9%, а побочным эффектом является то, что размер скомпилированного бинарника увеличится на 15%. Продолжайте смотреть пример

package main

import (
    "fmt"
    "strconv"
)

type Rectangle struct {}

//go:noinline
func (r *Rectangle) Height() int {
    h, _ := strconv.ParseInt("7", 10, 0)
    return int(h)
}

func (r *Rectangle) Width() int {
    return 6
}

func (r *Rectangle) Area() int { return r.Height() * r.Width() }

func main() {
    var r Rectangle
    fmt.Println(r.Area())
}

В этом примереr.Area()называетсяr.Width()а такжеr.Height(), первое может быть встроено, второе из-за добавления//go:noinlineНе может быть встроен. Давайте выполним следующую команду, чтобы увидеть встроенную ситуацию.

go build -gcflags='-m=2' square.go  

Как вы можете видеть в строках 3 и 4 вывода,widthа такжеAreaФункции могут быть встроенными, а красный прямоугольник — это встроенный оператор.

В строке 6 выводится следующее, указывающее, что оно не удовлетворяет условиям для встраивания, и имеетсяbudgetОграничения, на которые может ссылаться эта частьПолитики и ограничения встроенного встроенного в язык Go.

./square.go:22:6: cannot inline main: function too complex: cost 150 exceeds budget 80

потому что со звонкомr.Area()по сравнению со стоимостьюr.Area()Выполняемое умножение относительно простое, поэтому встроенноеr.Area()одно выражение, даже если оно вызываетr.Height()Внутренние условия не соблюдены.

встраивание быстрого пути

из-заmid-stackОптимизация позволяет встраивать другие функции, которые не могут быть встроены.Вставка быстрого пути принимает эту идею, то есть сложная часть сложной функции делится на функции ветвления, так что быстрый путь может быть встроен . . . пример изgolang code-review, автор использует средства быстрого встраивания пути кRUnlockМожет быть встроен, что приводит к повышению производительности.

Поскольку старый код слева содержит множество условий, в результатеRUnlockФункции не могут быть встроенными. Здесь автор разбивает логику со сложными условиями на функцию, которая называется функцией медленного пути. Здесь мы можем попробовать пример, это бессмысленный пример, просто чтобы доказать существование встроенной оптимизации.

package main

import (
    "sync"
)

var rw sync.RWMutex

func test(num int) int {
    rw.RLock()
    num += 1
    rw.RUnlock()
    return num
}

Используя вывод версии go 1.9,

Используя вывод версии go 1.13,

Из приведенного выше вывода мы видим, что после быстрой оптимизации пути вступает в силу встраивание.По данным стресс-теста автора, производительность снижается на 9%.Конечно, мы можем попробовать это сами.После многих тестов мы можем имеют18 ns/op -> 15 ns/opулучшение.

// go version 1.9
BenchmarkRlock-4        100000000               18.9 ns/op             0 B/op          0 allocs/op

// go version 1.13
BenchmarkRlock-4        76204650                15.3 ns/op             0 B/op          0 allocs/op

побег-анализ

что такое побег из памяти

Прежде всего, мы знаем, что память делится на динамическую память (heap) и стековую память (stack). Для динамической памяти ее необходимо очистить. Например, malloc в языке C используется для выделения памяти кучи.После подачи заявки на память кучи ее нужно освобождать вручную, иначе это вызовет утечки памяти. Но в языке Go есть GC, так что нет необходимости вручную выпускать. Так что для этого использование кучи дороже стека и давит на сборщик мусора, потому что значения в куче, на которые не ссылаются указатели, нужно удалять. Чем больше значений проверяется и удаляется, тем больше работы каждый раз выполняет сборщик мусора.

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

Короче говоря, escape-анализ определяет, выделяется ли память в стеке или в куче.

Как отслеживать утечку памяти

Вы можете узнать, произошла ли утечка памяти, просмотрев отчет компилятора. использоватьgo build -gcflags='-m=2'Вот и все. Всего 4 уровня-m, но более 2-mУровень возвращаемой информации больше. Обычно используются 2-mуровень.

Вызов метода типа интерфейса

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

package main

type S struct {
    s1 int
}

func (s *S) M1(i int) { s.s1 = i }

type I interface {
    M1(int)
}

func g() {
    var s1 S  // 逃逸
    var s2 S  // 不逃逸
    var s3 S  // 不逃逸

    f1(&s1)
    f2(&s2)
    f3(&s3)
}

func f1(s I) { s.M1(42) }
func f2(s *S) { s.M1(42) }
func f3(s I) { s.(*S).M1(42) }

Взгляните на отчет компилятора,

  1. Используйте вызовы интерфейсных методов напрямую, а не встроенные. Мы можем заглянуть в первое красное поле и вызвать его через интерфейсI.M1(42)Не может быть встроенным, в то время как утверждения и вызовы конкретных типов могут оставаться встроенными.
  2. Если метод интерфейса вызывается напрямую, произойдет утечка памяти. Однако, когда определенный тип вызывается или вызывается после утверждения, выхода из памяти не происходит. Это также подтверждает стресс-тест в начале статьи.Вызовы интерфейса имеют выделения памяти, и эти выделения памяти представляют собой память, которая сбежала в кучу.

рассмотрение

Давайте посмотрим на пример в начале статьи,

package main

type D interface {
    Append(D)
}

type Strings []string

func (s Strings) Append(d D) {}

func concreteTest() {
    s := Strings{} // only difference is that I'm not casting it to the generic interface
    for i := 0 ; i < 10 ; i += 1 {
        s.Append(Strings{""})
    }
}

func interfaceTest() {
    s := D(Strings{})
    for i := 0 ; i < 10 ; i += 1 {
        s.Append(Strings{""})
    }
}

func assertTest() {
    s := D(Strings{})
    for i := 0 ; i < 10 ; i += 1 {
        s.(Strings).Append(Strings{""})
    }
}

воплощать в жизнь

go build -gcflags='-m=2' iterface.go

Вы можете увидеть результат,

То есть интерфейс вызывается напрямую, без встраивания, и происходит побег из памяти. Когда мы вызываем метод после передачи утверждения, происходит встраивание и нет выхода из памяти. Поэтому производительность прямых вызовов интерфейса проблематична.

Суммировать

Благодаря приведенному выше анализу мы должны обратить внимание при использовании интерфейса.Лучше всего утвердить интерфейс перед его использованием, что повысит производительность. В то же время при ежедневной разработке можно проводить дополнительный анализ, чтобы избежать потребления памяти и давления на сборщик мусора, вызванного утечкой памяти, и повысить производительность.

Ссылаться на

go issue 20116

перейти к настройке производительности

Перейти на вики по оптимизации компиляции

inlining opt by dave

mid-stack inline proposal

golang mid-stack issue

голанг побег из памяти

golang: Escape analysis and interfaces Политики и ограничения встроенного встроенного в язык Go