Бенчмарки в Golang: тестируем производительность кода

Честно вам признаюсь  —  бенчмаркинг (тестирование производительности) не входит в число моих самых сильных сторон, к тому же и провожу я его не так часто, как хотелось бы. Но с момента программирования на Go в качестве основного моего языка случаи его применения намного участились. А все потому, что Go предоставляет отличную встроенную поддержку для бенчмаркинга. 

Go позволяет разработчикам тестировать производительность с помощью пакета testing, содержащего для этого все необходимое.  

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

Итак, в программировании под бенчмаркингом понимают тестирование производительности написанного кода. 

Бенчмарк  —  это выполнение компьютерной программы (набора программ) или других операций для оценки относительной производительности объекта”. 

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

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

“Преждевременная оптимизация  —  это корень всех зол”.  —  Дональд Кнут, “Искусство программирования”. 

Довольно часто в сообщениях на интернет-форумах начинающие разработчики спрашивают, какое из тех или иных решений кода является наилучшим. Но я бы не стал оценивать код в категориях “лучший”.  

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

Теперь научимся проводить бенчмарк в Go. Нам предстоит ответить на следующие вопросы:  

  • Что быстрее: срезы (slice) или карты (map)?
  • Влияет ли размер на скорость срезов и карт? 
  • Имеет ли значение тип ключа в картах?

Пишем самый простой бенчмарк 

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

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

go mod init benching

Потребуется также создать файл, оканчивающийся на _test.go. В нашем случае им будет benching_test.go.

Подобно обычным модульным тестам, бенчмарки в Go выполняются с помощью пакета testing и запускаются теми же инструментами тестирования. 

Инструмент Go определяет, какие методы являются бенчмарками по их именам. Любой метод, начинающийся с Benchmark и принимающий указатель на testing.B, будет выполняться как бенчмарк. Данный фрагмент иллюстрирует пример такого минимального метода:

package benching

import (
	"testing"
)

func BenchmarkSimplest(b *testing.B) {

}

Испытаем его, выполнив команду go test с флагом -bench=.

Результат выполнения команды go test -bench=.’ говорит о том, что бенчмарк работает.

Остановимся на этом моменте подробнее и проанализируем вывод. По завершении каждый бенчмарк выводит 3 значения: имя, количество выполнений и ns/op.

Первое значение говорит само за себя  —  это имя, которое мы задаем в тестовом файле. 

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

ns/op означает число наносекунд, затраченных на операцию. Это время, которое потребовалось на вызов метода.  

Если из большого числа бенчмарков вы хотите выполнить только один или несколько, можно заменить точку на строку в соответствии с такими именами, как -bench=BenchmarkSimplest. Напоминаю, что -bench=Benchmark по-прежнему будет запускать бенчмарк, поскольку строка соответствует началу метода. 

Замена значения ‘-bench=’ нужна для указания бенчмарка, подлежащего выполнению.

Теперь мы можем измерить скорость и не только. К счастью, заглянув в пакет testing, мы обнаружим, что добавление флага -benchmem позволит получить дополнительную информацию о том, сколько было выделено байт за операцию (B/op), а также сколько раз за операцию выделялась память (allocs/op).

При добавлении флага ‘-benchmem’ получаем B/op и allocs/op

Мы уже почти готовы тестировать реальные процессы, но уделим внимание еще ряду моментов. Что происходит с входным параметром *testing.B в нашем бенчмарке? Чтобы понять, с чем мы имеем дело, ознакомимся с его определением, предложенным в стандартной библиотеке (golang/src/testing/benchmark.go).

// B - это тип, передаваемый в Benchmark-функции для управления  
// таймингом бенчмарка и указания числа его итераций

