В Go есть возможность получить отчёт о выполняемом escape-анализе: go build -gcflags '-m=3 -l'
. В этой статье я расскажу, как можно визуализировать этот отчёт в VS Code. Дополнительно приведу способ, как в несколько кликов проверить теорию (escape-анализ) практикой (профилирование).
Визуализация с использованием gopls
Этот метод основан на статье Analyzing Go Heap Escapes.
- Идём в
Manage -> Settings -> User
, там ищемgopls
, далее выбираемEdit in settings.json
и добавляем следующие настройки (другие настройки см. тут):
"gopls": {
...
"ui.codelenses": {
"gc_details": true
},
"ui.diagnostic.annotations": {
"escape": true
},
...
},
После этого в исходниках появляется ссылка Toggle gc annotation details
, при нажатии на которую получаем искомую визуализацию.
Недостатки этого метода:
- Ссылка
Toggle gc annotation details
иногда не работает (например, если в workspace добавлено несколько папок). - Метод позволяет изучить только случаи
escape to heap
и не показываетleaking param
. - Ссылки в окне Problems не работают.
Возможно, с этим можно как-то бороться, возможно, это характерно для моего окружения (Windows 11), но я не нашёл способа обхода этих проблем. Поэтому я использую, в основном, другой метод, описанный ниже.
Визуализация с использованием Tasks
Принцип подсмотрен здесь:
- Копируем задачи или часть их в буфер обмена.
F1 -> Tasks: Open User Tasks
.- Вставляем всё или часть ранее скопированного
- Для Windows выставить
Git Bash
как профиль терминала по умолчанию:F1 -> Terminal: Select Default Profile
Теперь можно запускать задачи через F1 -> Tasks: Run Task
:
- Go: Escape analysis, leaking + escapes.
- Go: Escape analysis, escapes only.
- Go: Escape analysis, detailed, for current file and line.
- Go: Profile current benchmark (memory).
- Go: Profile current benchmark (cpu).
- Environment.
К escape-анализу относятся задачи 1-3, к профилированию — 4 и 5, задача 6 нужна для того, чтобы в окне Terminal
посмотреть, какие переменные окружения используются при запуске задач.
После запуска в окне Terminal
появляется результат. Строки результата интерпретируются как проблемы, проблемы автоматически попадают в окно Problems
, в коде появляются "красные метки".
Чтобы очистить окно Problems
, нужно выполнить F1 -> Developer: Reload Window
. Перед тем, как двигаться дальше, немного разберёмся в терминологии.
"Escape to heap" vs "Leaking param"
Escape to heap относится к ситуации, когда компилятор вынужден разместить значение в куче, а не на стеке. В примере ниже doesNotEscape
остаётся на стеке, а escapes
убегает в кучу:
func Slices() int {
doesNotEscape := make([]byte, 10000)
escapes := make([]byte, 100000)
return len(doesNotEscape) + len(escapes)
}
Порог, после которого происходит размещение в кучу, для slice на момент написания статьи таков (используется здесь):
// MaxImplicitStackVarSize is the maximum size of implicit variables that we will allocate on the stack.
// p := new(T) allocating T on the stack
// p := &T{} allocating T on the stack
// s := make([]T, n) allocating [n]T on the stack
// s := []byte("...") allocating [n]byte on the stack
// Note: the flag smallframes can update this value.
MaxImplicitStackVarSize = int64(64 * 1024)
С leaking param ситуация иная. Leaking param
– это такой параметр, значение для которого не может быть размещено вызывающей стороной на стеке.
Пример leaking param
:
func ReturnSlice(leaking []byte) []byte {
return leaking
}
Внутри функции не происходит "побега в кучу", он произойдёт в коде, вызывающем ReturnSlice
, например:
// `a` escapes to heap because of the ReturnSlice() leaking param
func CallReturnSlice() {
a := make([]byte, 8)
fmt.Println(ReturnSlice(a))
}
Leaking param
может случиться в достаточно обширном числе случаев.
In Go (Golang), "leaking param" typically refers to scenarios where a parameter passed to a function escapes to the heap, potentially causing memory inefficiencies. This usually happens in the following scenarios:
Storing a reference to a variable: When a function stores a reference to a parameter in a variable that outlives the function call, the parameter escapes to the heap.
Returning a pointer to a local variable: If a function returns a pointer to a local variable (including parameters), the local variable escapes.
Sending data to channels: If a parameter is sent over a channel and the channel outlives the function, the parameter escapes.
Capturing in a closure: When a parameter is captured by a closure (anonymous function) that outlives the function call, the parameter escapes.
Interface method calls: If a parameter is passed to an interface method, it may escape because the compiler cannot determine the exact implementation at compile time.
Using
defer
orgo
with parameters: Parameters passed to functions called withdefer
orgo
might escape, especially if the deferred function runs after the calling function returns.
Assigning to a global variable: If a parameter is assigned to a global variable, it escapes.
Slicing or appending to a slice: If a function slices or appends to a slice and the result outlives the function, the slice's underlying array may escape.
Passing to a function taking an interface: If a parameter is passed to a function that takes an interface type, it might escape due to the uncertainty about the underlying type.
Using reflection: Using reflection on a parameter, especially if modifying it, can cause it to escape.
Large structs: Sometimes, large structs are moved to the heap to avoid the cost of copying them.
Method receivers: If a method is called on a pointer receiver, the receiver may escape if it is modified in the method.
Passing to
fmt
package functions: Functions likefmt.Printf
that accept interface{} parameters can cause those parameters to escape.
Understanding these scenarios can help in optimizing Go code for better memory management, as avoiding unnecessary escapes to the heap can lead to more efficient memory usage.
В следующем примере "утечки" параметра функции SliceLen()
не происходит, slice вызывающей функции остаётся на стеке:
// `p` does NOT leak
func SliceLen(p []byte) int {
return len(p)
}
// `a` is kept on the stack
func CallSliceLen(f func([]byte) int) {
a := make([]byte, 8)
// Result of SliceLen(a) escapes to heap
fmt.Println(SliceLen(a))
}
Однако, согласно результатам escape-анализа, в кучу "убегает" результат вызова SliceLen()
, то есть значение типа int
. Это цена за использование параметра типа interface{}, о чём предупреждал ChatGPT в п.9.
Со всеми примерами можно ознакомиться здесь, в файлах escapes.go и escapes_test.go.
Профилирование: установка ПО и конфигурация
Суха теория, мой друг, но древо жизни зеленеет, проверим теорию практикой. Как я уже писал, проверка осуществляется в несколько кликов с помощью задач 4 и 5. Прежде чем запускать профилирование, нужно убедиться, что установлено соответствующее ПО и выполнить некоторую конфигурацию.
Установка ПО:
- Graphviz для визуализации результатов:
- Для Windows я использую:
choco install graphviz
- Для Windows я использую:
- Утилиты типа
xargs
,sed
,grep
. На unix-подобных ОС всё это есть, на Windows, если есть git, всё это тоже есть вC:\Program Files\Git\usr\bin
Конфигурация:
Запускаемые задачи будут создавать файлы cpu.out
, mem.out
, *.test
и *.test.exe
рядом с исходниками. Всё это полезно игнорировать в git, поэтому добавляем в .gitignore
, если нужно:
*.out
*.test
*.test.exe
Профилирование
Автор статьи "Analyzing Go Heap Escapes" приводит в качестве "подопытной" функцию, в которой статический анализатор видит две проблемы:
// `y`: leaking param
func YIfLongest(x, y *string) *string {
if len(*y) > len(*x) {
return y
}
// `s`: escapes to heap
s := ""
return &s
}
Давайте напишем для неё тест на производительность:
func Benchmark_YIfLongest(b *testing.B) {
x := "x"
y := "y"
for i := 0; i < b.N; i++ {
l := YIfLongest(&x, &y)
if l == nil {
b.Fatal("l is nil")
}
}
}
Устанавливаем курсор на строку с Benchmark_YIfLongest, запускаем задачу F1 -> Go: Profile current benchmark (cpu)
. Должно повезти и откроется окно браузера с результатом, где выбираем View -> Source
:
Внимание! После завершения просмотра результатов нужно зайти в окноTerminal
и нажатьCtrl+C
. Это, пожалуй, единственный недостаток рассматриваемого метода.
Исходя из показанного результата, мы можем заключить, что код YIfLongest не выполняется вообще. К слову, писать микротесты на производительность становится всё труднее, так как компилятор склонен отбрасывать "мелочь" как несущественную. Пока можно "обмануть" так, хотя чувствую, недалёк тот день, когда и это будет "соптимизировано":
func Benchmark_YIfLongest1_array(b *testing.B) {
x := [5]string{"a", "ab", "abc", "abcd", "abcde"}
y := [5]string{"a", "ab", "abc", "abcd", "abcde"}
for i := 0; i < b.N; i++ {
l := YIfLongest(&x[i%len(x)], &y[i%len(y)])
if l == nil {
b.Fatal("l is nil")
}
}
}
Результат профилирования Benchmark_YIfLongest1_array по CPU (F1 -> Tasks: Run Task -> Go: Profile current benchmark (cpu)
):
Ну вот, теперь что-то выполняется и видно, что функция — встроена. Запуск Benchmark_YIfLongest1_array
из IDE дает интересный результат:
goos: windows
goarch: amd64
pkg: escapes
cpu: 12th Gen Intel(R) Core(TM) i7-12700
Benchmark_YIfLongest1_array
Benchmark_YIfLongest1_array-20
1000000000 0.5286 ns/op 0 B/op 0 allocs/op
То есть, никаких побегов в кучу не происходит, несмотря на двойное предупреждение escape-анализатора. Пока "принудить к побегу" можно директивой //go:noinline
:
//go:noinline
func YIfLongest_noinline(x, y *string) *string {
if len(*y) > len(*x) {
return y
}
s := ""
return &s
}
func Benchmark_YIfLongest_noinline(b *testing.B) {
x := "x"
y := "y"
for i := 0; i < b.N; i++ {
l := YIfLongest_noinline(&x, &y)
if l == nil {
b.Fatal("l is nil")
}
}
}
Здесь мы, наконец, получаем:
67038356 16.70 ns/op 16 B/op 1 allocs/op
Результат профилирования по памяти F1 -> Tasks: Open User Tasks -> Go: Profile current benchmark (memory)
:
Напоследок приведу пару примеров, с которыми я сталкивался в своей практике и получал небольшие "шишки".
Замыкания
// `v` and `closure` escape
func ProvideClosure(closureCaller func(func() int) int) int {
var v int
closure := func() int {
v++
return 2
}
return closureCaller(closure)
}
Замыкание и его данные "убегают в кучу", что, в принципе, очевидно. Менее очевидно, что в кучу будет убегать замыкание, приготовленное следующим образом:
func (c *Closure) Do() int {
c.v++
return 2
}
// c.Do escapes
func (c *Closure) ProvideInterfaceMethodAsClosure(closureCaller func(func() int) int) int {
return closureCaller(c.Do)
}
Но оно убегает. В одном из проектов под моим руководством было довольно много итераторов по такому шаблону:
func (ff *fields) Fields(cb func(IField)) {
for _, n := range ff.fieldsOrdered {
cb(ff.Field(n))
}
}
Это удобно, но когда такие штуки вызываются в highload потоке, начинает "течь" достаточно ощутимо. Пришлось оптимизировать в некоторых местах. Впрочем, это не заняло много времени, а "premature optimization is the root of all evil in programming".
Десериализация
Казалось бы, что может случиться, если прочитать int64 из io.Reader в переменную на стеке?
func ReadInt64UsingBinaryRead(r io.Reader) (int64, error) {
var v int64
err := binary.Read(r, binary.BigEndian, &v)
return v, err
}
Случится 8 байт на операцию:
Benchmark_ReadInt64UsingBinaryRead-20
65526505 16.23 ns/op 8 B/op 1 allocs/op
Случаться будет здесь:
Причем в версии Go 1.20 переменная v
тоже убегала в кучу! Этот исторический феномен можно посмотреть в статике таким образом:
- go install golang.org/dl/go1.20.12@latest
- go1.20.12 download
- Заменить
go
наgo1.20.12
для задачи "Go: Escape analysis, leaking + escapes" и выполнить задачу.
Заключение
В этой статье я рассказал, как можно визуализировать результаты escape-анализа в VS Code. Дополнительно привел способ, как в несколько кликов проверить теорию (escape-анализ) практикой (профилирование) и рассмотрел пару практических граблей, с которыми пришлось столкнуться на практике. Надеюсь, эта информация будет полезна. Оптимизируйте побеги в кучу, спасибо за внимание!