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

Что такое инлайнинг функций? 

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

Допустим, есть код, который мы хотим оптимизировать: 

func foo(a, b int) int {
  ret := 0
  for i := 0; i < 10; i++ {
    ret += a
  }
  for j := 0; j < 5; j++ {
    ret += b
  }
  return ret
}

func bar() int {
  s := foo(4, 5)
  return s
}

func foobar() int {
  return bar()
}

Функции foo и bar вызывают друг друга, и это можно оптимизировать. После того как компилятор применяет оптимизацию, код может выглядеть так:

func foobar() int{
  ret := 0;
  for i := 0; i < 10; i++ {
    ret += 4
  }
  for j := 0; j < 5; j++ {
    ret += 5
  }
  return ret;
}

Теперь мы можем считать (с некоторой долей упрощения), что функций foo и bar нет в коде. Их инструкции и код физически скопированы в то место, где функции вызывались.

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

Мы, пока очень упрощённо, избавились от foo и bar: весь код вставили в месте вызова. Это и есть инлайнинг в общем виде.

Стратегии, главный плюс и пара минусов встраивания

Бывает две стратегии инлайнинга:

  • на этапе компиляции — применяется в C, C++, Fortran, Rust, Zig, Go и других языках, которые используют LLVM-стек;

  • в runtime — для платформ или движков JVM, V8, а теперь и Ruby с его JIT-компиляцией. Чтобы для них произвести инлайниг, сначала происходит JIT-компиляция, а вместо байт-кодов уже вставляется машинный код. Это тоже можно рассматривать как встраивание. 

Да, в языках с JIT-компиляцией тоже есть инлайнинг, хотя и возможен только после JIT-а, конечно. Горячие участки кода будут пытаться встроиться прямо в место вызова.

Инлайнинг — это оптимизация. И как у любой оптимизации, у него есть обратная сторона. Посмотрим, какие плюсы и минусы есть у встраивания функций.

Главный плюс

  • Ускорение работы программы

На это и рассчитана оптимизация: чтобы программа работала быстрее. Но что это значит — быстрее?

Покажу, насколько значительной может быть разница между заинлайненным кодом и нет. Возьмём достаточно простой и синтетический, но зато максимально наглядный пример.

// go:noinline
func foo (a, b int) int {
  ret := 0
  for i := 0; i < 10; i++ {
    ret += a
    ret *= b
  }
  return ret
}   

func bar(a, b int) int {
  ret := 0
  for i := 0; i < 10; i++ {
    ret += a
    ret *= b
  }
  return ret
}   

Есть две одинаковые функции foo и bar, но только одна из них помечена директивой go:noinline. Она говорит о том, что эта функция встроена не будет ни при каких обстоятельствах. 

Локальный бенчмарк для этого примера показал, что код с инлайнингом отработал в два раза быстрее, чем без.

goos: darwin
goarch: amb64
pkg: x
cpu: Intel(R) Core(TM) i5-1038NG7 CPU @ 2.00GHz

BenchmarkWithoutInlinig-8   215878278 55.426 ns/op
BenchmarkWithInlinig-8       551489780 2.274 ns/op

Уточним: пример синтетический, не всегда инлайнинг даёт ускорение в два раза.

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

А что значит «только её вызов»? Это несколько шагов — ведь чтобы вызвать функцию, нужно:

  • выделить ей место на стеке;

  • скопировать параметры;

  • записать регистры;

  • скопировать ответ;

  • почистить за собой стек и регистры процессора.

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

Пара минусов

  • Увеличение размера исполняемого файла

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

Возможно, для энтерпрайз-решений это не очень актуально. Подумаешь, бинарник будет весить на несколько килобайт больше. Его завернут в docker-контейнер, отправят в Kubernetes, в облако. 

Но вот во встраиваемых системах, IoT и мобильных приложениях размер исполняемого файла критически значим для системы. Если код должен исполняться на клиенте, то лишний вес файла может свести на нет профит от инлайнинга.

  • Замедление программы

«Так, стоп», — скажете вы. Только что обсуждали, что встраивание ускоряет выполнение программы, и проверяли выигрыш на тесте. Откуда взялось замедление?

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

Программы для компьютера — это набор инструкций (и данных), которые процессор должен исполнить. Программа запускается, загружается в оперативную память. Процессор постепенно идёт по инструкциям и выполняет их.