// Бенчмарк завершается, когда его Benchmark-функция делает возврат или вызывает любой из методов: 
// FailNow, Fatal, Fatalf, SkipNow, Skip или Skipf. Эти методы вызываются только горутиной, выполняющей Benchmark-функцию. 
// Другие методы отчетности, такие как разновидности Log и Error, 
// могут вызываться одновременно несколькими go-рутинами. 
//
// Как и в тестах, логи бенчмарка накапливаются в процессе выполнения 
//и по завершении выводятся в stdout. В отличии от тестов логи 
//бенчмарка выводятся всегда, демонстрируя выходные данные, которые могут
// повлиять на результаты бенчмарка. 
	common
	importPath       string // импорт пути пакета, содержащего бенчмарк 
	context          *benchContext
	N                int
	previousN        int           // число итераций в предыдущем выполнении 
	previousDuration time.Duration // общая продолжительность предыдущего выполнения 
	benchFunc        func(b *B)
	benchTime        benchTimeFlag
	bytes            int64
	missingBytes     bool // в одном из суб-бенчмарков не установлены байты. 
	timerOn          bool
	showAllocResult  bool
	result           BenchmarkResult
	parallelism      int // RunParallel создает параллельные go-рутины GOMAXPROCS
	// Исходные состояния memStats.Mallocs и memStats.TotalAlloc.
	startAllocs uint64
	startBytes  uint64
	// Чистый итог этого теста после выполнения. 
	netAllocs uint64
	netBytes  uint64
	// Дополнительные показатели, полученные ReportMetric
	extra map[string]float64
}

Testing.B содержит любые данные, относящиеся к выполняемому бенчмарку, а также структуру BenchmarkResult для форматирования вывода. Если какая-то часть вывода вам непонятна, то настоятельно рекомендую открыть benchmark.go и прочитать код. 

Особое внимание заслуживает переменная N. Как вы помните, бенчмарки выполняются по многу раз. Так вот, переменная N в testing.B как раз и указывает на количество выполнений. 

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

// BenchmarkSimplestNTimes выполняет бенчмарк N раз. 
func BenchmarkSimplestNTimes(b *testing.B) {
	for i := 0; i < b.N; i++ {
		// Здесь выполняем функцию для тестирования 
	}
}

Мы обновили функцию, создав цикл for, который перебирает N раз. При проведении тестирования я устанавливаю конкретные значения N, обеспечивая адекватность бенчмарков. В противном случае один из них выполнится 100 000 раз, а другой  —  дважды. 

Для этого можно добавить флаг -benchtime=. Входными данными являются либо секунды, либо X раз, поэтому для стократного выполнения бенчмарка устанавливаем его на -benchtime=100x.

Протестируем новый метод 100 раз 

На старт, внимание, бенчмарк! 

Приступаем к тестированию и ответам на ранее заданные вопросы о производительности. 

  • Что быстрее: срезы или карты?
  • Влияет ли размер на скорость срезов и карт?
  • Имеет ли значение тип ключа в картах?

Начнем реализацию одного бенчмарка для оценки записи данных в карты и срезы, а другого  —  для оценки их обратного считывания. У разработчика Дейва Чини я позаимствовал один прием  —  создание метода, принимающего входные параметры, которые мы хотим протестировать. С его помощью можно запросто проверять много различных значений. 

// insertXIntMap применяется для добавления Х элементов в Map[int]int 
func insertXIntMap(x int, b *testing.B) {
	// Инициализируем Map и вставляем X элементов 
	testmap := make(map[int]int, 0)
	// Сбрасываем таймер после инициализации Map
	b.ResetTimer()
	for i := 0; i < x; i++ {
		// Вставляем значение I в ключ I. 
		testmap[i] = i
	}
}

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

Ниже представлены методы бенчмарков, которые выполняются N раз и записывают X элементов в карту.

// BenchmarkInsertIntMap100000 тестирует скорость вставки 100000 целых чисел в карту. 
func BenchmarkInsertIntMap100000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXIntMap(100000, b)
	}
}

// BenchmarkInsertIntMap10000 тестирует скорость вставки 10000 целых чисел в карту. 
func BenchmarkInsertIntMap10000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXIntMap(10000, b)
	}
}

// BenchmarkInsertIntMap1000 тестирует скорость вставки 1000 целых чисел в карту.
func BenchmarkInsertIntMap1000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXIntMap(1000, b)
	}
}

// BenchmarkInsertIntMap100 тестирует скорость вставки 100 целых чисел в карту.
func BenchmarkInsertIntMap100(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXIntMap(100, b)
	}
}

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

Результат бенчмарка записи элементов в карту

