Прелюдия
Это третья из четырех статей в серии, которая даст представление о механике и дизайне указателей, стеков, куч, escape analysis и семантики значения/указателя в Go. Этот пост посвящен профилированию памяти.
Оглавление цикла статей:
- Language Mechanics On Stacks And Pointers (перевод)
- Language Mechanics On Escape Analysis (перевод)
- Language Mechanics On Memory Profiling
- Design Philosophy On Data And Semantics
Посмотрите это видео, чтобы увидеть демонстрацию этого кода:
DGopherCon Singapore (2017) — Escape Analysis
Вступление
В предыдущем посте я обучил основам escape analysis, используя пример, который разделяет значение в стеке горутины. Я не показал вам других сценариев, которые могут привести к переносу значений в кучу. Чтобы помочь вам с этим я собираюсь отладить программу, которая делает аллокации неожиданным образом.
Программа
Я хотел узнать больше о пакете io, поэтому я придумал для себя небольшую задачу. Дан поток байтов, напишите функцию, которая может найти строку elvis и заменить ее строкой Elvis с заглавной буквы. Мы говорим о короле, поэтому его имя всегда должно быть написано с заглавной буквы.
Вот ссылка на решение: play.golang.org/p/n_SzF4Cer4
Вот ссылка на бенчмарки: play.golang.org/p/TnXrxJVfLV
В листинге приведены две разные функции, которые решают эту задачу. В этом посте основное внимание будет уделено функции algOne, поскольку она использует пакет io. Используйте функцию algTwo, чтобы самостоятельно поэкспериментировать с профилями памяти и процессора.
Вот входные данные, которые мы собираемся использовать и ожидаемый результат от функции algOne.
Листинг 1
Input:
abcelvisaElvisabcelviseelvisaelvisaabeeeelvise l v i saa bb e l v i saa elvi
selvielviselvielvielviselvi1elvielviselvis
Output:
abcElvisaElvisabcElviseElvisaElvisaabeeeElvise l v i saa bb e l v i saa elvi
selviElviselvielviElviselvi1elviElvisElvis
Ниже приведен листинг функции algOne.
Листинг 2
80 func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
81
82 // Use a bytes Buffer to provide a stream to process.
83 input := bytes.NewBuffer(data)
84
85 // The number of bytes we are looking for.
86 size := len(find)
87
88 // Declare the buffers we need to process the stream.
89 buf := make([]byte, size)
90 end := size - 1
91
92 // Read in an initial number of bytes we need to get started.
93 if n, err := io.ReadFull(input, buf[:end]); err != nil {
94 output.Write(buf[:n])
95 return
96 }
97
98 for {
99
100 // Read in one byte from the input stream.
101 if _, err := io.ReadFull(input, buf[end:]); err != nil {
102
103 // Flush the reset of the bytes we have.
104 output.Write(buf[:end])
105 return
106 }
107
108 // If we have a match, replace the bytes.
109 if bytes.Compare(buf, find) == 0 {
110 output.Write(repl)
111
112 // Read a new initial number of bytes.
113 if n, err := io.ReadFull(input, buf[:end]); err != nil {
114 output.Write(buf[:n])
115 return
116 }
117
118 continue
119 }
120
121 // Write the front byte since it has been compared.
122 output.WriteByte(buf[0])
123
124 // Slice that front byte out.
125 copy(buf, buf[1:])
126 }
127 }
Я хочу знать, насколько хорошо работает эта функция и какое давление она оказывает на кучу. Чтобы узнать это, давайте запустим бенчмарк.
Бенчмаркинг
Я написал бенчмарк, который вызывает функцию algOne для выполнения обработки потока данных.
Листинг 3
15 func BenchmarkAlgorithmOne(b *testing.B) {
16 var output bytes.Buffer
17 in := assembleInputStream()
18 find := []byte("elvis")
19 repl := []byte("Elvis")
20
21 b.ResetTimer()
22
23 for i := 0; i < b.N; i++ {
24 output.Reset()
25 algOne(in, find, repl, &output)
26 }
27 }
Мы можем выполнить этот бенчмарк используя go test с ключасми -bench, -benchtime и -benchmem.
Листинг 4
$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem
BenchmarkAlgorithmOne-8 2000000 2522 ns/op 117 B/op 2 allocs/op
После выполнения бенчмарка мы видим, что функция algOne аллоцирует 2 значения общей стоимостью 117 байт на операцию. Это здорово, но нам нужно знать, какие строки кода в функции вызывают эти аллокации. Чтобы узнать это, нам нужно сгенерировать данные профилирования для этого теста.
Профилирование
Чтобы сгенерировать данные профилирования, запустим бенчмарк снова, но на этот раз запросим профиль памяти с помощью ключа -memprofile.
Листинг 5
$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem -memprofile mem.out
BenchmarkAlgorithmOne-8 2000000 2570 ns/op 117 B/op 2 allocs/op
После завершения бенчмарка, инструмент тестирования создал два новых файла.
Листинг 6
~/code/go/src/.../memcpu
$ ls -l
total 9248
-rw-r--r-- 1 bill staff 209 May 22 18:11 mem.out (NEW)
-rwxr-xr-x 1 bill staff 2847600 May 22 18:10 memcpu.test (NEW)
-rw-r--r-- 1 bill staff 4761 May 22 18:01 stream.go
-rw-r--r-- 1 bill staff 880 May 22 14:49 stream_test.go
Исходный код находится в папке memcpu в функции algOne файла stream.go и функции бенчмарка в файле stream_test.go. Созданные два новых файла называются mem.out и memcpu.test. Файл mem.out содержит данные профиля, а файл memcpu.test, названный в честь папки, содержит тестовый двоичный файл, который нам нужен для доступа к символам при просмотре данных профиля.
Имея данные профиля и тестовый двоичный файл, мы можем запустить инструмент pprof для изучения данных профиля.
Листинг 7
$ go tool pprof -alloc_space memcpu.test mem.out
Entering interactive mode (type "help" for commands)
(pprof) _
При профилировании памяти и поиске «низко висящих фруктов» вы можете использовать опцию -alloc_space вместо опции по умолчанию -inuse_space. Это покажет вам, где происходит каждую аллокацию, независимо от того, находится ли она в памяти или нет, когда вы берете профиль.
В окне ввода (pprof) мы можем проверить функцию algOne с помощью команды list. Эта команда принимает регулярное выражение в качестве аргумента, для того чтобы найти функцию(и), которую вы хотите просмотреть.
Листинг 8
(pprof) list algOne
Total: 335.03MB
ROUTINE ======================== .../memcpu.algOne in code/go/src/.../memcpu/stream.go
335.03MB 335.03MB (flat, cum) 100% of Total
. . 78:
. . 79:// algOne is one way to solve the problem.
. . 80:func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
. . 81:
. . 82: // Use a bytes Buffer to provide a stream to process.
318.53MB 318.53MB 83: input := bytes.NewBuffer(data)
. . 84:
. . 85: // The number of bytes we are looking for.
. . 86: size := len(find)
. . 87:
. . 88: // Declare the buffers we need to process the stream.
16.50MB 16.50MB 89: buf := make([]byte, size)
. . 90: end := size - 1
. . 91:
. . 92: // Read in an initial number of bytes we need to get started.
. . 93: if n, err := io.ReadFull(input, buf[:end]); err != nil || n < end {
. . 94: output.Write(buf[:n])
(pprof) _
Основываясь на этом профиле, мы теперь знаем что input и buf аллоцируются в кучу. Поскольку input является переменной-указателем, профиль действительно говорит о том, что выделяется значение bytes.Buffer, на которое указывает указатель input. Итак, давайте сначала сосредоточимся на аллокации input и поймем, почему она происходит.
Мы могли бы предположить, что аллокация происходит, потому что вызов функции bytes.NewBuffer разделяет значение bytes.Buffer, которое создает стек вызовов. Тем не менее, наличие значения в столбце flat (первый столбец в выводе pprof) говорит мне, что значение аллоцируется, потому что функция algOne разделяет его таким образом, чтобы заставить его выйти в кучу.
Я знаю, что столбец flat представляет аллокации в функции, поэтому посмотрите, что показывает команда list для функции Benchmark, которая вызывает algOne.
Листинг 9
(pprof) list Benchmark
Total: 335.03MB
ROUTINE ======================== .../memcpu.BenchmarkAlgorithmOne in code/go/src/.../memcpu/stream_test.go
0 335.03MB (flat, cum) 100% of Total
. . 18: find := []byte("elvis")
. . 19: repl := []byte("Elvis")
. . 20:
. . 21: b.ResetTimer()
. . 22:
. 335.03MB 23: for i := 0; i < b.N; i++ {
. . 24: output.Reset()
. . 25: algOne(in, find, repl, &output)
. . 26: }
. . 27:}
. . 28:
(pprof) _
Поскольку в столбце cum (второй столбец) есть только значение, это говорит мне о том, что функция Benchmark ничего не аллоцирует напрямую. Все аллокации происходят из вызовов функций, которые выполняются внутри этого цикла. Вы можете видеть, что все номера аллокаций из этих двух вызовов list совпадают.
Мы до сих пор не знаем, почему выделяется значение bytes.Buffer. Здесь вам пригодится ключ -gcflags "-m -m" команды go build. Профилировщик может только сказать вам, какие значения перемещаются в кучу, а build может сказать вам, почему.
Отчетность компилятора
Давайте спросим у компилятора, какие решения он принимает для escape analysis в коде.
Листинг 10
$ go build -gcflags "-m -m"
Эта команда производит много выходных данных. Нам просто нужно найти в выводе все, что имеет stream.go: 83, поскольку stream.go — это имя файла, который содержит этот код, а строка 83 содержит конструкцию значения bytes.buffer. После поиска мы находим 6 строк.
Листинг 11
./stream.go:83: inlining call to bytes.NewBuffer func([]byte) *bytes.Buffer { return &bytes.Buffer literal }
./stream.go:83: &bytes.Buffer literal escapes to heap
./stream.go:83: from ~r0 (assign-pair) at ./stream.go:83
./stream.go:83: from input (assigned) at ./stream.go:83
./stream.go:83: from input (interface-converted) at ./stream.go:93
./stream.go:83: from input (passed to call[argument escapes]) at ./stream.go:93
нас интересует первая строка, которую мы нашли по поиску stream.go: 83.
Листинг 12
./stream.go:83: inlining call to bytes.NewBuffer func([]byte) *bytes.Buffer { return &bytes.Buffer literal }
Это подтверждает, что значение bytes.Buffer не исчезло, поскольку оно было передано в стек вызовов. Это произошло потому, что вызов bytes.NewBuffer никогда не происходил, код внутри функции был встроенным.
Вот строка кода, о которой идет речь:
Листинг 13
83 input := bytes.NewBuffer(data)
Из-за того, что компилятор решил встроить вызов функции bytes.NewBuffer, написанный мной код преобразуется в это:
Листинг 14
input := &bytes.Buffer{buf: data}
Это означает, что функция algOne создает значение bytes.Buffer напрямую. Итак, теперь вопрос в том, что заставляет значение выходить из стекового фрейма algOne? Этот ответ находится в других 5 строках, которые мы нашли в отчете.
Листинг 15
./stream.go:83: &bytes.Buffer literal escapes to heap
./stream.go:83: from ~r0 (assign-pair) at ./stream.go:83
./stream.go:83: from input (assigned) at ./stream.go:83
./stream.go:83: from input (interface-converted) at ./stream.go:93
./stream.go:83: from input (passed to call[argument escapes]) at ./stream.go:93
Эти строки говорят нам о том, что побег в кучу происходит в 93 строке кода. Переменной input присваивается значению интерфейса.
Интерфейсы
Я не помню, чтобы вообще делал в коде присваивание значение интерфейса. Однако, если посмотреть на строку 93, станет ясно, что происходит.
Листинг 16
93 if n, err := io.ReadFull(input, buf[:end]); err != nil {
94 output.Write(buf[:n])
95 return
96 }
Вызов io.ReadFull вызывает присваивание интерфейса. Если вы посмотрите определение функции io.ReadFull, вы увидите, что она принимает переменную input через интерфейсный тип.
Листинг 17
type Reader interface {
Read(p []byte) (n int, err error)
}
func ReadFull(r Reader, buf []byte) (n int, err error) {
return ReadAtLeast(r, buf, len(buf))
}
Похоже, что передача адреса bytes.Buffer вниз по стеку вызовов и его сохранение в значении интерфейса Reader вызывает побег (escape). Теперь мы знаем, что цена использования интерфейса высока: распределение и косвенность. Так что, если неясно, как именно интерфейс делает код лучше, вероятно, вам не нужно его использовать. Вот несколько рекомендаций, которым я следую, чтобы проверить использование интерфейсов в моем коде.
Используйте интерфейс, когда:
- пользователи API должны предоставить детали реализации.
- У API есть несколько реализаций, которые они должны поддерживать внутри.
- Были определены части API, которые могут измениться и требуют разделения.
Не используйте интерфейс:
- ради использования интерфейса.
- для обобщения алгоритма.
- когда пользователи могут объявить свои собственные интерфейсы.
Теперь мы можем спросить себя, действительно ли этому алгоритму нужна функция io.ReadFull? Ответ — нет, потому что тип bytes.Buffer имеет набор методов, который мы можем использовать. Использование методов против значения, которым обладает функция, может предотвратить аллокации.
Давайте изменим код, чтобы удалить пакет io, и используем метод Read непосредственно для переменной input.
Это изменение кода избавляет от необходимости импортировать пакет io, поэтому, чтобы сохранить все номера строк одинаковыми, я использую пустой идентификатор для импорта пакета io. Это позволит импорту остаться в списке.
Листинг 18
12 import (
13 "bytes"
14 "fmt"
15 _ "io"
16 )
80 func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
81
82 // Use a bytes Buffer to provide a stream to process.
83 input := bytes.NewBuffer(data)
84
85 // The number of bytes we are looking for.
86 size := len(find)
87
88 // Declare the buffers we need to process the stream.
89 buf := make([]byte, size)
90 end := size - 1
91
92 // Read in an initial number of bytes we need to get started.
93 if n, err := input.Read(buf[:end]); err != nil || n < end {
94 output.Write(buf[:n])
95 return
96 }
97
98 for {
99
100 // Read in one byte from the input stream.
101 if _, err := input.Read(buf[end:]); err != nil {
102
103 // Flush the reset of the bytes we have.
104 output.Write(buf[:end])
105 return
106 }
107
108 // If we have a match, replace the bytes.
109 if bytes.Compare(buf, find) == 0 {
110 output.Write(repl)
111
112 // Read a new initial number of bytes.
113 if n, err := input.Read(buf[:end]); err != nil || n < end {
114 output.Write(buf[:n])
115 return
116 }
117
118 continue
119 }
120
121 // Write the front byte since it has been compared.
122 output.WriteByte(buf[0])
123
124 // Slice that front byte out.
125 copy(buf, buf[1:])
126 }
127 }
Когда мы запустим бенчмарк для этого изменения кода, мы увидим, что аллокации для значения bytes.Buffer больше нет.
Листинг 19
$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem -memprofile mem.out
BenchmarkAlgorithmOne-8 2000000 1814 ns/op 5 B/op 1 allocs/op
Мы также видим улучшение производительности примерно на 29%. Время изменилось с 2570 нс / оп до 1814 нс / оп. Теперь, когда это решено, мы можем сосредоточиться на аллокации вспомогательно среза для фрагмента buf. Если мы снова используем профилировщик для новых данных профиля, которые мы только что создали, мы сможем определить, что именно вызывает оставшееся аллокации.
Листинг 20
$ go tool pprof -alloc_space memcpu.test mem.out
Entering interactive mode (type "help" for commands)
(pprof) list algOne
Total: 7.50MB
ROUTINE ======================== .../memcpu.BenchmarkAlgorithmOne in code/go/src/.../memcpu/stream_test.go
11MB 11MB (flat, cum) 100% of Total
. . 84:
. . 85: // The number of bytes we are looking for.
. . 86: size := len(find)
. . 87:
. . 88: // Declare the buffers we need to process the stream.
11MB 11MB 89: buf := make([]byte, size)
. . 90: end := size - 1
. . 91:
. . 92: // Read in an initial number of bytes we need to get started.
. . 93: if n, err := input.Read(buf[:end]); err != nil || n < end {
. . 94: output.Write(buf[:n])
Единственное оставшаяся аллокация находится в строке 89, которая предназначена для создания вспомогательный среза.
Стековые фреймы
Мы хотим знать почему для вспомогательного среза для buf происходит аллоцируется? Давайте снова запустим сборку с помощью опции -gcflags "-m -m" и выполним поиск stream.go: 89.
Листинг 21
$ go build -gcflags "-m -m"
./stream.go:89: make([]byte, size) escapes to heap
./stream.go:89: from make([]byte, size) (too large for stack) at ./stream.go:89
В отчете говорится, что вспомогательный массив «слишком большой для стека». Это сообщение вводит в заблуждение. Дело не в том, что массив слишком большой, а в том, что компилятор не знает, какой размер у вспомогательного массива во время компиляции.
Значения могут быть помещены в стек только в том случае, если компилятор во время компиляции знает размер значения. Это связано с тем, что размер каждого стекового фрейма для каждой функции вычисляется во время компиляции. Если компилятор не знает размер значения, оно помещается в кучу.
Чтобы показать это, давайте временно захардкодим размер среза до 5 и снова запустим бенчмарк.
Листинг 22
89 buf := make([]byte, 5)
В этот раз аллокаций больше нет.
Листинг 23
$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem
BenchmarkAlgorithmOne-8 3000000 1720 ns/op 0 B/op 0 allocs/op
Если вы еще раз посмотрите на отчет компилятора, вы увидите, что ничто не перемещается в кучу.
Листинг 24
$ go build -gcflags "-m -m"
./stream.go:83: algOne &bytes.Buffer literal does not escape
./stream.go:89: algOne make([]byte, 5) does not escape
Очевидно, что мы не можем хардкодить размер среза, поэтому нам придется жить с 1 аллокацией для этого алгоритма.
Аллокации и производительность
Сравните приросты производительности, которых мы достигли при каждом рефакторинге.
Листинг 25
Before any optimization
BenchmarkAlgorithmOne-8 2000000 2570 ns/op 117 B/op 2 allocs/op
Removing the bytes.Buffer allocation
BenchmarkAlgorithmOne-8 2000000 1814 ns/op 5 B/op 1 allocs/op
Removing the backing array allocation
BenchmarkAlgorithmOne-8 3000000 1720 ns/op 0 B/op 0 allocs/op
Мы получили увеличение производительности примерно на 29% за счет того, что убрали аллокацию bytes.Buffer и ускорение на ~ 33% после удаления всех аллокаций. Аллокации — это место, где производительность приложений может пострадать.
Заключение
В Go есть несколько удивительных инструментов, которые позволяют вам понять принимаемые компилятором решения в отношении escape analysis. Основываясь на этой информации, вы можете выполнить рефакторинг кода, чтобы способствовать сохранению значений в стеке, которые не должны быть в куче. Вы не должны писать программу с нулевым количеством аллокаций, но должны стремиться минимизировать аллокации, когда это возможно.
Не делайте производительность главным приоритетом при написания кода, потому что вы не хотите гадать что должно быть производительным. Напишите код и оптимизируйте его для достижения производительной работы для достижения задачи первого приоритета. Это означает, что в первую очередь нужно сосредоточиться на целостности, удобочитаемости и простоте. После того, как у вас есть рабочая программа, определите, достаточно ли она быстра. Если нет, используйте инструменты, предоставляемые языком, чтобы найти и исправить проблемы с производительностью.