Только оперативная память медленная относительно скорости работы CPU.

L1 cache reference 			    0.5 ns
Branch mispredict				5   ns
L2 cache reference 			    7   ns
Mutex lock/unlock 				25  ns
Main memory reference 			100 ns

В таблице Latency numbers every programmer should know видим, что доступ к памяти занимает 100 ns. И при этом понимаем, что есть ближняя и дальняя память, так что до второй путь может быть ещё дольше. Поэтому и придумали всякие кеши (L1, L2, L3, даже L0), куда инструкции подгружаются пачками, чтобы быстрый CPU не ходил за ними в память.

Вернёмся к инлайнингу. Есть случаи, когда функция после инлайнинга раздувается так, что не помещается за раз в кеш CPU. Ведь исполняются не только наша функция и программа, есть и другие, которым тоже нужны ресурсы. Так что CPU приходится ходить в память — ближнюю, а то и дальнюю. Ещё хуже сценарий, когда одна функция лежит на разных страницах памяти. Тогда получаем лишние прерывания и потраченное время, которое центральный процессор мог бы использовать для чего-то более полезного (например, исполнения кода). В таких случаях оптимизации не получится — программа замедлится. Но такие проблемы возникают в очень редких и узконаправленных случаях.

Инлайнинг в Go: как реализуется 

В Go инлайнинг реализуется на этапе компиляции, и его идея максимально простая — основана на budget model, или cost-based model. Суть: у нас есть бюджет, равный 80, на встраивание одной функции. Считаем стоимость функции — если очень грубо, то это сумма всех инструкций, помноженных на какой-то вес. Если стоимость меньше или равна бюджету, мы можем встроить функцию. Если нет, то нет. 

На примере:

func bar(a, b int) int {
  ret := 0

  for i := 0; i < 10; i++ {
    ret += a
    ret *= b
  }
    return ret
} 

Вот функция bar, и мы идём по каждому узлу в AST-дереве и складываем в наш формальный «контейнер». 

Если видим, что место осталось, то теоретически можем встроить. А если дальше в функции есть какой-то код и бюджет с ним превышает наш лимит — значит, функцию bar уже не встроим. Спойлер: есть техника оптимизации для этого, рассмотрим её позже.

Всё так просто?

Почти. Когда дело касается нелистовых функций, стратегии инлайнинга становятся сложнее. Посмотрим на код:

func func1() {
/*
some code
*/
func2()
}

func func2() {
/*
some code 
*/
}

Есть func1 с некоторым кодом, который точно будет встроен, и есть вызов функции func2. Сумма бюджетов этих функций входит в допустимый размер. Вопрос: встроим ли мы func1 в место её вызова?

 

Ответ: можем встроить по-разному, в зависимости от версии языка Go. Переломным моментом стал выпуск Go 1.12: с ним пришёл mid-stack inlining. Он нацелен на то, чтобы решить проблему встраивания нелистовых функций.

Mid-stack inlining

Компилятор Go ранее встраивал только листовые функции — то есть те, которые не содержали в себе вызов других функций. Начало этой оптимизации было положено в версии 1.9, о ней рассказывается в презентации Mid-stack inlining in the Go compiler (David Lazar). До версии 1.12 она была доступна только через флаг -gcflags = -l = 4.

Как влиять на встраивание функций в Go

Кто работает с C/C++, знает, что компилятор можно попросить заинлайнить функцию. А как в Go? 

Во-первых, директивы // go:inline не существует! Нельзя сказать «Go, пожалуйста, встрой мне эту функцию», как бывает в C++. Зато есть директива // go:noinline. Она как раз говорит компилятору не встраивать функцию.

Второе, что можно использовать, это один из двух флагов компилятора:

-gcflags=-l (или -gcflags=-l=1) отключить встраивание;

-gcflags=-l=4 — уменьшить стоимость встраивания метода нелистовых функций.

Ещё есть -gcflags=-l=2 и -gcflags=-l=3, но они сейчас не используются, поэтому никакого эффекта на компилятор не окажут.

Это пока все способы, которыми можно влиять на встраивание функций, не меняя код. 

Что Go может инлайнить, а что нет 

Посмотрим, что может и не может заинлайнить Go сейчас и как это происходило раньше. Для этого разберём несколько тестов.