Количество затраченного времени растет, что закономерно в связи с увеличением количества вставок. Пока эти результаты особо ни о чем не говорят, поскольку их не с чем сравнивать. Однако можно выделить время для ответа на один из вопросов: “Имеет ли значение тип ключа в картах?”.

Скопируем все методы и заменим используемый тип ключа интерфейсом. В этот раз у нас будет всего 2 файла: benching_map_interface_test.go и benching_map_int_test.go. Методы бенчмарков будут соответствовать имени для поддержания легко управляемой структуры при добавлении дополнительных бенчмарков.

Ниже следует пример тестирования записи в карту с интерфейсом в качестве ключа:

package benching

import (
	"testing"
)

// insertXInterfaceMap - для добавления X элементов в Map[interface]int 
func insertXInterfaceMap(x int, b *testing.B) {
	// Инициализируем Map и вставляем X элементов 
	testmap := make(map[interface{}]int, 0)
	// Сбрасываем таймер после инициализации Map
	b.ResetTimer()
	for i := 0; i < x; i++ {
		// Вставляем значение I в ключ I. 
		testmap[i] = i
	}
}

// BenchmarkInsertInterfaceMap1000000 тестирует скорость вставки 1000000 целых чисел в карту. 
func BenchmarkInsertInterfaceMap1000000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXInterfaceMap(1000000, b)
	}
}

// BenchmarkInsertInterfaceMap100000 тестирует скорость вставки 100000 целых чисел в карту. 
func BenchmarkInsertInterfaceMap100000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXInterfaceMap(100000, b)
	}
}

// BenchmarkInsertInterfaceMap10000 тестирует скорость вставки 10000 целых чисел в карту.
func BenchmarkInsertInterfaceMap10000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXInterfaceMap(10000, b)
	}
}

// BenchmarkInsertInterfaceMap1000 тестирует скорость вставки 1000 целых чисел в карту. 
func BenchmarkInsertInterfaceMap1000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXInterfaceMap(1000, b)
	}
}

// BenchmarkInsertInterfaceMap100 тестирует скорость вставки 100 целых чисел в карту.
func BenchmarkInsertInterfaceMap100(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXInterfaceMap(100, b)
	}
}
Результаты бенчмарка показывают, что тип ключа на самом деле имеет значение 

Ответ на один из вопросов получен. Судя по результатам, тип ключа немаловажен. В этом тесте Int в качестве ключа вместо Interface в 2.23 раза быстрее, учитывая показатели бенчмарка 1000000. Хотя я не припомню, чтобы интерфейс когда-либо применялся в качестве ключа.

  • Что быстрее: срезы или карты?
  • Влияет ли размер на скорость срезов и карт?
  • Имеет ли значение тип ключа в картах? Да, имеет

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

Но прежде нам предстоит изменить метод insertXIntMap, а также инициализацию карты на использование длины X. Был создан новый файл benching_map_prealloc_int_test.go, в котором я изменил метод insertXIntMap для предварительной инициализации размера. ‘make(map[int]int, 0)’ был заменен на ‘make(map[int]int,x)’. 

// insertXPreallocIntMap - для добавления X элементов в Map[int]int 
func insertXPreallocIntMap(x int, b *testing.B) {
	// Инициализируем Map, вставляем X элементов и заранее предустанавливаем размер X 
	testmap := make(map[int]int, x)
	// Сбрасываем таймер после инициализации Map
	b.ResetTimer()
	for i := 0; i < x; i++ {
		// Вставляем значение I в ключ I. 
		testmap[i] = i
	}
}

Напоминаю, что мы можем управлять выполнением тех или иных бенчмарков с помощью флага -bench=. Теперь самое время вспомнить этот прием, учитывая их немалое количество. Но задача именно этого бенчмарка  —  сравнить производительность карт с предустановленным размером и без него. 

Новые бенчмарки я назвал BenchmarkInsertIntMapPrealloc, чтобы их имена совпадали с BenchmarkInsertIntMap, что можно будет использовать в качестве триггера. Этот новый файл является точной копией другого бенчмарка IntMap  —  изменились лишь имена и метод, подлежащий выполнению. 

package benching

