Инлайн-функции: как компилятор экономит на вызовах функций
Мы привыкли разбивать код на небольшие функции. Это улучшает читаемость, упрощает тестирование и повторное использование логики. Но каждый вызов функции потенциально имеет свою цену. Здесь на помощь приходит инлайнинг (inlining): компилятор встраивает тело функции непосредственно в место её вызова. Как это работает? Представим простую функцию: func add(a, b int) int { return a + b }
func main() { result := add(10, 20) } При обычном вызове программе необходимо передать аргументы, организовать выполнение функции и вернуть результат. Компилятор может преобразовать этот участок кода так, чтобы вместо вызова выполнялась непосредственно логика функции: result := 10 + 20 Это упрощённая иллюстрация, а не буквальное описание машинного кода. Суть в том, что отдельный вызов может исчезнуть, а компилятор получит дополнительные возможности для оптимизации окружающего кода. Например, он может убрать лишние операции или вычислить константное выражение заранее. Почему не встроить все функции? Потому что у оптимизации есть обратная сторона. Если встроить большую функцию в десятки мест, размер машинного кода увеличится. Это может ухудшить использование процессорного кэша инструкций и даже снизить производительность. Поэтому компилятор ищет баланс между сокращением накладных расходов и размером кода. Он учитывает сложность функции, её размер и другие факторы. Важно: маленькая функция не обязательно будет встроена, а большая не всегда останется отдельной. Проверяем на Go Например, есть функция: func add(a, b int) int { return a + b } Посмотреть решения компилятора можно командой: go build -gcflags="-m" . В выводе ищите сообщения can inline и inlining call to. Но как сравнить производительность? Напишем бенчмарк, запрещающий инлайнинг для одного из вариантов. package main
import "testing"
func add(a, b int) int { return a + b }
//go:noinline func addNoInline(a, b int) int { return a + b }
var result int
func BenchmarkInline(b *testing.B) { for i := 0; i < b.N; i++ { result = add(i, 10) } }
func BenchmarkNoInline(b *testing.B) { for i := 0; i < b.N; i++ { result = addNoInline(i, 10) } } Запускаем: go test -bench=. -benchmem Пример возможного вывода: BenchmarkInline-20 1000000000 0.30 ns/op 0 B/op 0 allocs/op BenchmarkNoInline-20 500000000 2.40 ns/op 0 B/op 0 allocs/op Это иллюстративные цифры, а не реальные результаты запуска. Фактические значения зависят от процессора, версии Go и окружения. В таком тесте разница может быть заметной, поскольку функция предельно простая, а затраты на вызов составляют существенную часть всей работы. На реальных задачах результат может быть совершенно другим. Нужно ли оптимизировать код специально? Мой подход: сначала писать понятный код, затем измерять производительность и только после этого оптимизировать узкие места. Не стоит отказываться от небольших функций ради гипотетической экономии. Компилятор Go умеет самостоятельно встраивать многие из них. Инлайнинг также показывает, что исходный код и машинный код не обязаны иметь одинаковую структуру. Мы пишем понятные функции, а компилятор решает, где выгоднее убрать границу между ними. Хороший компилятор оптимизирует выполнение кода, а разработчик должен прежде всего оптимизировать его понимание. А вы проверяете решения компилятора или обычно доверяете ему и смотрите только на результаты бенчмарков?