Сразу оговоримся, что Go 1 никогда не сможет заинлайнить:

  • функции с recover(),

  • runtime.Caller.

Это классы функций, которым нужен полноценный указатель на стек. 

Смотрим на примере:

func main() {
  foo()
}

func foo() {
  /*
    some code
  */
  bar()
}

func bar() {
  /*
    some code 
  */
  runtime.Caller(1)
  /*
    some code 
  */
}

Есть main, который вызывает foo; foo вызывает bar; bar вызывает runtime.Caller. С его помощью можем узнать, кто нас вызвал. При этом как аргумент эта функция принимает количество данных со стека. Так что можем узнать не только родителя, который нас вызывал, но и имя его предка. Функция bar в этом случае должна иметь полноценный указатель на стек функции foo, а та — на стек функции main

А что Go уже может заинлайнить?

  • panic;

  • goto;

  • atomic-операции;

  • append;

  • map access;

  • замыкания (почти);

  • type switch (такая штука, когда вы по интерфейсу можете получить исходный тип) — эта оптимизация доступна с версии 1.16;

  • for-loop;

  • код с mutex начиная с Go 1.13;

  • sync.Once.

Как узнать, встроится функция или нет? Посмотрим на примере с type switch:

func foo(i interface{}) {
  switch i.(type) {
    case int64:
      print(“float32”)
    case string:
      print(“string”)
    default:
      print(“unknown”)
  }
}

Есть флаг компилятора, который покажет, какие оптимизации были и как они отработали. 

Видим, что в версии 1.15 код, который производит инлайнинг, даже не знает узел type switch в AST-дереве. А в 1.16 уже знает и функция будет встроена: её стоимость равна 18 и 18 меньше, чем 80.

Go 1.16 может встраивать for. Но не любой. Например:

  • Вот такой может:

func foo() {
  for i := 0; i <= 10; i++ {
    print(i)
  }
}
  • И такой может:

func foo(arr []int) {
  for i := 0; i < len(arr); i++ {
    print(arr[i])
  }
}
  • А такой уже нет, только с версии 1.18:

func foo(arr []int) {
  for i := range arr {
    print(i)
  }
}

Go 1.13 и выше может встроить код с mutex. Это всё благодаря mid-stack inlining.

Если посмотрим на код из стандартной библиотеки, то увидим, что есть Fast path и Slow path. Причём быстрый путь состоит из вызова одной инструкции к процессору.

    // Lock locks m.
    // If the lock is already in use, the calling goroutine
    // blocks until the mutex is available.
    func (m *Mutex) Lock() {
    // Fast path: grab unlocked mutex.
     	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
     		if race.Enabled {
     			race.Acquire(unsafe.Pointer(m))
     		}
     		return
     	}
     	// Slow path (outlined so that the fast path can be inlined)
      	m.lockSlow()
     }