import (
	"testing"
)

// insertXPreallocIntMap - для добавления X элементов в Map[int]int 
func insertXPreallocIntMap(x int, b *testing.B) {
	// Инициализируем Map, вставляем X элементов и предустанавливаем размер X. 
	testmap := make(map[int]int, x)
	// Сбрасываем таймер после инициализации Map. 
	b.ResetTimer()
	for i := 0; i < x; i++ {
		// Insert value of I into I key.
		testmap[i] = i
	}
}

// BenchmarkInsertIntMapPreAlloc1000000 тестирует скорость вставки 1000000 целых чисел в карту. 
func BenchmarkInsertIntMapPreAlloc1000000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXPreallocIntMap(1000000, b)
	}
}

// BenchmarkInsertIntMapPreAlloc100000 тестирует скорость вставки 100000 целых чисел в карту.
func BenchmarkInsertIntMapPreAlloc100000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXPreallocIntMap(100000, b)
	}
}

// BenchmarkInsertIntMapPreAlloc10000 тестирует скорость вставки 10000 целых чисел в карту.
func BenchmarkInsertIntMapPreAlloc10000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXPreallocIntMap(10000, b)
	}
}

// BenchmarkInsertIntMapPreAlloc1000 тестирует скорость вставки 1000 целых чисел в карту.
func BenchmarkInsertIntMapPreAlloc1000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXPreallocIntMap(1000, b)
	}
}

// BenchmarkInsertIntMapPreAlloc100 тестирует скорость вставки 100 целых чисел в карту.
func BenchmarkInsertIntMapPreAlloc100(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXPreallocIntMap(100, b)
	}
}

Выполняем бенчмарк и меняем флаг -bench=

go test -bench=BenchmarkInsertIntMap -benchmem -benchtime=100x
Отличия между предустановленным и непредустановленным размером в карте 

Согласно результатам бенчмарка, предустановка размера карты играет значимую роль  —  достаточно лишь увидеть, что производительность теста 1000000 в 1.92 раза больше, а показатели B/op еще лучше. 

Далее перейдем к реализации бенчмарка записи в срезы. Ее принцип аналогичен реализации карты с той лишь разницей, что срез используется с append.

Опять-таки ради интереса напишем бенчмарки для предустановленных и непредустановленных размеров срезов. Заново создаем метод insertX, все копируем и затем заменяем Map на Slice. Итак, записываем в срез X элементов для тестирования вставки.

package benching

import (
	"testing"
)

// insertXPreallocIntMap - для добавления X элементов в Map[int]int 
func insertXPreallocIntMap(x int, b *testing.B) {
	// Инициализируем Map, вставляем X элементов и предустанавливаем размер X. 
	testmap := make(map[int]int, x)
	// Сбрасываем таймер после инициализации Map. 
	b.ResetTimer()
	for i := 0; i < x; i++ {
		// Insert value of I into I key.
		testmap[i] = i
	}
}

// BenchmarkInsertIntMapPreAlloc1000000 тестирует скорость вставки 1000000 целых чисел в карту. 
func BenchmarkInsertIntMapPreAlloc1000000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXPreallocIntMap(1000000, b)
	}
}

// BenchmarkInsertIntMapPreAlloc100000 тестирует скорость вставки 100000 целых чисел в карту.
func BenchmarkInsertIntMapPreAlloc100000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXPreallocIntMap(100000, b)
	}
}

// BenchmarkInsertIntMapPreAlloc10000 тестирует скорость вставки 10000 целых чисел в карту.
func BenchmarkInsertIntMapPreAlloc10000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXPreallocIntMap(10000, b)
	}
}

// BenchmarkInsertIntMapPreAlloc1000 тестирует скорость вставки 1000 целых чисел в карту.
func BenchmarkInsertIntMapPreAlloc1000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXPreallocIntMap(1000, b)
	}
}

// BenchmarkInsertIntMapPreAlloc100 тестирует скорость вставки 100 целых чисел в карту.
func BenchmarkInsertIntMapPreAlloc100(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXPreallocIntMap(100, b)
	}
}

Бенчмарк добавления срезов: 

package benching

import (
	"testing"
)

