Анализ производительности программы Go 101

Go

Преждевременная оптимизация — корень всех зол.

предисловие

Эта статья взята изВыступление на GopherCon 2019, сначала сравнивает программу go, которая подсчитывает количество текстовых слов, с wc, а затем постепенно оптимизирует производительность в зависимости от профиля ЦП и памяти, используя модель параллелизма Go и анализ побега. Затем программа, которая рисует фрактальную диаграмму Мандельброта, обсуждает трассировку выполнения параллельных программ и границы улучшения производительности, которые могут принести параллельные программы.

pprof & trace

Встроенная экосистема Go предоставляет ряд API и инструментов для диагностики логики программы и проблем с производительностью. Их можно условно разделить на следующие категории:

  • Профилирование: инструменты профилирования (такие как pprof) используются для анализа сложности и накладных расходов в программе, таких как использование памяти и частота вызовов функций и т. д., и используют их для выявления наиболее ресурсоемких частей программы.
  • Трассировка: Трассировка используется для анализа задержки всего процесса вызова или запроса пользователя. Он поддерживает кросс-процесс и показывает время, затраченное каждым компонентом во всей системе.
  • Отладка: при отладке можно проверить состояние программы и поток выполнения, а также приостановить программу и проверить ее выполнение.
  • Статистика и события среды выполнения: собирает и анализирует статистику и события среды выполнения и предоставляет общий обзор работоспособности программы. Всплески/падения градусов могут помочь нам определить изменения в пропускной способности, использовании и производительности.

Для получения дополнительной информации о pprof и трассировке см.:golang.org/doc/嗲GN OS…

Профилирование использования ЦП или памяти с помощью pprof

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

package main

import (
	"fmt"
	"io"
	"log"
	"os"
	"unicode"
)

func readbyte(r io.Reader) (rune, error) {
	var buf [1]byte
	_, err := r.Read(buf[:])
	return rune(buf[0]), err
}

func main() {
	f, err := os.Open(os.Args[1])
	if err != nil {
		log.Fatalf("could not open file %q: %v", os.Args[1], err)
	}

	words := 0
	inword := false
	for {
		r, err := readbyte(f)
		if err == io.EOF {
			break
		}
		if err != nil {
			log.Fatalf("could not read file %q: %v", os.Args[1], err)
		}
		if unicode.IsSpace(r) && inword {
			words++
			inword = false
		}
		inword = unicode.IsLetter(r)
	}
	fmt.Printf("%q: %d words\n", os.Args[1], words)
}

Здесь у нас есть текстовый файл для тестирования программы, размер 1,2 м.

$ ls -lh moby.txt
-rw-r--r--  1 f1renze  staff   1.2M Jan 19 16:32 moby.txt

Давайте запустим программу и увидим, что чтение занимает 2 секунды + подсчет слов, что, похоже, не работает.

$ time go run main.go moby.txt
"moby.txt": 181275 words
        2.13 real         1.41 user         1.81 sys

Давайте сначала профилируем ЦП и добавим сегмент кода, который генерирует файл профиля, в код следующим образом:

