Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Язык Go набирает популярность. Настолько уверенно, что появляется все больше конференций, например, GolangConf, а язык входит в десятку самых высокооплачиваемых технологий. Поэтому уже имеет смысл разговаривать о его специфических проблемах, например, производительности. Кроме общих для всех компилируемых языков проблем, у Go есть и свои собственные. Они связаны с оптимизатором, стеком, системой типов и моделью многозадачности. Способы их решения и обхода иногда бывают весьма специфическими.
Даниил Подольский, хоть и евангелист Go, тоже встречает в нем много странного. Все странное и, главное, интересное собирает и тестирует, а потом рассказывает об этом на HighLoad++. В расшифровке доклада будут цифры, графики, примеры кода, результаты работы профайлера, сравнение производительности одних и тех же алгоритмов на разных языках — и все остальное, за что мы так ненавидим слово «оптимизация». В  расшифровке не будет откровений — откуда они в таком простом языке, — и всего, о чем можно прочесть в газетах.
О спикерах. Даниил Подольский: 26 лет стажа, 20 в эксплуатации, в том числе, руководителем группы, 5 лет программирует на Go. Кирилл Даншин: создатель Gramework, Maintainer, Fast HTTP, Чёрный Go-маг.
Доклад совместно готовили Даниил Подольский и Кирилл Даншин, но с докладом выступал Даниил, а Кирилл помогал ментально.
У нас есть эталон производительности —
Результат функции — 1,46 нс на операцию. Это минимальный вариант. Быстрее 1,5 нс на операцию, наверное, не получится.
Языковую конструкцию
Но так его использовать нельзя! Каждый defer съедает 40 нс на операцию.
Я подумал, может это из-за inline? Может inline такой быстрый?
Direct инлайнится, а defer-функция инлайниться не может. Поэтому скомпилировал отдельную тестовую функцию без inline.
Ничего не изменилось, defer занял те же 40 нс. Defer дорогой, но не катастрофически.
Но там, где функция занимает больше микросекунды, уже все равно — можно воспользоваться defer.
Рассмотрим популярный миф.
Ничего не изменилось — ничего не стоит.
За исключением 3 нс на defer, но это спишем на флуктуации.
Иногда новички спрашивают: «Анонимная функция — это дорого?»
Есть интерфейс и структура, которая его реализует.
Есть три варианта использовать метод increment. Напрямую от Struct:
От соответствующего конкретного интерфейса:
С runtime конверсией интерфейса:
Ниже runtime конверсия интерфейса и использование напрямую.
Runtime конверсия интерфейса стоит, но не дорого — специально отказываться не надо. Но старайтесь обойтись без этого там, где возможно.
Мифы:
Каждый новичок в Go спрашивает, что будет, если заменить switch на map. Будет быстрее?
Switch бывают разного размера. Я тестировал на трех размерах: маленький на 10 кейсов, средний на 100 и большой на 1000 кейсов. Switch на 1000 кейсов встречаются в реальном продакшн-коде. Конечно, никто руками их не пишет. Это автосгенерированный код, обычно type switch. Протестировал на двух типах: int и string. Показалось, что так получится нагляднее.
Маленький switch.Самый быстрый вариант — собственно switch. Вслед за ним сразу идет slice, где по соответствующему целочисленному индексу лежит ссылка на функцию. Map не в лидерах ни на int, ни на string.
Switch на строках существенно медленнее, чем на int. Если есть возможность сделать switch не на string, а на int, так и поступите.
Средний switch. На int все еще правит собственно switch, но slice его немного обогнал. Map по-прежнему плох. Но на string-ключе map быстрее, чем switch — ожидаемо.
Большой switch. На тысяче кейсов видно безоговорочную победу map в номинации «switch по string». Теоретически победил slice, но практически я советую здесь использовать все тот же switch. Map все еще медленный, даже учитывая, что у map для целочисленных ключей есть специальная функция хэширования. Вообще эта функция ничего и не делает. В качестве хэша для int выступает сам этот int.
Выводы. Map лучше только на больших количествах и не на целочисленном условии. Я уверен, что на любом из условий, кроме int, он будет вести себя также, как на string. Slice рулит всегда, когда условия целочисленные. Используйте его, если хотите «ускорить» свою программу на 2 нс.
Тема сложная, тестов я провел много и представлю самые показательные. Мы знаем следующие средства межгорутинного взаимодействия.
Конечно, я тестировал на существенно большем количестве горутин, которые конкурируют за один ресурс. Но показательными выбрал для себя три: мало — 100, средне — 1000 и много — 10000.
Профиль нагрузки бывает разным. Иногда все горутины хотят писать в одну переменную, но это редкость. Обычно все-таки какие-то пишут, какие-то читают. Из в основном читающих — 90% читают, из пишущих — 90% пишут.
Это код, который используется, чтобы горутина, которая обслуживает канал, могла обеспечить одновременно и чтение из переменной, и запись в нее.
Если к нам приезжает сообщение по каналу, через который мы пишем — выполняем. Если канал закрылся — горутину завершаем. В любой момент мы готовы писать в канал, который используется другими горутинами для чтения.
Это данные по одной горутине. Канальный тест выполняется на двух горутинах: одна обрабатывает Channel, другая в этот Channel пишет. А эти варианты были протестированы на одной.
На малом количестве горутин эффективный и быстрый способ синхронизации все тот же Atomic, что неудивительно. Direct тут нет, потому что нам нужна синхронизация, которую он не обеспечивает. Но у Atomic есть недостатки, конечно.
Следующий — Mutex. Я ожидал, что Channel будет примерно таким же быстрым, как Mutex, но нет.
Причем Channel и буферизованный Channel выходят примерно в одну цену. А есть Channel, у которого буфер никогда не переполняется. Он на порядок дешевле, чем тот, у которого буфер переполняется. Только если буфер в Channel не переполняется, то стоит примерно столько же в порядках величин, сколько Mutex. Это то, чего я ожидал от теста.
Эта картина с распределением того, что сколько стоит, повторяется на любом профиле нагрузки — и на MostlyRead, и на MostlyWrite. Причем полный MostlyRead Channel стоит столько же, сколько и не полный. И MostlyWrite буферизованный Channel, в котором буфер не переполняется, стоит столько же, сколько и остальные. Почему это так, сказать не могу — еще не изучил этот вопрос.
Как быстрее передавать параметры — по ссылке или по значению? Давайте проверим.
Я проверял следующим образом — сделал вложенные типы от 1 до 10.
В десятом вложенном типе будет 10 полей int64, и вложенных типов предыдущей вложенности тоже 10.
Дальше написал функции, которые создают тип вложенности.
Для тестирования использовал три варианта типа: маленький с вложенностью 2, средний с вложенностью 3, большой с вложенностью 5. Очень большой тест с вложенность 10 пришлось ставить на ночь, но там картина точно такая же как для 5.
В функциях передача по значению минимум вдвое быстрее, чем передача по ссылке. Связано это с тем, что передача по значению не нагружает escape-анализ. Соответственно, переменные, которые мы выделяем, оказываются на стеке. Это существенно дешевле для runtime, для garbage collector. Хотя он может и не успеть подключиться. Эти тесты шли несколько секунд — garbage collector, наверное, еще спал.
Знаете ли вы, что выведет эта программа?
Результат программы зависит от архитектуры, на которой она исполняется. На little endian, например, AMD64, программа выводит . На big endian — единицу. Результат разный, потому что на little endian эта единица оказывается в середине числа, а на big endian — в конце.
На свете все еще существуют процессоры, у которых endian переключается, например, Power PC. Выяснять, что за endian сконфигурирован на вашем компьютере, придется во время старта, прежде чем делать умозаключения, что делают такого рода unsafe-фокусы. Например, если вы напишите Go-код, который будет исполняться на каком-нибудь многопроцессорном сервере IBM.
Я привел этот код, чтобы объяснить, почему я считаю весь unsafe черной магией. Пользоваться им не надо. Но Кирилл считает, что надо. И вот почему.
Есть некая функция, которая делает то же самое, что и GOB — Go Binary Marshaller. Это Encoder, но на unsafe.
Фактически она берет кусок памяти и изображает из него массив байт.
Это даже не порядок — это два порядка. Поэтому Кирилл Даншин, когда пишет высокопроизводительный код, не стесняется залезть в кишки своей программы и устроить ей unsafe.
Даниил Подольский, хоть и евангелист Go, тоже встречает в нем много странного. Все странное и, главное, интересное собирает и тестирует, а потом рассказывает об этом на HighLoad++. В расшифровке доклада будут цифры, графики, примеры кода, результаты работы профайлера, сравнение производительности одних и тех же алгоритмов на разных языках — и все остальное, за что мы так ненавидим слово «оптимизация». В  расшифровке не будет откровений — откуда они в таком простом языке, — и всего, о чем можно прочесть в газетах.
О спикерах. Даниил Подольский: 26 лет стажа, 20 в эксплуатации, в том числе, руководителем группы, 5 лет программирует на Go. Кирилл Даншин: создатель Gramework, Maintainer, Fast HTTP, Чёрный Go-маг.
Доклад совместно готовили Даниил Подольский и Кирилл Даншин, но с докладом выступал Даниил, а Кирилл помогал ментально.
Языковые конструкции
У нас есть эталон производительности —
direct
. Это функция, которая инкрементирует переменную и больше не делает ничего.// эталон производительности
var testInt64 int64
func BenchmarkDirect(b *testing.B) {
for i := 0; i < b.N; i++ {
incDirect()
}
}
func incDirect() {
testInt64++
}
Результат функции — 1,46 нс на операцию. Это минимальный вариант. Быстрее 1,5 нс на операцию, наверное, не получится.
Defer, как мы его любим
Языковую конструкцию
defer
многие знают и любят использовать. Довольно часто мы её используем так.func BenchmarkDefer(b *testing.B) {
for i := 0; i < b.N; i++ {
incDefer()
}
}
func incDefer() {
defer incDirect()
}
Но так его использовать нельзя! Каждый defer съедает 40 нс на операцию.
// эталон производительности
BenchmarkDirect-4 2000000000 1.46 нс/оп
// defer
BenchmarkDefer-4 30000000 40.70 нс/оп
Я подумал, может это из-за inline? Может inline такой быстрый?
Direct инлайнится, а defer-функция инлайниться не может. Поэтому скомпилировал отдельную тестовую функцию без inline.
func BenchmarkDirectNoInline(b *testing.B) {
for i := 0; i < b.N; i++ {
incDirectNoInline()
}
}
//go:noinline
func incDirectNoInline() {
testInt64++
}
Ничего не изменилось, defer занял те же 40 нс. Defer дорогой, но не катастрофически.
Там, где функция занимает меньше 100 нс, можно обойтись и без defer.
Но там, где функция занимает больше микросекунды, уже все равно — можно воспользоваться defer.
Передача параметра по ссылке
Рассмотрим популярный миф.
func BenchmarkDirectByPointer(b *testing.B) {
for i := 0; i < b.N; i++ {
incDirectByPointer(&testInt64)
}
}
func incDirectByPointer(n *int64) {
*n++
}
Ничего не изменилось — ничего не стоит.
// передача параметра по ссылке
BenchmarkDirectByPointer-4 2000000000 1.47 нс/оп
BenchmarkDeferByPointer-4 30000000 43.90 нс/оп
За исключением 3 нс на defer, но это спишем на флуктуации.
Анонимные функции
Иногда новички спрашивают: «Анонимная функция — это дорого?»
func BenchmarkDirectAnonymous(b *testing.B) {
for i := 0; i < b.N; i++ {
func() {
testInt64++
}()
}
}
Анонимная функция не дорогая, занимает 40,4 нс.
Интерфейсы
Есть интерфейс и структура, которая его реализует.
type testTypeInterface interface {
Inc()
}
type testTypeStruct struct {
n int64
}
func (s *testTypeStruct) Inc() {
s.n++
}
Есть три варианта использовать метод increment. Напрямую от Struct:
var testStruct = testTypeStruct{}
. От соответствующего конкретного интерфейса:
var testInterface testTypeInterface = &testStruct
.С runtime конверсией интерфейса:
var testInterfaceEmpty interface{} = &testStruct
.Ниже runtime конверсия интерфейса и использование напрямую.
func BenchmarkInterface(b *testing.B) {
for i := 0; i < b.N; i++ {
testInterface.Inc()
}
}
func BenchmarkInterfaceRuntime(b *testing.B) {
for i := 0; i < b.N; i++ {
testInterfaceEmpty.(testTypeInterface).Inc()
}
}
Интерфейс, как таковой, ничего не стоит.
// интерфейс
BenchmarkStruct-4 2000000000 1.44 нс/оп
BenchmarkInterface-4 2000000000 1.88 нс/оп
BenchmarkInterfaceRuntime-4 200000000 9.23 нс/оп
Runtime конверсия интерфейса стоит, но не дорого — специально отказываться не надо. Но старайтесь обойтись без этого там, где возможно.
Мифы:
- Dereference — разыменование указателей — бесплатно.
- Анонимные функции — бесплатно.
- Интерфейсы — бесплатно.
- Runtime конверсия интерфейса — НЕ бесплатно.
Switch, map и slice
Каждый новичок в Go спрашивает, что будет, если заменить switch на map. Будет быстрее?
Switch бывают разного размера. Я тестировал на трех размерах: маленький на 10 кейсов, средний на 100 и большой на 1000 кейсов. Switch на 1000 кейсов встречаются в реальном продакшн-коде. Конечно, никто руками их не пишет. Это автосгенерированный код, обычно type switch. Протестировал на двух типах: int и string. Показалось, что так получится нагляднее.
Маленький switch.Самый быстрый вариант — собственно switch. Вслед за ним сразу идет slice, где по соответствующему целочисленному индексу лежит ссылка на функцию. Map не в лидерах ни на int, ни на string.
BenchmarkSwitchIntSmall-4 | 500000000 | 3.26 нс/оп |
BenchmarkMapIntSmall-4 | 100000000 | 11.70 нс/оп |
BenchmarkSliceIntSmall-4 | 500000000 | 3.85 нс/оп |
BenchmarkSwitchStringSmall-4 | 100000000 | 12.70 нс/оп |
BenchmarkMapStringSmall-4 | 100000000 | 15.60 нс/оп |
Switch на строках существенно медленнее, чем на int. Если есть возможность сделать switch не на string, а на int, так и поступите.
Средний switch. На int все еще правит собственно switch, но slice его немного обогнал. Map по-прежнему плох. Но на string-ключе map быстрее, чем switch — ожидаемо.
BenchmarkSwitchIntMedium-4 | 300000000 | 4.55 нс/оп |
BenchmarkMapIntMedium-4 | 100000000 | 17.10 нс/оп |
BenchmarkSliceIntMedium-4 | 300000000 | 3.76 нс/оп |
BenchmarkSwitchStringMedium-4 | 50000000 | 28.50 нс/оп |
BenchmarkMapStringMedium-4 | 100000000 | 20.30 нс/оп |
Большой switch. На тысяче кейсов видно безоговорочную победу map в номинации «switch по string». Теоретически победил slice, но практически я советую здесь использовать все тот же switch. Map все еще медленный, даже учитывая, что у map для целочисленных ключей есть специальная функция хэширования. Вообще эта функция ничего и не делает. В качестве хэша для int выступает сам этот int.
BenchmarkSwitchIntLarge-4 | 100000000 | 13.6 нс/оп |
BenchmarkMapIntLarge-4 | 50000000 | 34.3 нс/оп |
BenchmarkSliceIntLarge-4 | 100000000 | 12.8 нс/оп |
BenchmarkSwitchStringLarge-4 | 20000000 | 100.0 нс/оп |
BenchmarkMapStringLarge-4 | 30000000 | 37.4 нс/оп |
Выводы. Map лучше только на больших количествах и не на целочисленном условии. Я уверен, что на любом из условий, кроме int, он будет вести себя также, как на string. Slice рулит всегда, когда условия целочисленные. Используйте его, если хотите «ускорить» свою программу на 2 нс.
Межгорутинное взаимодействие
Тема сложная, тестов я провел много и представлю самые показательные. Мы знаем следующие средства межгорутинного взаимодействия.
- Atomic. Это средства ограниченной применимости — можно заменить указатель или использовать int.
- Mutex используем широко со времен Java.
- Channel уникальны для GO.
- Buffered Channel — буферизованные каналы.
Конечно, я тестировал на существенно большем количестве горутин, которые конкурируют за один ресурс. Но показательными выбрал для себя три: мало — 100, средне — 1000 и много — 10000.
Профиль нагрузки бывает разным. Иногда все горутины хотят писать в одну переменную, но это редкость. Обычно все-таки какие-то пишут, какие-то читают. Из в основном читающих — 90% читают, из пишущих — 90% пишут.
Это код, который используется, чтобы горутина, которая обслуживает канал, могла обеспечить одновременно и чтение из переменной, и запись в нее.
go func() {
for {
select {
case n, ok := <-cw:
if !ok {
wgc.Done()
return
}
testInt64 += n
case cr <- testInt64:
}
}
}()
Если к нам приезжает сообщение по каналу, через который мы пишем — выполняем. Если канал закрылся — горутину завершаем. В любой момент мы готовы писать в канал, который используется другими горутинами для чтения.
BenchmarkMutex-4 | 100000000 | 16.30 нс/оп |
BenchmarkAtomic-4 | 200000000 | 6.72 нс/оп |
BenchmarkChan-4 | 5000000 | 239.00 нс/oп |
Это данные по одной горутине. Канальный тест выполняется на двух горутинах: одна обрабатывает Channel, другая в этот Channel пишет. А эти варианты были протестированы на одной.
- Direct пишет в переменную.
- Mutex берет лог, пишет в переменную и отпускает лог.
- Atomic пишет в переменную через Atomic. Он не бесплатный, но все-таки существенно дешевле Mutex на одной гарутине.
На малом количестве горутин эффективный и быстрый способ синхронизации все тот же Atomic, что неудивительно. Direct тут нет, потому что нам нужна синхронизация, которую он не обеспечивает. Но у Atomic есть недостатки, конечно.
BenchmarkMutexFew-4 | 30000 | 55894 нс/оп |
BenchmarkAtomicFew-4 | 100000 | 14585 нс/оп |
BenchmarkChanFew-4 | 5000 | 323859 нс/оп |
BenchmarkChanBufferedFew-4 | 5000 | 341321 нс/оп |
BenchmarkChanBufferedFullFew-4 | 20000 | 70052 нс/оп |
BenchmarkMutexMostlyReadFew-4 | 30000 | 56402 нс/оп |
BenchmarkAtomicMostlyReadFew-4 | 1000000 | 2094 нс/оп |
BenchmarkChanMostlyReadFew-4 | 3000 | 442689 нс/оп |
BenchmarkChanBufferedMostlyReadFew-4 | 3000 | 449666 нс/оп |
BenchmarkChanBufferedFullMostlyReadFew-4 | 5000 | 442708 нс/оп |
BenchmarkMutexMostlyWriteFew-4 | 20000 | 79708 нс/оп |
BenchmarkAtomicMostlyWriteFew-4 | 100000 | 13358 нс/оп |
BenchmarkChanMostlyWriteFew-4 | 3000 | 449556 нс/оп |
BenchmarkChanBufferedMostlyWriteFew-4 | 3000 | 445423 нс/оп |
BenchmarkChanBufferedFullMostlyWriteFew-4 | 3000 | 414626 нс/оп |
Следующий — Mutex. Я ожидал, что Channel будет примерно таким же быстрым, как Mutex, но нет.
Channel на порядок дороже, чем Mutex.
Причем Channel и буферизованный Channel выходят примерно в одну цену. А есть Channel, у которого буфер никогда не переполняется. Он на порядок дешевле, чем тот, у которого буфер переполняется. Только если буфер в Channel не переполняется, то стоит примерно столько же в порядках величин, сколько Mutex. Это то, чего я ожидал от теста.
Эта картина с распределением того, что сколько стоит, повторяется на любом профиле нагрузки — и на MostlyRead, и на MostlyWrite. Причем полный MostlyRead Channel стоит столько же, сколько и не полный. И MostlyWrite буферизованный Channel, в котором буфер не переполняется, стоит столько же, сколько и остальные. Почему это так, сказать не могу — еще не изучил этот вопрос.
Передача параметров
Как быстрее передавать параметры — по ссылке или по значению? Давайте проверим.
Я проверял следующим образом — сделал вложенные типы от 1 до 10.
type TP001 struct {
I001 int64
}
type TV002 struct {
I001 int64
S001 TV001
I002 int64
S002 TV001
}
В десятом вложенном типе будет 10 полей int64, и вложенных типов предыдущей вложенности тоже 10.
Дальше написал функции, которые создают тип вложенности.
func NewTP001() *TP001 {
return &TP001{
I001: rand.Int63(),
}
}
func NewTV002() TV002 {
return TV002{
I001: rand.Int63(),
S001: NewTV001(),
I002: rand.Int63(),
S002: NewTV001(),
}
}
Для тестирования использовал три варианта типа: маленький с вложенностью 2, средний с вложенностью 3, большой с вложенностью 5. Очень большой тест с вложенность 10 пришлось ставить на ночь, но там картина точно такая же как для 5.
В функциях передача по значению минимум вдвое быстрее, чем передача по ссылке. Связано это с тем, что передача по значению не нагружает escape-анализ. Соответственно, переменные, которые мы выделяем, оказываются на стеке. Это существенно дешевле для runtime, для garbage collector. Хотя он может и не успеть подключиться. Эти тесты шли несколько секунд — garbage collector, наверное, еще спал.
BenchmarkCreateSmallByValue-4 | 200000 | 8942 нс/оп |
BenchmarkCreateSmallByPointer-4 | 100000 | 15985 нс/оп |
BenchmarkCreateMediuMByValue-4 | 2000 | 862317 нс/оп |
BenchmarkCreateMediuMByPointer-4 | 2000 | 1228130 нс/оп |
BenchmarkCreateLargeByValue-4 | 30 | 47398456 нс/оп |
BenchmarkCreateLargeByPointer-4 | 20 | 61928751 нс/op |
Черная магия
Знаете ли вы, что выведет эта программа?
package main
type A struct {
a, b int32
}
func main() {
a := new(A)
a.a = 0
a.b = 1
z := (*(*int64)(unsafe.Pointer(a)))
fmt.Println(z)
}
Результат программы зависит от архитектуры, на которой она исполняется. На little endian, например, AMD64, программа выводит . На big endian — единицу. Результат разный, потому что на little endian эта единица оказывается в середине числа, а на big endian — в конце.
На свете все еще существуют процессоры, у которых endian переключается, например, Power PC. Выяснять, что за endian сконфигурирован на вашем компьютере, придется во время старта, прежде чем делать умозаключения, что делают такого рода unsafe-фокусы. Например, если вы напишите Go-код, который будет исполняться на каком-нибудь многопроцессорном сервере IBM.
Я привел этот код, чтобы объяснить, почему я считаю весь unsafe черной магией. Пользоваться им не надо. Но Кирилл считает, что надо. И вот почему.
Есть некая функция, которая делает то же самое, что и GOB — Go Binary Marshaller. Это Encoder, но на unsafe.
func encodeMut(data []uint64) (res []byte) {
sz := len(data) * 8
dh := (*header)(unsafe.Pointer(&data))
rh := &header{
data: dh.data,
len: sz,
cap: sz,
}
res = *(*[]byte)(unsafe.Pointer(&rh))
return
}
Фактически она берет кусок памяти и изображает из него массив байт.
Это даже не порядок — это два порядка. Поэтому Кирилл Даншин, когда пишет высокопроизводительный код, не стесняется залезть в кишки своей программы и устроить ей unsafe.
BenchmarkGob-4 | 200000 | 8466 нс/op | 120.94 МБ/с |
BenchmarkUnsafeMut-4 | 50000000 | 37 нс/op | 27691.06 МБ/с |
Больше специфических особенностей Go будем обсуждать 7 октября на GolangConf — конференции для тех, кто использует Go в профессиональной разработке, и тех, кто рассматривает этот язык в качестве альтернативы. Даниил Подольский как раз входит в Программный комитет, если хотите поспорить с этой статьей или раскрыть смежные вопросы — подавайте заявку на доклад.
Для всего остального, что касается высокой производительности, конечно, HighLoad++. Туда тоже принимаем заявки. Подпишитесь на рассылку и будете в курсе новостей всех наших конференций для веб-разработчиков.