Язык Go набирает популярность. Настолько уверенно, что появляется все больше конференций, например, GolangConf, а язык входит в десятку самых высокооплачиваемых технологий. Поэтому уже имеет смысл разговаривать о его специфических проблемах, например, производительности. Кроме общих для всех компилируемых языков проблем, у Go есть и свои собственные. Они связаны с оптимизатором, стеком, системой типов и моделью многозадачности. Способы их решения и обхода иногда бывают весьма специфическими.
Даниил Подольский, хоть и евангелист 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++. Туда тоже принимаем заявки. Подпишитесь на рассылку и будете в курсе новостей всех наших конференций для веб-разработчиков.
Автор: Олег Бунин