func main() {
  # 添加以下代码
	cpuProfile, _ := os.Create("cpu_profile")
	pprof.StartCPUProfile(cpuProfile)
	defer pprof.StopCPUProfile()
	
	....

Повторно запустите программу и запустите pprof как web (сначала необходимо установитьGraphviz):

go tool pprof -http=:8081 cpu_profile

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

image.png

Сначала посмотрите на крайний правый.syscall.syscallЭто занимает в общей сложности 0,93 секунды процессора, почему вы тратите так много времени на системный вызов? Давайте посмотрим на исходный код с проблемой и увидим, что программа продолжает вызывать в цикле forreadbyteСпособ чтения файла:

...
for {
		r, err := readbyte(f)
		if err == io.EOF {
			break
		}
...

Принимая во внимание, что в этом методе за раз читается только один байт:

func readbyte(r io.Reader) (rune, error) {
	var buf [1]byte
	_, err := r.Read(buf[:])
	return rune(buf[0]), err
}

Вот проблема!

truth.jpg

Именно небуферизованные частые чтения вызывают долгую занятость системных вызовов,Это также приводит к другой проблеме, глядя на левую часть стека вызовов,runtime.pthread_cond_signalиruntime.pthread_cond_waitЭто заняло 0,58 с и 1 с соответственно.

Поскольку сопрограмма go запланирована встроенным планировщиком, то есть моделью GMP, каждому P назначается M (фактический поток ОС) для выполнения G. Когда M, выполняющий G, перехватывается синхронным блокирующим системным вызовом (например, файловый ввод-вывод), P будет перемещен в новый M (новый поток ОС или кеш потока). Как показано ниже:

img1

M1 застревает в системном вызове во время выполнения G, планировщик отсоединяет M1 от P (G1 все еще подключен к M1), назначает M2 P, а затем выбирает из PLocal Run QueueВыберите G2 для выполнения (переключатель контекста на M2 в этот момент).

img2

Когда вызов синхронной блокировки, инициированный G1, будет выполнен, G1 будет перемещен обратно в P.Local Run Queue. И M1 добавляется в кеш потока.

img3

Таким образом, настоящая причина такова: накладные расходы, вызванные несколькими системными вызовами, плюс передача P, вызванная M, пойманным в системном вызове, делают программу очень плохой.

Выяснив причину, давайте оптимизируем код:

words := 0
inword := false
# 返回带缓冲的 io.Reader
b := bufio.NewReader(f)
for {
  # 由 buffer 中读取
  r, err := readbyte(b)
  if err == io.EOF {
    break
  }

Проверьте время выполнения, которое составляет лишь часть предыдущего времени выполнения:

$ time go run main.go moby.txt
"moby.txt": 181275 words
        0.63 real         0.29 user         0.25 sys

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

image.png

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

Чтобы продемонстрировать здесь изменение частоты дискретизации на 1, профилировщик собирает всю информацию о выделении памяти. (Как правило, это не рекомендуется, поскольку это снижает эффективность программы)

func main() {
# 将之前 profile CPU 的代码注释掉后添加以下代码
memProfile, _ := os.Create("mem_profile")
runtime.MemProfileRate = 1

defer func() {
  pprof.WriteHeapProfile(memProfile)
  memProfile.Close()
}()
...

Просмотр стека вызовов и отображение распределения памяти

image.png

можно увидетьmain.readbyteВыделено 1,2 м памяти, что соответствует размеру файла moby.txt.Нажмите источник, чтобы просмотреть конкретное место выделения памяти:

image.png

Сравниватьmainфункция сreadbyteфункции, вы можете видеть, что ридер, возвращенный bufio, занимает всего 4 КБ памяти иreadbyteсерединаbufПод массив выделено 1,2 мб памяти!

image.png
image.png

Поскольку массив buf объявлен в функции, теоретически он должен размещаться в пространстве стека функции и освобождаться сразу после возврата из функции. Однако результат профиля, похоже, не тот.Давайте посмотрим на журнал escape-анализа компилятора:

$ go build -gcflags=-m main.go
# command-line-arguments
...
./main.go:15:6: moved to heap: buf
./main.go:43:21: moved to heap: buf
....

Игнорируя ненужную информацию, здесь вы можете видеть, что компилятор выделяет buf, которая является локальной переменной в функции, в кучу. так какПеременные размещаются в куче или стеке, определяется компилятором go, каждый раз, когда вы вводитеreadbyteОбъявленный в функции buf выделяется в кучу, а общий объем памяти равен размеру самого файла.

Меняем buf на глобальную переменную и снова профилируем, проблема решена:

image.png

Анализ выполнения подпрограммы с использованием трассировки

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

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

// mandelbrot example code adapted from Francesc Campoy's mandelbrot package.
// https://github.com/campoy/mandelbrot
package main

import (
	"flag"
	"image"
	"image/color"
	"image/png"
	"log"
	"os"
	"sync"
)

func main() {
	var (
		height  = flag.Int("h", 1024, "height of the output image in pixels")
		width   = flag.Int("w", 1024, "width of the output image in pixels")
		mode    = flag.String("mode", "seq", "mode: seq, px, row, workers")
		workers = flag.Int("workers", 1, "number of workers to use")
	)
	flag.Parse()

	const output = "mandelbrot.png"

	// open a new file
	f, err := os.Create(output)
	if err != nil {
		log.Fatal(err)
	}

	// create the image
	c := make([][]color.RGBA, *height)
	for i := range c {
		c[i] = make([]color.RGBA, *width)
	}

	img := &img{
		h: *height,
		w: *width,
		m: c,
	}

	switch *mode {
	case "seq":
		seqFillImg(img)
	case "px":
		oneToOneFillImg(img)
	case "row":
		onePerRowFillImg(img)
	case "workers":
		nWorkersPerRowFillImg(img, *workers)
	default:
		panic("unknown mode")
	}

	// and encoding it
	if err := png.Encode(f, img); err != nil {
		log.Fatal(err)
	}
}

type img struct {
	h, w int
	m    [][]color.RGBA
}

func (m *img) At(x, y int) color.Color { return m.m[x][y] }
func (m *img) ColorModel() color.Model { return color.RGBAModel }
func (m *img) Bounds() image.Rectangle { return image.Rect(0, 0, m.h, m.w) }

// SEQSTART OMIT
func seqFillImg(m *img) {
	for i, row := range m.m {
		for j := range row {
			fillPixel(m, i, j)
		}
	}
}

// SEQEND OMIT

func oneToOneFillImg(m *img) {
	var wg sync.WaitGroup
	wg.Add(m.h * m.w)
	for i, row := range m.m {
		for j := range row {
			go func(i, j int) {
				fillPixel(m, i, j)
				wg.Done()
			}(i, j)
		}
	}
	wg.Wait()
}

func onePerRowFillImg(m *img) {
	var wg sync.WaitGroup
	wg.Add(m.h)
	for i := range m.m {
		go func(i int) {
			for j := range m.m[i] {
				fillPixel(m, i, j)
			}
			wg.Done()
		}(i)
	}
	wg.Wait()
}

func nWorkersFillImg(m *img, workers int) {
	c := make(chan struct{ i, j int })
	for i := 0; i < workers; i++ {
		go func() {
			for t := range c {
				fillPixel(m, t.i, t.j)
			}
		}()
	}

	for i, row := range m.m {
		for j := range row {
			c <- struct{ i, j int }{i, j}
		}
	}
	close(c)
}

func nWorkersPerRowFillImg(m *img, workers int) {
	c := make(chan int, m.h)
	var wg sync.WaitGroup
	wg.Add(workers)
	for i := 0; i < workers; i++ {
		go func() {
			for row := range c {
				for col := range m.m[row] {
					fillPixel(m, row, col)
				}
			}
			wg.Done()
		}()
	}

	for row := range m.m {
		c <- row
	}
	close(c)
	wg.Wait()
}

func fillPixel(m *img, x, y int) {
	const n = 1000
	const Limit = 2.0
	Zr, Zi, Tr, Ti := 0.0, 0.0, 0.0, 0.0
	Cr := (2*float64(x)/float64(n) - 1.5)
	Ci := (2*float64(y)/float64(n) - 1.0)

	for i := 0; i < n && (Tr+Ti <= Limit*Limit); i++ {
		Zi = 2*Zr*Zi + Ci
		Zr = Tr - Ti + Cr
		Tr = Zr * Zr
		Ti = Zi * Zi
	}
	paint(&m.m[x][y], Tr, Ti)
}

func paint(c *color.RGBA, x, y float64) {
	n := byte(x * y * 2)
	c.R, c.G, c.B, c.A = n, n, n, 255
}

Давайте запустим его и посмотрим, он немного медленный:

$ time go run mandelbrot.go 
        1.93 real         1.70 user         0.27 sys

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

func main() {
	var (
		height  = flag.Int("h", 1024, "height of the output image in pixels")
		width   = flag.Int("w", 1024, "width of the output image in pixels")
		mode    = flag.String("mode", "seq", "mode: seq, px, row, workers")
		workers = flag.Int("workers", 1, "number of workers to use")
	)
	flag.Parse()
	
	# 加入以下代码段
	var fn string
	switch *mode {
	case "seq":
		fn = "trace.seq"
	case "px":
		fn = "trace.px"
	case "row":
		fn = "trace.row"
	case "workers":
		fn = "trace.workers"
	}
	traceFile, _ := os.Create(fn)
	if err := trace.Start(traceFile); err != nil {
		log.Fatal(err)
	}
	defer trace.Stop()
	
	....

После запуска программы используйтеgo tool trace trace.seqПосмотреть в Хроме:

image.png

image.png

Советы: Shift + ? Просмотреть справку по команде, с увеличением/уменьшением масштаба.

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

В этом случае мы меняем режим работы и напрямую распараллеливаем каждый пиксель Реализация дана в программе:

func oneToOneFillImg(m *img) {
	var wg sync.WaitGroup
	wg.Add(m.h * m.w)
	for i, row := range m.m {
		for j := range row {
			go func(i, j int) {
				fillPixel(m, i, j)
				wg.Done()
			}(i, j)
		}
	}
	wg.Wait()
}

Давайте переключимся в соответствующий рабочий режим px, чтобы увидеть:

$ time go run mandelbrot.go -mode px
        2.01 real         7.26 user         2.90 sys

Ну очень странно, что программа на самом деле медленнее, используемgo tool trace trace.seqПосмотрите, что происходит.

Размер сгенерированного файла трассировки зависит от количества горутин, и выполнение этой команды будет медленнее.

image.png

Из-за большого количества горутин видно, что файл трассировки разбит на множество фрагментов, посмотрите на один из фрагментов:

image.png

На этот раз нет простаивающих процессов, но поток выполнения кажется прерывистым, что очень очевидно при увеличении:

image.png

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

Затем мы настраиваем степень детализации параллелизма и назначаем вычислительные задачи каждой строки различным горутинам, Соответствующие режимы и исходный код следующие:

time go run mandelbrot.go -mode row
        0.85 real         1.85 user         0.32 sys
func onePerRowFillImg(m *img) {
	var wg sync.WaitGroup
	wg.Add(m.h)
	for i := range m.m {
		go func(i int) {
			for j := range m.m[i] {
				fillPixel(m, i, j)
			}
			wg.Done()
		}(i)
	}
	wg.Wait()
}

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

image.png

Пиксельная вычислительная часть потока исполнения выглядит чрезвычайно удобно и полностью использует ресурсы ЦП, при этом все 12 потоков работают на полную мощность (6-ядерный ЦП с гиперпоточностью). После увеличения видно, что между горутинами почти нет промежутков, и я не могу не восхищаться тем, что Go — лучший язык в мире 😆.

image.png

Конечно, у этого режима все еще есть свои недостатки.Обратите внимание на очевидные колебания в столбце Grooutine в верхней части интерфейса трассировки.Хотя мы настраиваем степень детализации параллелизма, он все равно будет генерировать большое количество Goroutine за короткий период времени. время, а затем выполнять их последовательно. На самом деле, поскольку количество ядер ЦП фиксировано, количество потоков ОС, которые одновременно выполняют горутины параллельно, также фиксировано, поэтому мы можем повторно использовать пулы горутин для экономии ресурсов, которые обычно называют рабочими пулами.

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

func nWorkersPerRowFillImg(m *img, workers int) {
	c := make(chan int, m.h)
	var wg sync.WaitGroup
	wg.Add(workers)
	for i := 0; i < workers; i++ {
		go func() {
			for row := range c {
				for col := range m.m[row] {
					fillPixel(m, row, col)
				}
			}
			wg.Done()
		}()
	}

	for row := range m.m {
		c <- row
	}
	close(c)
	wg.Wait()
}

Давайте посмотрим на время выполнения, которое немного быстрее, чем в построчном режиме. Здесь следует отметить, что количество Workers необходимо указать вручную, что эквивалентно максимальному количеству потоков, поддерживаемых текущим оборудованием, которое можно использовать в Go.fmt.Println(runtime.NumCPU())Проверять.

time go run mandelbrot.go -mode workers -workers 12
        0.74 real         1.86 user         0.26 sys

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

image.png

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

Amdahl's law

law.svg

И ускорение, которое может дать распараллеливание, не бесконечно. Как показано на рисунке выше, закон Амдала выражает связь между количеством параллельных процессоров и повышением эффективности.Ключ к ускорению программы зависит от части, которая должна выполняться последовательно (например, Мандельброт занимает около 50% самая быстрая программа) время кодирования картинки). Когда 95% выполнения в программе можно распараллелить, даже если количество ЦП увеличить до тысяч, эффективность ускорения может быть ограничена только 20-кратным значением.

Суммировать

Это действительно отличный доклад, в котором простыми словами объясняются три способа анализа производительности в Go, что также включает в себя множество низкоуровневых знаний. Друзья, которые чувствуют себя неудовлетворенными, могут прочитать статьи автора по теме 👉Dave.Cheney.net/high-per для…