// insertXIntSlice - для добавления Xэлементов в []int 
func insertXIntSlice(x int, b *testing.B) {
	// Инициализация Slice и вставка X элементов 
	testSlice := make([]int, 0)
	// Сброс таймера 
	b.ResetTimer()
	for i := 0; i < x; i++ {
		testSlice = append(testSlice, i)
	}
}

// BenchmarkInsertIntSlice1000000 тестирует скорость вставки 1000000 целых чисел в срез. 
func BenchmarkInsertIntSlice1000000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXIntSlice(1000000, b)
	}
}

// BenchmarkInsertIntSlice100000 тестирует скорость вставки 100000 целых чисел в срез. 
func BenchmarkInsertIntSlice100000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXIntSlice(100000, b)
	}
}

// BenchmarkInsertIntSlice10000 тестирует скорость вставки 10000 целых чисел в срез. 
func BenchmarkInsertIntSlice10000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXIntSlice(10000, b)
	}
}

// BenchmarkInsertIntSlice1000 тестирует скорость вставки 1000 целых чисел в срез.
func BenchmarkInsertIntSlice1000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXIntSlice(1000, b)
	}
}

// BenchmarkInsertIntSlice100 тестирует скорость вставки 100 целых чисел в срез. 
func BenchmarkInsertIntSlice100(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXIntSlice(100, b)
	}
}

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

// insertXPreallocIntSlice - для добавления X элементов в []int 
func insertXPreallocIntSlice(x int, b *testing.B) {
	// Инициализация Slice и вставка X элементов 
	testSlice := make([]int, x)
	// Сброс таймера 
	b.ResetTimer()
	for i := 0; i < x; i++ {
		testSlice[i] = i
	}
}

Полный вариант кода для тестирования среза в предварительно выделенной памяти:

package benching

import (
	"testing"
)

// insertXPreallocIntSlice - для добавления X элементов в []int 
func insertXPreallocIntSlice(x int, b *testing.B) {
	// Инициализация Slice и вставка X элементов 
	testSlice := make([]int, x)
	// сброс таймера 
	b.ResetTimer()
	for i := 0; i < x; i++ {
		testSlice[i] = i
	}
}

// BenchmarkInsertIntSlicePreAllooc1000000 тестирует скорость вставки 1000000 целых чисел в срез.
func BenchmarkInsertIntSlicePreAllooc1000000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXPreallocIntSlice(1000000, b)
	}
}

// BenchmarkInsertIntSlicePreAllooc100000 тестирует скорость вставки 100000 целых чисел в срез.
func BenchmarkInsertIntSlicePreAllooc100000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXPreallocIntSlice(100000, b)
	}
}

// BenchmarkInsertIntSlicePreAllooc10000 тестирует скорость вставки 10000 целых чисел в срез.
func BenchmarkInsertIntSlicePreAllooc10000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXPreallocIntSlice(10000, b)
	}
}

// BenchmarkInsertIntSlicePreAllooc1000 тестирует скорость вставки 1000 целых чисел в срез.
func BenchmarkInsertIntSlicePreAllooc1000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXPreallocIntSlice(1000, b)
	}
}

// BenchmarkInsertIntSlicePreAllooc100 тестирует скорость вставки 100 целых чисел в срез.
func BenchmarkInsertIntSlicePreAllooc100(b *testing.B) {
	for i := 0; i < b.N; i++ {
		insertXPreallocIntSlice(100, b)
	}
}

Бенчмарки срезов готовы, пора их выполнить и посмотреть результаты: 

Результаты бенчмарка срезов 

Разница между предустановленными и динамическими срезами просто огромная. Показатели бенчмарка 1000000 составляют соответственно 7246 ns/op против 75388 ns/op, т. е. скорость выполнения операции в первом случае в 10.4 раз больше, чем во втором. Однако в некоторых ситуациях работать со срезами фиксированного размера бывает проблематично. Что касается моих приложений, то обычно я не знаю размер срезов, поскольку они, как правило, динамические.  

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

Для этого инициализируем срез и карту по аналогии с предыдущими вариантами, добавим X элементов и сбросим таймер. Затем протестируем, как быстро мы сможем найти X элементов. Я перебираю срез и карту со значением индекса i. Ниже представлен код обоих бенчмарков, поскольку они почти идентичны. 