По такому же сценарию работает sync.Once. Там тоже есть быстрый путь, который представляет собой atomic-операцию (помним, что Go может встраивать atomic). И есть медленный.

      func (o *Once) Do(f func()) {
     	// Note: Here is an incorrect implementation of Do:
     	//
      	//	if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
      	//		f()
      	//	}
      	//
      	// Do guarantees that when it returns, f has finished.
      	// This implementation would not implement that guarantee:
      	// given two simultaneous calls, the winner of the cas would
      	// call f, and the second would return immediately, without
      	// waiting for the first's call to f to complete.
      	// This is why the slow path falls back to a mutex, and why
      	// the atomic.StoreUint32 must be delayed until after f returns.
      
      	if atomic.LoadUint32(&o.done) == 0 {
      		// Outlined slow-path to allow inlining of the fast-path.
      		o.doSlow(f)

Общая идея оптимизации — разделять быстрые и медленные участки кода на разные функции. Это называется Function outlining и используется в мьютексах, в sync.Once, криптографии и ещё много где. Такой оптимизацией можем обмануть Go и заставить его инлайнить там, где формально он бы этого не делал. Можно использовать для оптимизаций не только встраивания, но и работы с памятью. 

При написании кода надо подумать, какая часть функции будет вызываться чаще, какая быстрее или медленнее. Отталкиваясь от этого, решить, использовать ли Function outlining.

Как это всё применять на практике

Так что, нужно иметь под рукой табличку «Что Go может встроить, а что нет» и постоянно с ней сверяться? Нет, есть способы поудобнее. 

  1. Флаги к компилятору — уже упоминали их выше. Работает, но не совсем удобно, если у вас большой код или функция.

По Command+Shift+P или Ctrl+Shift+P в VSCode можно вызвать контекстное меню и выбрать action Go: Toggle gc details. Он при наведении на функцию покажет, будет ли она встроена, а если нет, то почему.

Если живёте на GoLand, то у вас пока такого нет. И судя по этому семилетнему открытому тикету, скорее всего, не будет. Но можно наставить ему лайков, чтобы привлечь внимание разработчиков.

Бонус: несколько находок про инлайнинг

  • В Go функции при инлайнинге не совсем равноправны. Компилятор разделяет функции на большие и маленькие. Большие — это те, в которых больше 5 000 узлов (узел — это часть AST-дерева). Инлайнинг в большую функцию стоит намного дороже, и бюджет на встраивание уменьшается с 80 до 20. Это категорическое снижение бюджета напоминает о том, что в Go хорошо писать небольшие функции. Не только чтобы было проще покрывать их тестами, делать код читаемым и поддерживаемым, но и чтобы Go мог эффективнее производить оптимизации. 

  • Go может встроить замыкание, которое вы вернули из функции. На встраивание функции с замыканием влияет и сам факт замыкания, и то, насколько «дорогой» код внутри него. Стоимость одной функции, которую возвращает другая, влияет на общую стоимость.

  • Константы не влияют на инлайнинг и на бюджет встраивания. На то они и константы.

  • Любой вызов метода, который не может быть встроен, сжирает больше половины бюджета на инлайнинг. Обойти это можно флагом -gcflags="-l=4". Но надеюсь, что это будет влито в upstream и станет работать под флагом по умолчанию.

И так как встраивание в большую функцию снижает бюджет на инлайнинг с 80 до 20, то встроить функцию, которая вызывает лишь метод, уже не получится.


В Go инлайнинг стремительно эволюционировал. Раньше мы даже не ожидали, что нелистовые функции могут быть встроены. А сейчас видим, что Go сможет встроить и циклы, и type switch, и atomic. 

Вместо вывода — три простых совета для работы с инлайнингом:

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

  • Помните про Fast path и Slow path в Function outlining — потому что он влияет не только на инлайнинг, но и на то, чтобы обходить escape-анализ.

  • Пишите бенчмарки — это база, от которой можно отталкиваться при оптимизации кода. Без неё не знаешь, хорошо или плохо делаешь. 

Эта статья написана по мотивам моего выступления на GolangConf в рамках HighLoad++ Foundation. Можно посмотреть видео, если это удобнее, или послушать Q&A сессией после доклада.

Комментарии (14)


  1. Hixon10
    06.09.2022 23:29
    +2

    в runtime — для платформ или движков JVM, V8, а теперь и Ruby с его JIT-компиляцией. Чтобы для них произвести инлайниг, сначала происходит JIT-компиляция, а вместо байт-кодов уже вставляется машинный код. Это тоже можно рассматривать как встраивание.


    Понятно, что статья про go, и это лишь сноска на полях, но всё же. Есть ли какой-то внятный источник, почему мы это можем рассматривать, как встраивание? Для меня это не глядит корректным, но, может быть, кто-то авторитетный решил, что это корректно.


  1. alex103
    07.09.2022 05:57
    +3

    Функции foo и bar вызывают друг друга, и это можно оптимизировать

    (Мыжпрограмисты) Всё таки только одна из них вызывает другую, а вторая нет.


  1. jmdorian
    07.09.2022 09:14
    +2

    Заголовок немного сбивает с толку. Под встраиванием все-таки обычно подразумевают композицию, как в набившем оскомину вопросе "Почему встраивание в Go - не наследование". Но статья интересная, спасибо.


  1. serjeant
    07.09.2022 10:24

    Спасибо за интересную статью!


  1. QtRoS
    07.09.2022 12:37
    +2

    Go и других языках, которые используют LLVM-стек

    Go разве на LLVM? Вроде там свой компилятор, со своей (не)хитрой кодогенерацией. Навскидку нагугленный пруф

    почистить за собой стек и регистры процессора.

    Почистить стек - это как? ЕМНИП его никто не чистит, иначе откуда тогда понятие "мусор со стека" и собственно само ускорение за счёт стека. То же самое про регистры.


  1. WASD1
    07.09.2022 16:16
    +1

    Общая часть про "инлайнинг вообще" рисует неточнкую картину не по-мелочам (что простительно - это не фокус статьи), а по-крупному.
    1. Выгода он инлайна идёт из 2 механизмов:
    - а) экономия на call-ret
    - б) заинлайненый код может исполняться одноверменно (перемешиваться как компилятором так и ООО-процессором) с другим кодом в вызывающей ф-ии. Для явного call-ret компилятор ничего не перемешиваем, процессор - весьма ограниченно.

    Вы вовсе не упомянули про пункт "б". И явно описали худший случай для пункта а).
    Скажем для С типовой случай инлайнинга это static- функции. В этом случае предусмотрен вариант передачи аргументов непосредственно через регистры, минуя стек. Что делает страшное описание вызова ф-ии на деле не таким накладным.


  1. AndreyBerenda
    08.09.2022 01:49

    Кто работает с C/C++, знает, что компилятор можно попросить заинлайнить функцию. А как в Go? 

    Можно только попросить, но inline не обязывает компилятор C/C++ встраивать функцию
    поэтому выглядит что не сильно отличается С/С++ от Golang в этом плане
    https://stackoverflow.com/questions/1759300/when-should-i-write-the-keyword-inline-for-a-function-method


    1. KrD
      09.09.2022 16:03
      +1

      1. AndreyBerenda
        09.09.2022 16:04

        Спасибо, не знал


  1. ivanburak
    08.09.2022 01:51

    Никита, спасибо, хорошая статья, только у меня не получилось такой большой разницы в бенчмарках для foo() и bar()

    ```
    package main
    import "testing"

    var n int
    var x, y = f()

    func f() (int, int) {return 2, 4}

    func Benchmark_Foo(b *testing.B) {
    var v int
    for i := 0; i < b.N; i++ {
    v = foo(x, y)
    }
    n = v
    }

    func Benchmark_Bar(b *testing.B) {
    var v int
    for i := 0; i < b.N; i++ {
    v = bar(x, y)
    }
    n = v
    }

    //go:noinline
    func foo(a, b int) int {
    ret := 0
    for i := 0; i < 10; i++ {
    ret += a
    ret *= b
    }
    return ret
    }

    func bar(a, b int) int {
    ret := 0
    for i := 0; i < 10; i++ {
    ret += a
    ret *= b
    }
    return ret
    }

    ```

    `
    goos: windows
    goarch: amd64
    pkg: ivanburak/20220212/inline
    cpu: Intel(R) Core(TM) i7-8565U CPU @ 1.80GHz
    Benchmark_Foo-8 287415028 4.143 ns/op
    Benchmark_Bar-8 305612419 3.926 ns/op
    `
    go version go1.19.1 windows/amd64


    1. Denisss025
      09.09.2022 14:26

      У меня ещё веселее (go1.19)

      goos: windows
      goarch: amd64
      cpu: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz
      Benchmark_Foo-12        261612658                4.551 ns/op
      Benchmark_Bar-12        163953508                7.072 ns/op
      PASS
      ok      test    3.795s

      WSL2, go1.19:

      goos: linux
      goarch: amd64
      cpu: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz
      Benchmark_Foo-12        255724063                4.657 ns/op
      Benchmark_Bar-12        288291266                4.160 ns/op
      PASS
      ok      test    3.298s


  1. Aiudar
    08.09.2022 01:53

    Что Go может инлайнить, а что нет 

    Прошу мне немного пояснить.

    Есть main, который вызывает foofoo вызывает barbar вызывает runtime.Caller ...

    Правильно ли я понял, что ни одна функция не заинлайнится?

    Вопрос следующий. У меня есть сервис -> в нем логгер -> внутри логгера, у функции log.Error() есть runtime.Caller(1), чтобы сразу видеть где ошибка (Пишет имя файлика и строку). Получается ли так, что раз сервис создается еще в main, то вообще никакого инлайнинга у меня в итоге нет?

    P.S. debug.Stack() я так понимаю с инлайном работает нормально?


  1. KrasPvP
    08.09.2022 01:56

    так получается, что go сам инлайнит всё, что может, а мы можем принудительно ему только указать, что не нужно инлайнить //go: noinline ?


    1. AndreyBerenda
      08.09.2022 02:00

      Да, все правильно