Golang: специфические вопросы производительности

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

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

Даниил Подольский, хоть и евангелист Go, тоже встречает в нем много странного. Все странное и, главное, интересное собирает и тестирует, а потом рассказывает об этом на HighLoad++. В расшифровке доклада будут цифры, графики, примеры кода, результаты работы профайлера, сравнение производительности одних и тех же алгоритмов на разных языках — и все остальное, за что мы так ненавидим слово «оптимизация». В&nbsp расшифровке не будет откровений — откуда они в таком простом языке, — и всего, о чем можно прочесть в газетах.



О спикерах. Даниил Подольский: 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, программа выводит $2^{32}$. На 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++. Туда тоже принимаем заявки. Подпишитесь на рассылку и будете в курсе новостей всех наших конференций для веб-разработчиков.
Источник: https://habr.com/ru/company/oleg-bunin/blog/461291/


Интересные статьи

Интересные статьи

СЕГОДНЯ, 27 октября, в 20:00 в наших соцсетях выступит Алсу Миссарова — PhD по системной биологии (functional genomics in yeast) из Университета Ponepu Fabra в Барселоне. Сейчас Алс...
Многие компании в определенный момент приходят к тому, что ряд процессов в бизнесе нужно автоматизировать, чтобы не потерять свое место под солнцем и своих заказчиков. Поэтому все...
Автор статьи Dave Troy — исследователь онлайн-дезинформации и лжи, а также эксперт в области расчётов, связанных со статистикой болезней. В последнее время он отвечал на многие вопросы о Covid-19...
В ноябре мы задумали собрать большой пост с ответами на вопросы о том, как жить, если ты индивидуальный предприниматель (ИП). Мы попросили пользователей Хабра набросать нам вопросы из серии «хоте...
Практически все коммерческие интернет-ресурсы создаются на уникальных платформах соответствующего типа. Среди них наибольшее распространение получил Битрикс24.