Бенчмарк выборки из карты: 

package benching

import (
	"testing"
)

// selectXIntMap применяется для выборки X элементов из карты 
func selectXIntMap(x int, b *testing.B) {
	// Инициализация Map и вставка X элементов 
	testmap := make(map[int]int, x)
	// Сброс таймера после инициализации Map 
	for i := 0; i < x; i++ {
		// Вставка значения I в ключ I. 
		testmap[i] = i
	}
	// holder нужен для хранения найденногоint, нельзя извлекать из карты значение без сохранения результата
	var holder int
	b.ResetTimer()
	for i := 0; i < x; i++ {
		// Выборка из карты 
		holder = testmap[i]
	}
	// Компилятор не оставит без внимания неиспользованный holder, но с помощью быстрой проверки мы его обхитрим. 
	if holder != 0 {

	}
}

// BenchmarkSelectIntMap1000000 тестирует скорость выборки 1000000 элементов из карты. 
func BenchmarkSelectIntMap1000000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		selectXIntMap(1000000, b)
	}
}

// BenchmarkSelectIntMap100000 тестирует скорость выборки 100000 элементов из карты.
func BenchmarkSelectIntMap100000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		selectXIntMap(100000, b)
	}
}

// BenchmarkSelectIntMap10000 тестирует скорость выборки 10000 элементов из карты.
func BenchmarkSelectIntMap10000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		selectXIntMap(10000, b)
	}
}

// BenchmarkSelectIntMap1000 тестирует скорость выборки 1000 элементов из карты.
func BenchmarkSelectIntMap1000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		selectXIntMap(1000, b)
	}
}

// BenchmarkSelectIntMap100 тестирует скорость выборки 100 элементов из карты.
func BenchmarkSelectIntMap100(b *testing.B) {
	for i := 0; i < b.N; i++ {
		selectXIntMap(100, b)
	}
}

Бенчмарк выборки из среза: 

package benching

import (
	"testing"
)

// selectXIntSlice - для выборки X элементов из среза 
func selectXIntSlice(x int, b *testing.B) {
	// Инициализация Slice и вставка X элементов
	testSlice := make([]int, x)
	// Сброс таймера после инициализации Slice 
	for i := 0; i < x; i++ {
		// Вставка значения I в ключ I. 
		testSlice[i] = i
	}
	// holder нужен для хранения найденного int, так как нельзя извлекать значение из среза без сохранения результата
	var holder int
	b.ResetTimer()
	for i := 0; i < x; i++ {
		// Select from Slice
		holder = testSlice[i]
	}
	// Компилятор не оставит без внимания неиспользованный holder, но с помощью быстрой проверки мы его обхитрим. 
	if holder != 0 {

	}
}

// BenchmarkSelectIntSlice1000000 тестирует скорость выборки 1000000 элементов из среза. 
func BenchmarkSelectIntSlice1000000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		selectXIntSlice(1000000, b)
	}
}

// BenchmarkSelectIntSlice100000 тестирует скорость выборки 100000 элементов из среза.
func BenchmarkSelectIntSlice100000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		selectXIntSlice(100000, b)
	}
}

// BenchmarkSelectIntSlice10000 тестирует скорость выборки 10000 элементов из среза. 
func BenchmarkSelectIntSlice10000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		selectXIntSlice(10000, b)
	}
}

// BenchmarkSelectIntSlice1000 тестирует скорость выборки 1000 элементов из среза. 
func BenchmarkSelectIntSlice1000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		selectXIntSlice(1000, b)
	}
}

// BenchmarkSelectIntSlice100 тестирует скорость выборки 100 элементов из среза. 
func BenchmarkSelectIntSlice100(b *testing.B) {
	for i := 0; i < b.N; i++ {
		selectXIntSlice(100, b)
	}
}
Сравнение результатов бенчмарка карт и срезов 

Как вы заметили, код для selectXIntSlice и selectXIntMap один и тот же, единственное отличие  —  команда make. Про разницу в производительности и говорить нечего  —  все очевидно. 

