company_banner

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

    Язык 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, программа выводит $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++. Туда тоже принимаем заявки. Подпишитесь на рассылку и будете в курсе новостей всех наших конференций для веб-разработчиков.

    Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

    Как быстрее передавать параметры: по ссылке или по значению?

    • +29
    • 5,3k
    • 5
    Конференции Олега Бунина (Онтико)
    759,83
    Конференции Олега Бунина
    Поделиться публикацией

    Комментарии 5

      0

      А в чем смысл опроса?

        –1
        Проверка, как вы прочитали статью :))
        +1

        Некоторые выводы некорректны, т.к. основаны на эффектах ssa-оптимизации компилятором настолько простых примеров. Нужно было взять немного более сложные.

          0
          Связано это с тем, что передача по значению не нагружает escape-анализ.

          ясно-понятно
            0

            Да, меня тоже удивило, ведь escape analysis идёт во время компиляции...

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое