Глубокое погружение в бенчмаркинг на Голанге
Я не собираюсь лгать - сравнительный анализ не одна из моих самых сильных сторон. Я делаю это не так часто, как хотелось бы. Но это стало более частым с тех пор, как я начал использовать Go в качестве основного языка. Одна из причин этого заключается в том, что Go имеет отличную встроенную поддержку для тестирования производительности.
Go позволяет разработчикам тестировать тесты с помощью пакета тестирования. Таким образом, пакет тестирования включает в себя возможности тестирования. Это потрясающе!
В этой статье я хочу подробнее остановиться на тестах, но я начну с нуля. Я надеюсь, что после прочтения вы немного лучше разобрались в тестах производительности.
Давайте поговорим о сравнительном анализе. Бенчмаркинг в разработке программного обеспечения - это проверка производительности написанного нами кода.
Тест - это запуск« компьютерной программы , набора программ или других операций для оценки относительной производительности объекта». - Википедия
Бенчмаркинг позволяет нам выбирать разные решения и проверять их производительность, сравнивая измеренные скорости. Это отличные знания, которыми должен обладать разработчик, особенно когда у вас есть приложение, которое нужно ускорить и оптимизировать.
При разработке важно помнить золотое правило: никогда не оптимизируйте преждевременно. Тот факт, что мы научимся проводить тесты, не означает, что я предлагаю запускать и тестировать каждый фрагмент кода, который у вас есть. Я твердо уверен, что тестирование - это инструмент, который можно использовать, когда вы сталкиваетесь с проблемами производительности или когда вас убивает чистое любопытство.
«Преждевременная оптимизация - корень всех зол».
- Дональд Э. Кнут, «Искусство компьютерного программирования»
Нередко в Интернете можно увидеть сообщения от младших разработчиков о различных решениях кода, в которых спрашивают, какое из них лучше. Но если говорить о коде, то это лучшее - это то, чего я предпочитаю не делать.
Давайте придерживаться выражения наиболее производительный, поскольку иногда более медленный код легче поддерживать и читать. Таким образом, этот код лучше, если вы спросите меня, если, конечно, вы не сталкиваетесь с проблемами производительности.
Давайте начнем изучать, как проводить сравнительный анализ с помощью Go. Я собрал несколько вопросов от младшего разработчика, на которые я не мог ответить, связанных с производительностью.
Мы для него посмотрим на них.
- Срезы или карты быстрее?
- Влияет ли размер на скорость срезов и карт?
- Имеет ли значение тип ключа, используемый на картах?
Написание сверхпростого теста
Прежде чем решать вопросы, я начну с создания простого теста производительности и демонстрации того, как он выполняется с помощью Go. Когда мы узнаем, как это сделать, давайте доработаем его, чтобы найти необходимые ответы.
Я создал новый проект для этих тестов и рекомендую вам сделать то же самое, чтобы вы могли попробовать сами. Вам нужно будет создать каталог и запустить:
go mod init benching
Вам также необходимо создать файл, заканчивающийся на _test.go
. В моем случае это benching_test.go
.
Тесты в Go выполняются с помощью пакета тестирования, как и обычные модульные тесты. Как и модульные тесты, тесты запускаются с помощью того же инструментария Go Test.
Инструмент Go узнает, какие методы являются эталонными, на основе их названий. Любой метод, начинающийся с Benchmark
, который принимает указатель на testing.B
, будет работать в качестве теста производительности.
Попробуйте это, запустив команду go test
с флагом -bench=.
.
Давайте ненадолго остановимся и поразмыслим над результатом. Каждый выполненный тест выдаст три значения: имя, количество запусков теста и ns/op
.
Название говорит само за себя. Это имя мы установили в тестовом файле.
Интересно количество запусков теста. Каждый тест выполняется несколько раз, и каждое выполнение рассчитывается по времени. Затем время выполнения усредняется в зависимости от количества запусков. Это хорошо, поскольку однократный запуск теста обеспечит плохую статистическую корректность.
ns/op
означает наносекунды на операцию. Это время, которое потребовалось для вызова метода.
Если у вас есть несколько тестов и вы хотите запустить только один или несколько, вы можете заменить точку на строку, совпадающую с именами типа -bench=BenchmarkSimplest
. Помните, что выражение -bench=Benchmark
по-прежнему будет запускать наш тест, поскольку строка соответствует началу метода.
Итак, прямо сейчас мы можем измерить скорость, но это не всегда все, что мы хотим измерить. К счастью, если мы заглянем в пакет тестирования, мы обнаружим, что добавление флага -benchmem
добавит информацию о байтах, выделенных на операцию (B / op) и выделенных на операцию (allocs / op).
Если вы не знакомы с распределением памяти и памятью, я могу порекомендовать статью Винсента Бланшона, найденную здесь.
Скоро мы будем готовы приступить к сравнительному анализу реальных вещей - просто потерпите еще несколько минут. Что случилось с входным параметром в нашем тесте *testing.B
? Давайте посмотрим на определение этого слова в стандартной библиотеке, чтобы узнать, с чем мы имеем дело.
Testing.B
- это структура, содержащая любые данные, относящиеся к работающему тесту. Он также содержит структуру с именем BenchmarkResult
, которая используется для форматирования вывода. Если вы что-то не понимаете, я настоятельно рекомендую открыть benchmark.go
и прочитать код.
Обратите внимание на одну важную вещь - переменную N
. Помните, как тесты выполняются много раз? Сколько раз выполняются тесты, указывается переменной N
внутри testing.B
.
Согласно документации, это необходимо учитывать в тестах, поэтому давайте обновим BenchmarkSimplest
, чтобы учесть N
.
Мы обновили его, создав for
цикл, который будет повторяться N
раз. Когда я тестирую тесты, мне нравится устанавливать N
на определенные значения, поэтому я удостоверяюсь, что мои тесты справедливы. В противном случае один тест может выполняться 100 000 раз, а другой - дважды.
Это можно сделать, добавив флаг -benchtime=
. Вводится либо секунды, либо X раз, поэтому для принудительного выполнения тестов 100 раз мы можем установить значение -benchtime=100x
.
Готово, готово, эталон!
Пришло время начать тестирование и ответить на поставленные ранее вопросы о производительности.
- Срезы или карты быстрее?
- Влияет ли размер на скорость срезов и карт?
- Имеет ли значение используемый ключ?
Я начну использовать тест для вставки данных в карты и фрагменты, а затем еще один тест для чтения данных. Уловка, которую я позаимствовал у Дэйва Чейни, заключается в том, чтобы создать метод, который принимает входные параметры, которые мы хотим протестировать - это очень упрощает тестирование множества различных значений.
Этот метод принимает целочисленное значение того, сколько целых чисел нужно вставить в карту. Это сделано для того, чтобы мы могли проверить, влияет ли размер карты на производительность вставки. Этот метод будет выполняться нашими тестами. Я также создам несколько тестовых функций, каждая из которых вставляет разные целые числа для тестирования.
Посмотрите, как я повторно использую один и тот же метод в каждом тесте, но просто изменяю количество вставок? Это изящный трюк, поскольку мы можем легко тестировать как большие, так и маленькие суммы.
Таким образом, количество времени увеличивается - это вполне ожидаемо, поскольку мы увеличиваем количество вставок. Это пока мало что говорит нам, так как нам нужно с чем сравнивать результаты. Однако мы можем найти время, чтобы ответить на один вопрос: ли имеет значение тип ключа, используемый в картах?
Я собираюсь скопировать все методы и заменить используемый тип ключа на интерфейс. Чтобы упростить задачу, у меня теперь есть два файла: benching_map_interface_test.go
и benching_map_int_test.go
. Методы тестирования будут соотноситься с названием - это просто для того, чтобы поддерживать удобную для навигации структуру, когда мы добавляем дополнительные тесты.
Думаю, мы пока нашли ответ как минимум на один вопрос. Тип ключа, похоже, имеет значение, как мы можем видеть по результату. Использование Int вместо Interface
в качестве ключа в этом тесте в 2,23 раза быстрее, с учетом теста 1000000
. Однако я никогда раньше не видел, чтобы интерфейс использовался в качестве ключа.
Удвоение производительности на основе ключей, похоже, соответствует заключению, сделанному мной Джейсоном Мойроном. Он написал Go Performance Tales, что очень читается.
- Срезы или карты быстрее?
- Влияет ли размер на скорость срезов и карт?
- D̸o̸e̸s̸ ̸t̸h̸e̸ ̸ke̸y̸ ̸t̸y̸p̸e̸ ̸u̸s̸e̸d̸ ̸i̸n̸ ̸m̸a̸p̸s̸ ̸m̸a̸t̸t̸e̸r̸? Да.
Прежде чем мы продолжим, я хотел бы воспользоваться моментом и добавить новый тест, так как это весело. В тестах, которые мы только что запустили, размер карт не был заранее выделен. Так что мы можем изменить это и измерить разницу.
Что нам нужно изменить, так это метод insertXIntMap
, и мы также должны изменить инициализацию карты, чтобы вместо этого использовать длину X. Я создал один новый файл, benching_map_prealloc_int_test.go
, и в нем изменил метод insertXIntMap
, чтобы заранее инициализировать размер.
Помните, как я сказал, что мы можем контролировать, какие тесты производительности запускать, используя флаг -bench=
? Пришло время использовать этот трюк, потому что теперь у нас есть много тестов. Но для этого конкретного теста меня интересует только сравнение карты без заданного размера и карты с заранее заданным размером.
Я назвал свои новые тесты BenchmarkInsertIntMapPrealloc
, поэтому они имеют то же имя, что и BenchmarkInsertIntMap
. Мы можем использовать это в качестве триггера. Этот новый файл эталонного теста является точной копией другого IntMap
эталонного теста - я изменил только названия и метод выполнения.
Давайте запустим тест и изменим флаг теста.
go test -bench=BenchmarkInsertIntMap -benchmem -benchtime=100x
Этот тест показывает нам, что установка размера карты имеет довольно большое влияние. На самом деле это когда вы видите, что тест 1000000
имеет разницу в производительности в 1,92 раза. И посмотреть на выделенные байты (B / op) - намного приятнее.
Перейдем к реализации теста на вставку срезов. Это будет копия реализации карты, но на этот раз с использованием фрагмента с append
.
Я также собираюсь создать тесты для предварительно выделенных срезов и тесты для нераспределенных размеров, так как это было очень весело. Мы можем просто воссоздать метод insertX
, скопировать и вставить все, а затем найти Map
и заменить его на Slice
.
Для предварительно выделенного фрагмента мы не хотим использовать append
, поскольку это добавляет индекс к фрагменту. Таким образом, необходимо изменить предварительно выделенный индекс, чтобы вместо него использовался правильный индекс.
Теперь, когда мы закончили тесты срезов, давайте запустим их и посмотрим на результаты.
Разница между предварительно выделенными фрагментами и динамическими фрагментами огромна. 1000000
Тесты 75388 ns/op
против 7246 ns/op
. Это разница в производительности в 10,4 раза выше скорости. Однако в некоторых случаях работа с фрагментами фиксированного размера может вызывать затруднения. Обычно я не знаю размера своих приложений, поскольку они обычно динамические.
Кажется, что срезы превосходят карты, когда дело доходит до вставки данных - как для небольших, так и для больших чисел. Нам также необходимо сравнить, как работает выбор данных.
Чтобы проверить это, мы инициализируем срез и карту, как мы это делали, добавляем X
количество элементов, а затем сбрасываем таймер. Затем мы начнем сравнивать, насколько быстро мы можем найти X
элементов. Я решил перебрать срез и сопоставить, используя значение индекса i
. Я опубликую код обоих тестов ниже - они почти идентичны.
Если вы заметили, код для selectXIntSlice
и selectXIntMap
одинаковый - единственная разница - это команда make
. Однако разница в производительности для этих двух устройств очевидна.
Сравнение результатов тестов
Итак, теперь у нас есть контрольные цифры - давайте сгруппируем их в таблицу, чтобы ее было легче просматривать.
Так какая разница между фрагментами и картами?
Срезы быстрее в 21,65 раза (1321196/75388) при сравнении производительности записи в динамический размер.
Срезы быстрее в 118,35 раза (857588/7246) при сравнении производительности записи в заранее выделенный размер.
Срезы быстрее в 177,19 раза (507843/2866) при сравнении скорости чтения.
Срезы или карты быстрее?
Срезы, похоже, намного превосходят карты при использовании этих тестов. Разница настолько велика, что я думаю, что, должно быть, как-то облажался с этими тестами.
Однако карты проще в использовании. В этих тестах мы предполагаем, что нам известны индексы в срезе, которые нужно использовать. Я могу вспомнить множество случаев, когда мы не знаем индекса и, вероятно, придется перебирать весь фрагмент, например, map[userID]User
вместо цикла for
над []User
.
Влияет ли размер на скорость срезов и карт?
Размер в этих случаях не имеет значения.
Имеет ли значение тип ключа, используемый на картах?
Да. Использование целого числа оказалось в 2,23 раза быстрее, чем интерфейс.
Добавление более реалистичного варианта использования
Таким образом, срезы кажутся намного более производительными, но я буду честен - я вряд ли когда-либо узнаю правильный индекс для своих срезов. В большинстве случаев мне приходится перебирать весь фрагмент, чтобы найти то, что я ищу. Это основная причина, по которой я часто использую карту.
Я собираюсь создать тест с этим вариантом использования. У нас будут map[userID]User
и []User
. Тестом будет гонка по поиску определенного пользователя.
Я создал новый файл, содержащий код для генерации случайных пользователей. Я создам 10 000, 100 000 и 1 миллион пользователей в срезе и на карте. Представьте, что если бы у нас был API, нам отправили идентификатор пользователя, и мы хотели бы найти этого пользователя. Это сценарий, который мы и проверим. Я также буду перетасовать фрагмент, поскольку это имитирует реальный вариант использования, когда данные добавляются динамически.
Я называю этот тест «Спасение рядового Райана». Нам нужно его найти, а у него ID пользователя 7777
.
Как видите, срез больше не превосходит карту. Если вы посмотрите на результат, вы увидите, что карта имеет тенденцию сохранять одну и ту же скорость независимо от того, сколько на ней элементов, в то время как срез занимает больше времени для каждого добавленного элемента.
В этом случае карта более производительна с коэффициентом 6678,53x (140917 / 21,1).
Заключение
Срезы или карты быстрее?
Срезы гораздо более производительны, когда дело доходит до чистой мощности, но менее сложны и сложнее в использовании - как это продемонстрировано с помощью нашего теста «Спасти рядового Райана». Иногда власть - это еще не все.
Я предпочитаю использовать карты, поскольку они обеспечивают легкий доступ к сохраненным значениям. Как это часто бывает в программировании, это зависит от вашего варианта использования.
Влияет ли размер на скорость срезов и карт?
К сожалению для меня, моя жена говорит, что размер имеет значение. Мой тест говорит то же самое. При использовании правильного порядкового номера - конечно, не имеет значения. Но если вы не знаете, в каком индексе хранится ваше значение, размер имеет большое значение.
Имеет ли значение тип ключа, используемый на картах?
Да. Использование целого числа оказалось в 2,23 раза быстрее, чем интерфейс.
На сегодня все, надеюсь, вы кое-что узнали о сравнительном анализе. Я точно знаю. Полный код можно найти здесь.
Не забудьте выйти на улицу и оценить мир.