Сравним результаты бенчмарка 

Итак, теперь у нас есть результаты тестирования. Для более простого сравнительного анализа оформим в таблицу показатели всех бенчмарков с 1000000 элементов. 

Benchmark,Type,ns/op,B/op,allocs/op,Fixed-size
Insert,Map[interface]int,3730206,1318557,10380,no
Insert,Map[int]int,1632196,879007,381,no
Insert,Map[int]int,857588,1569,0,yes
Insert,[]int,75388,451883,0,no
Insert,[]int,7246,0,0,yes
Select,Map[int]int,507843,0,0,yes
Select,[]int,2866,0,0,yes

Насколько сильно отличаются результаты срезов и карт? 

Срезы быстрее в 21.65 раз (1321196/75388), если сравнивать производительность записи в их динамическую форму.  

Срезы быстрее в 118.35 раз (857588/7246 ) при сравнении производительности записи в их форму с предустановленным размером. 

Срезы быстрее в 117.19 раз (507843/2866) при сравнении производительности считывания. 

Что быстрее: срезы или карты? 

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

Но зато карты проще в использовании. В данных бенчмарках предполагалось, что индексы в срезах известны. Однако есть много случаев, когда мы их не знаем, так что приходится перебирать весь срез, например map[userID]User вместо цикла for для []User.

Влияет ли размер на скорость срезов и карт? 

Размер в этих случаях не имеет значения. 

Имеет ли значение тип ключа в картах? 

Имеет. Применение целого числа по сравнению с интерфейсом оказалось в 2.23 раза быстрее. 

Рассмотрим более реалистичный случай 

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

Создадим бенчмарк как раз для такого случая. У нас есть map[userID]User и []User. Тест нацелен на сравнение скоростных показателей нахождения конкретного пользователя в картах и срезах. 

Я создал новый файл, содержащий код для генерации случайных пользователей, количество которых в срезе и карте составило 10 000, 100 000 и 1 миллион. Допустим, что у нас есть API, и нам был отправлен ID пользователя, которого нужно найти. Далее следует сценарий, подлежащий тестированию. Я также перемешиваю срез для имитации реальной ситуацией, когда данные добавляются динамически. 

Этот бенчмарк я назвал “Спасти рядового Райана”. Вот его мы и будем искать по ID пользователя 7777. Ниже представлен бенчмарк более реалистичного случая использования срезов и карт: 

package benching

import (
	"math/rand"
	"testing"
	"time"
)

// Создаем срезы и карты для бенчмарка, чтобы не заниматься этим при каждой итерации 
var userSlice10000 []User
var userSlice100000 []User
var userSlice1000000 []User

var userMap10000 map[int]User
var userMap100000 map[int]User
var userMap1000000 map[int]User

// Имена Применяем списки неслучайных имен для генерации случайных пользователей (Random Users) 
var Names []string = []string{"Jasper", "Johan", "Edward", "Niel", "Percy", "Adam", "Grape", "Sam", "Redis", "Jennifer", "Jessica", "Angelica", "Amber", "Watch"}

// Фамилии Применяем списки неслучайных имен для генерации случайных пользователей 
var SirNames []string = []string{"Ericsson", "Redisson", "Edisson", "Tesla", "Bolmer", "Andersson", "Sword", "Fish", "Coder"}

var IDCounter int

type User struct {
	ID   int
	Name string
}

func init() {
	userMap10000 = generateMapUsers(10000)
	userMap100000 = generateMapUsers(100000)
	userMap1000000 = generateMapUsers(1000000)

	userSlice10000 = generateSliceUsers(10000)
	userSlice100000 = generateSliceUsers(100000)
	userSlice1000000 = generateSliceUsers(1000000)

	// Перемешиваем срез 
	rand.Shuffle(len(userSlice10000), func(i, j int) {
		userSlice10000[i], userSlice10000[j] = userSlice10000[j], userSlice10000[i]
	})
	rand.Shuffle(len(userSlice100000), func(i, j int) {
		userSlice100000[i], userSlice100000[j] = userSlice100000[j], userSlice100000[i]
	})
	rand.Shuffle(len(userSlice1000000), func(i, j int) {
		userSlice1000000[i], userSlice1000000[j] = userSlice1000000[j], userSlice1000000[i]
	})
}

