ℹ️ В статье используется Go 1.13.

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

Правила

Начнём с примера, поясняющего суть встраивания. Следующая программа, разделённая на два файла, выполняет суммирование и вычитание списка цифр:

main.gofunc main() {
   n := []float32{120.4, -46.7, 32.50, 34.65, -67.45}
   fmt.Printf("The total is %.02f\n", sum(n))
}

func sum(s []float32) float32 {
   var t float32
   for _, v := range s {
      if t < 0 {
         t = add(t, v)
      } else {
         t = sub(t, v)
      }
   }

return t
}op.gofunc add(a, b float32) float32 {
   return a + b
}

func sub(a, b float32) float32 {
   return a - b
}

Запустив её с флагом -gcflags="-m", можно увидеть встроенные функции:

./op.go:3:6: can inline add
./op.go:7:6: can inline sub
./main.go:16:11: inlining call to sub
./main.go:14:11: inlining call to add
./main.go:7:12: inlining call to fmt.Printf

Здесь метод add встроенный. А что же с методом sum? Запустив программу и использовав -m -m в качестве значения во флаге, получим ответ:

./main.go:10:6: cannot inline sum: unhandled op RANGE

Go не осуществляет встраивание методов, использующих range. Более того, некоторые операции блокируют встраивание (вызовы замыкания, select, for, defer) и создание горутины с помощью go

Это не единственное правило. Так при выполнении синтаксического анализа графа АСД (абстрактного синтаксического дерева) Go распределяет бюджет 80 узлов для встраивания. Каждый узел расходует один из таких бюджетов, когда при вызове функций возникают затраты, связанные со встраиванием этих функций. В качестве примера возьмём следующую инструкцию a = a + 1 с пятью узлами: AS, NAME, ADD, NAME, LITERAL. Вот дамп SSA:

Когда затраты на функцию превышают бюджет, встраивание останавливается. А вот пример с функцией побольше add:

./op.go:3:6: cannot inline add: function too complex: cost 104 exceeds budget 80

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

Проблема

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

func add(a, b float32) float32 {
   if b < 0 {
      panic(`Do not add negative number`)
   }

   return a+b
}

Запустив программу, можно увидеть панику в правильной строке, хотя код встроен:

panic: Do not add negative number

goroutine 1 [running]:
main.add(...)
    op.go:5
main.sum(0xc00007cf2c, 0x5, 0x5, 0xc00007cf20)
    main.go:14 +0x80
main.main()
    main.go:7 +0x59
exit status 2

Go сохраняет отображение со встроенными функциями. Он генерирует сначала встраиваемое дерево, которое можно визуализировать, использовав флаг -gcflags="-d pctab=pctoinline". Вот дерево для метода sum, созданного из ассемблерного кода:

Значение -1 представляет родительскую функцию sub. Go отображает встроенные функции в сгенерированном коде. Так же, как и строки. Для визуализации используем флаг -gcflags="-d pctab=pctoline". Вот как выводится метод sum:

Файлы тоже отображаются и с помощью флага -gcflags="-d pctab=pctofile" выводятся на экран:

Теперь у нас есть правильное отображение всех сгенерированных инструкций:

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

Эффект встраивания

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

name                     old time/op    new time/op    delta
BinaryTree17-8              2.34s ± 2%     2.43s ± 3%   +3.77%
Fannkuch11-8                2.21s ± 1%     2.26s ± 1%   +2.01%
FmtFprintfEmpty-8          33.6ns ± 6%    35.2ns ± 3%   +4.85%
FmtFprintfString-8         55.3ns ± 3%    62.8ns ± 1%  +13.48%
FmtFprintfInt-8            63.1ns ± 3%    70.0ns ± 2%  +11.04%
FmtFprintfIntInt-8         95.9ns ± 3%   102.3ns ± 3%   +6.68%
FmtFprintfPrefixedInt-8     105ns ± 4%     111ns ± 1%   +5.83%
FmtFprintfFloat-8           165ns ± 4%     175ns ± 1%   +6.16%
FmtManyArgs-8               405ns ± 2%     427ns ± 0%   +5.38%
GobDecode-8                4.69ms ± 2%    4.78ms ± 4%   +1.77%
GobEncode-8                3.84ms ± 2%    3.93ms ± 3%     ~   
Gzip-8                      210ms ± 3%     208ms ± 1%     ~   
Gunzip-8                   28.1ms ± 7%    29.4ms ± 1%   +4.69%
HTTPClientServer-8         70.0µs ± 2%    70.9µs ± 1%   +1.21%
JSONEncode-8               7.28ms ± 5%    7.00ms ± 2%   -3.91%
JSONDecode-8               33.9ms ± 3%    33.1ms ± 1%   -2.32%
Mandelbrot200-8            3.74ms ± 0%    3.74ms ± 1%     ~

Производительность со встраиванием для этого набора тестов приблизительно на 5-6% выше, чем без встраивания.

Читайте также:


Перевод статьи Vincent Blanchon: Go: Inlining Strategy & Limitation

Предыдущая статьяВычисление π: моделирование методом Монте-Карло
Следующая статьяReact TypeScript: Основы и лучшие практики