// generateSliceUsers генерирует X пользователей в срезе
func generateSliceUsers(x int) []User {
	IDCounter = 0
	users := make([]User, x)
	for i := 0; i <= x; i++ {
		users = append(users, generateRandomUser())
	}
	return users
}

// generateMapUsers генерирует X пользователей в карте 
func generateMapUsers(x int) map[int]User {
	IDCounter = 0
	users := make(map[int]User, x)
	for i := 0; i <= x; i++ {
		users[i] = generateRandomUser()
	}
	return users
}

// Генерируем RandomUser 
func generateRandomUser() User {
	rand.Seed(time.Now().UnixNano())
	nameMax := len(Names)
	sirNameMax := len(SirNames)

	nameIndex := rand.Intn(nameMax-1) + 1
	sirNameIndex := rand.Intn(sirNameMax-1) + 1
	id := IDCounter
	IDCounter++
	return User{
		ID:   id,
		Name: Names[nameIndex] + " " + SirNames[sirNameIndex],
	}
}

// findUserInSlice ищет userID в срезе пользователей 
func findUserInSlice(userID int, users []User) {
	// Теперь ищем предоставленный userID 
	for _, user := range users {
		if user.ID == userID {
			return
		}
	}

}

// findUserInMap ищет конкретный ID в карте пользователей 
func findUserInMap(userID int, users map[int]User) {
	if _, ok := users[userID]; ok {
		return
	}
}

// BenchmarkFindUserInSlice1000000 перебирает срез размером в 1000000 для поиска ID пользователя
func BenchmarkFindUserInSlice1000000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		findUserInSlice(7777, userSlice1000000)
	}
}
  
// BenchmarkFindUserInSlice100000 перебирает срез размером в 100000 для поиска ID пользователя 
func BenchmarkFindUserInSlice100000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		findUserInSlice(7777, userSlice100000)
	}
}

// BenchmarkFindUserInSlice10000 перебирает срез размером в 10000 для поиска ID пользователя 
func BenchmarkFindUserInSlice10000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		findUserInSlice(7777, userSlice10000)
	}
}

// BenchmarkFindUserInMap1000000 перебирает карту размером в 1000000 для поиска ID пользователя 
func BenchmarkFindUserInMap1000000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		findUserInMap(7777, userMap1000000)
	}
}

// BenchmarkFindUserInMap100000 перебирает карту размером в 100000 для поиска ID пользователя 
func BenchmarkFindUserInMap100000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		findUserInMap(7777, userMap100000)
	}
}

// BenchmarkFindUserInMap10000 перебирает карту размером в 10000 для поиска ID пользователя 
func BenchmarkFindUserInMap10000(b *testing.B) {
	for i := 0; i < b.N; i++ {
		findUserInMap(7777, userMap100000)
	}
}
Сравнение результатов тестирования по поиску пользователя в карте и срезе 

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

Type,Users,ns/op
Slice,10000,1763
Slice,100000,72760
Slice,100000,140917
Map,10000,21.8
Map,100000,19.5
Map,100000,21.1

На этот раз производительность карты превосходит срез в 6 678.53 раза. 

Заключение 

Что быстрее: срезы или карты? 

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

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

Влияет ли размер на скорость срезов и карт? 

Судя по результатам теста  —  влияет. Если нужный номер индекса известен, то, конечно, размер не важен. Но если вы не знаете, под каким индексом хранится значение, то размер играет важную роль. 

Имеет ли значение тип ключа в картах? 

Да. Работа с целым числом в 2.23 раза быстрее, чем с интерфейсом. 

Ответы на все вопросы получены, и я надеюсь, что вы узнали что-то для себя полезное о бенчмарках. С полным вариантом кода можно ознакомиться здесь.  

Не забудьте выйти в мир и протестировать его производительность. 

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Percy Bolmér: We Measure the Power of Cars, Computers, and Cellphones. But What About Code?

Предыдущая статья3 совета, как стать мастером Йода по JavaScript
Следующая статьяКак предварительно обработать данные и текстовые сообщения из социальных сетей